---
title: Transform the response body on every page
description: "Implement IResponseProcessor to rewrite the final response body as a string — inject HTML before </body>, log an outgoing payload, or append a non-HTML footer."
canonical_url: https://usepennington.net/how-to/response-pipeline/response-processor/
sidecar_url: https://usepennington.net/how-to/response-pipeline/response-processor.md
content_hash: sha256:2300993212d3cebaab263fd0f786383bc1cfece03eb5451ba7de427dd2c66334
tokens: 1830
uid: how-to.response-pipeline.response-processor
reading_time_minutes: 3
---

Guides
# Transform the response body on every page

Implement IResponseProcessor to rewrite the final response body as a string — inject HTML before </body>, log an outgoing payload, or append a non-HTML footer.

 
To transform the final response body on every rendered page, implement `IResponseProcessor`. The processor receives the full body as a string and returns the replacement — use it to insert a pre-serialized HTML fragment before `</body>`, log an outgoing payload, or append a non-HTML footer. When the work is DOM-shaped (anchor rewrites, attribute additions, element injection at a CSS selector), implement `IHtmlResponseRewriter` instead so every rewriter shares one AngleSharp parse. See [Rewrite HTML attributes after parsing](https://usepennington.net/how-to/response-pipeline/html-rewriter.md).

 
The recipe references `examples/ExtensibilityLabExample/FeedbackWidgetProcessor.cs`, which injects a "Was this helpful?" aside before `</body>` against a bare `AddPennington` host.

 
## Before you begin

 
 - An existing Pennington site (see [Create your first Pennington site](https://usepennington.net/tutorials/getting-started/first-site.md) if not).
 - The response pipeline buffers the full response body before the processor runs. This is fine for HTML pages but unsuitable for large binary streams — gate those out in `ShouldProcess`.
 
 
## Write the processor

 
Implement [Pennington.Infrastructure.IResponseProcessor](https://usepennington.net/reference/api/i-response-processor.md) as a sealed class. Two rules carry the page:

 
 - `ShouldProcess` runs before the body is buffered. Returning `false` skips body capture entirely, so this is where filtering by status code, content type, or request path belongs. The example accepts only 2xx HTML responses, letting static assets, JSON endpoints, and redirects pass through untouched.
 - `ProcessAsync` receives the full captured body as a string and returns the replacement. The example locates the last `</body>` with `LastIndexOf` and inserts the widget HTML there, falling back to append-at-end when the tag is absent so content still reaches the browser.
 
 
```csharp:symbol
namespace ExtensibilityLabExample;
  
using System.Text;
using Microsoft.AspNetCore.Http;
using Pennington.Infrastructure;
  
/// <summary>
/// Implements <see cref="IResponseProcessor"/>. Injects a
/// "Was this helpful?" footer before the closing <c>&lt;/body&gt;</c>
/// tag of every rendered HTML page.
/// <para>
/// Runs at <see cref="Order"/> 500 — after the xref/locale/base-URL HTML
/// rewriting processor (<c>HtmlResponseRewritingProcessor</c>) so the
/// injected HTML is not subject to any further pipeline passes in this
/// app, and well before the live-reload and diagnostic-overlay
/// processors at 1000+.
/// </para>
/// <para>
/// <see cref="ShouldProcess"/> gates on content type: text/html only,
/// and only for 2xx responses. Static assets and API JSON skip through.
/// </para>
/// <para>
/// Backs how-to 2.3.40 <c>/how-to/extensibility/response-processor</c>.
/// </para>
/// </summary>
public sealed class FeedbackWidgetProcessor : IResponseProcessor
{
    private const string WidgetHtml = """
        <aside class="feedback-widget" data-extensibility-lab="feedback-widget">
          <p><strong>Was this helpful?</strong>
            <button type="button" data-feedback="yes">Yes</button>
            <button type="button" data-feedback="no">No</button>
          </p>
        </aside>
        """;
  
    public int Order => 500;
  
    public bool ShouldProcess(HttpContext context)
    {
        if (context.Response.StatusCode is < 200 or >= 300)
        {
            return false;
        }
  
        var contentType = context.Response.ContentType;
        return contentType is not null
               && contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase);
    }
  
    public Task<string> ProcessAsync(string responseBody, HttpContext context)
    {
        if (string.IsNullOrEmpty(responseBody))
        {
            return Task.FromResult(responseBody);
        }
  
        var closeBodyIndex = responseBody.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
        if (closeBodyIndex < 0)
        {
            // No </body> — append at end. Still visible, still verifiable.
            return Task.FromResult(responseBody + WidgetHtml);
        }
  
        var sb = new StringBuilder(responseBody.Length + WidgetHtml.Length);
        sb.Append(responseBody, 0, closeBodyIndex);
        sb.Append(WidgetHtml);
        sb.Append(responseBody, closeBodyIndex, responseBody.Length - closeBodyIndex);
        return Task.FromResult(sb.ToString());
    }
}
```

 
## Pick an Order value

 
Slot into the `Order` sequence so the processor sees the HTML state it expects. Anything below `10` would see un-resolved `<xref:...>` placeholders that `HtmlResponseRewritingProcessor` expands. The example uses `500` so the widget is inserted after every built-in pass has run. For the full table of shipped `Order` values, see [Pennington.Infrastructure.IResponseProcessor](https://usepennington.net/reference/api/i-response-processor.md).

 
## Register the processor

 
Every registered `IResponseProcessor` is picked up and ordered by its `Order` value, so a single registration is the entire wiring step. Use the lifetime that matches your dependencies — `AddSingleton` for stateless processors, `AddTransient` (or `AddFileWatched`) when the processor captures file-watched state.

 
```csharp
builder.Services.AddSingleton<IResponseProcessor, FeedbackWidgetProcessor>();
```

 
## Result

 
Every `text/html` response carries the widget aside immediately before its closing `</body>` tag:

 
```html
<aside class="feedback-widget" data-extensibility-lab="feedback-widget">
      <p><strong>Was this helpful?</strong>
        <button type="button" data-feedback="yes">Yes</button>
        <button type="button" data-feedback="no">No</button>
      </p>
    </aside>
  </body>
</html>
```

 
Non-HTML endpoints (`/styles.css`, `/sitemap.xml`) are unmodified because `ShouldProcess` returns `false` for them.

 
## Verify

 
 - Run `dotnet run --project examples/ExtensibilityLabExample` and visit `/`. The rendered HTML contains `<aside class="feedback-widget" data-extensibility-lab="feedback-widget">` immediately before `</body>`; fetch `/styles.css` and the aside is absent.
 - Static build: `dotnet run --project examples/ExtensibilityLabExample -- build output` — grep `output/index.html` for `data-extensibility-lab="feedback-widget"` to confirm the processor runs during publish as well as dev.
 
 
## Related

 
 - Reference: [Response processing interfaces](https://usepennington.net/reference/api/i-response-processor.md)
 - Background: [The response-processing pipeline](https://usepennington.net/explanation/core/response-processing.md)
 - Related how-to: [Write an HTML rewriter](https://usepennington.net/how-to/response-pipeline/html-rewriter.md)
 
 
[Previous
                
                Rewrite HTML attributes after parsing](https://usepennington.net/how-to/response-pipeline/html-rewriter.md)[Next
                    
                Customize the DocSite chrome through DocSiteOptions](https://usepennington.net/how-to/response-pipeline/override-docsite-components.md)