Adopting the response pipeline
This release rebuilds the rendered-response path. @Render() used to decide six things in one interceptor — what a controller value meant, what request data was client-visible, whether a request wanted JSON (by substring match), how layouts resolved, and how to write an adapter-specific response. Those are now separate stages you can reason about one at a time.
Nothing in this release changes what a working application serves. There is no migration to perform. Everything below is new capability that applications may adopt independently.
The defaults that should change — conservative caching and enforced payload limits — are deliberately held back. Existing controller shapes also retain their historical Accept behavior. The new explicit factories have no prior wire contract, so they use correct standards-aware ranking immediately.
What you get without changing anything
- Distinct HTML and JSON payloads are now expressible (below).
timeoutfinally does something. It was accepted and effectively inert; a render now gets a realAbortSignal, and abort-aware work stops when the client disconnects or the deadline passes.- Client-side navigation validates segments before applying them — schema version, size, and whether the swap target exists in the current layout tree — and writes them through a single Trusted Types sink. A response that fails any check becomes a full navigation instead of a partly applied fragment.
- Credential headers can no longer leak into the render context, even if
allowedHeadersnames one. - Express and Fastify actually behave identically, including a fixed bug where
Varylost its first field on Fastify. - Payload diagnostics. Values outside the configured serialization rules are logged with the property path and never the value.
Distinct payloads per representation
Page props are a view model; an API body is a contract. When they should differ:
import { Render, api, page, representations } from '@nestjs-ssr/react';
@Get(':slug')
@Render(RecipeDetail)
getRecipe(@Param('slug') slug: string) {
const recipe = this.recipes.findBySlug(slug);
const chef = this.chefs.findById(recipe.chefId);
return representations({
html: page({ props: { recipe, chef }, head: { title: recipe.name } }),
json: api(() => ({ slug: recipe.slug, chef: chef.name })),
});
}html and json keep independent static types, and only the representation the request negotiates is built — passing a function defers the work entirely. The function receives the request's AbortSignal, allowing data work to stop on a deadline or disconnect.
Plain props and RenderResponse keep working exactly as before.
Narrowing the render context
Whatever your context factory returns is serialized into the page. If it hands over a domain object, this hook is where you narrow it — and it applies to HTML hydration, JSON, and segments alike:
RenderModule.forRoot({
context: ({ req }) => ({ user: req.user }),
projectContext: ({ context }) => ({
...context,
user: context.user && { id: context.user.id, name: context.user.name },
}),
});Request header bags
Allowed headers are available at context.headers['x-tenant-id'] as well as the established context['x-tenant-id'] alias. Reserved names such as path remain isolated in the bag and cannot shadow URL context.
Both access forms remain supported. useHeader() and useHeaders() read the bag and fall back to the top-level shape.
Payload limits: warned, not enforced
Client-visible payloads are checked against the same rules the serializers actually have. A payload is reported — never refused — when it contains a function, a symbol, an un-awaited promise, binary data, or a prototype-polluting key; when it exceeds 2 MiB or 64 levels of nesting; or, for JSON specifically, when it holds a Map, Set, RegExp, bigint, or a cycle that JSON cannot represent.
The report names the property path and never its value:
Cannot serialize props: functions cannot cross the response boundary at
props.user.onClick. This is allowed through because representation.limits.mode
is 'warn'; it will be refused once the mode is 'enforce'.Run on warn — the default — long enough to see whether anything real trips it. When your logs are quiet, turn it on:
RenderModule.forRoot({
representation: { limits: { mode: 'enforce' } },
});Per-route, and tightenable but never raisable above the module's limit:
@Render(Widget, {
representation: { limits: { mode: 'enforce', maxBytes: 32 * 1024 } },
})Response headers: available, off by default
The pipeline can apply a cache stance and security headers, and composes them with whatever your middleware already set — it never overwrites a header the application provided. It emits nothing at all until you ask:
RenderModule.forRoot({
representation: {
cache: { visibility: 'private' }, // Cache-Control: private, no-store
securityHeaders: { referrerPolicy: 'no-referrer' },
},
});Declaring either one turns the stage on for that scope. A route can opt into public caching, and its cache keys are combined with the headers negotiation already depends on:
@Render(PublicPage, {
representation: {
cache: { visibility: 'public', maxAge: 300, keys: ['Accept-Language'] },
},
})Vary itself is unchanged — Accept and X-Current-Layouts were always sent.
Two supported JSON configuration styles
jsonApi remains supported and still serves page props as the JSON body. representation.json is an additive alternative for applications that also want limits, cache policy, security headers, or independent API DTOs.
// Existing style
RenderModule.forRoot({ jsonApi: true });
@Render(Page, { jsonApi: false })
// Policy style
RenderModule.forRoot({ representation: { json: true } });
@Render(Page, { representation: { json: false } })Raw controller strings
A @Render() route that returns a string preserves the existing passthrough contract. Because the string bypasses projection, payload limits, and response policy, applications should only use it for already-safe, deliberate output.
@Get('legacy')
@Render(Page)
getLegacy() { return this.renderer.toHtml(); }
// A plain route is also available when @Render() adds no value.
@Get('legacy')
@Header('Content-Type', 'text/html')
getLegacy() { return this.renderer.toHtml(); }