Inject HTML before </body> on every page
To inject a feedback widget, banner, or analytics tag before </body> on every rendered page, implement IResponseProcessor. The processor receives the full response body as a string and returns the replacement — useful when the goal is to splice a pre-serialized HTML fragment, 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.
Before you begin
- An existing Pennington site. See the Create your first Pennington site tutorial if not.
ResponseProcessingMiddlewarebuffers the full response body before the processor runs. This is fine for HTML pages but unsuitable for large binary streams — gate those out inShouldProcess.- The built-in processors and their
Ordervalues:HtmlResponseRewritingProcessorat 10,LiveReloadScriptProcessorat 20 (dev only),DiagnosticOverlayProcessorat 30 (dev only).
The ExtensibilityLabExample project provides a working reference — FeedbackWidgetProcessor.cs injects a "Was this helpful?" aside before </body> and is registered in Program.cs against a bare AddPennington host.
Implement the processor
IResponseProcessor has three members. The shipped example at examples/ExtensibilityLabExample/FeedbackWidgetProcessor.cs exercises all of them in one sealed type.
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.
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);
}
ProcessAsync receives the full captured body as a string and returns the replacement string — an empty return empties the response. The example locates the last </body> with LastIndexOf and splices the widget HTML in, falling back to append-at-end when the tag is absent so content still reaches the browser.
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
The built-ins occupy 10 (HtmlResponseRewritingProcessor), 20 (LiveReloadScriptProcessor, dev-only), and 30 (DiagnosticOverlayProcessor, dev-only). Slot into the same 10/20/30/40/50 sequence — 40 runs after all three built-ins so the output is not rewritten further, while anything below 10 would see the un-resolved <xref:...> placeholders that HtmlResponseRewritingProcessor expands.
public int Order => 500;
Register the implementation
ResponseProcessingMiddleware resolves every registered IResponseProcessor from the container and sorts by Order on each request, so a single AddSingleton is the entire wiring step.
builder.Services.AddSingleton<IResponseProcessor, FeedbackWidgetProcessor>();
Result
Every text/html response carries the widget aside immediately before its closing </body> tag:
<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, /_spa-data/*.json) are unmodified because ShouldProcess returns false for them.
Verify
- Run
dotnet run --project examples/ExtensibilityLabExampleand visit/. - Expect the rendered HTML to contain
<aside class="feedback-widget" data-extensibility-lab="feedback-widget">immediately before</body>; fetch/styles.cssand confirm the aside is absent (non-HTML content type gated out byShouldProcess). - Static build:
dotnet run --project examples/ExtensibilityLabExample -- build output— grepoutput/index.htmlfordata-extensibility-lab="feedback-widget"to confirm the processor runs during publish as well as dev.
Related
- Reference: Response processing interfaces
- Background: The response-processing pipeline
- Related how-to: Write an HTML rewriter