Upgrading from 0.3 to 0.4
0.4 adds NestJS 12 support and a set of opt-in improvements. It requires Vite 7 or newer and Node.js 22.17 or newer (or 24+). An application on 0.3.x that meets both, upgrades and changes nothing else keeps rendering the same pages, with the same responses, on NestJS 11 or 12.
pnpm add @nestjs-ssr/react@^0.4.0Node.js 22.17 or newer
The serializer behind hydration state (devalue 6) needs Node.js 22.17+. On Node 22.15 or 22.16, upgrade Node first. Node 24 and 26 are unaffected.
Vite 7 or newer
Vite 6 is no longer supported (peer range ^7 || ^8). The per-route client entry waits for the page's code with a top-level await, which Vite 6's default build target rejects. If you are on Vite 6, upgrade Vite and @vitejs/plugin-react first; nothing in this library's configuration changes.
NestJS 12
Both NestJS 11 and 12 are supported (@nestjs/* peer range ^11 || ^12). Upgrade Nest on your own schedule; the library works the same on both.
Nest 12's nest new creates ES module projects ("type": "module"). They work out of the box, and init generates ESM-correct files for them. In an ES module project, relative imports in your own code need the .js extension:
import Home from './views/home.js';Optional: load each page's code separately
By default every view is bundled into the client entry, so every page downloads every other page's code. New projects now load views per route, and the server preloads the current page's chunk so hydration is not delayed. On an app with 30 more pages, a page's initial JavaScript went from 605 KB to 238 KB (95 KB to 75 KB gzipped) — see Performance.
To opt in on an existing project:
Add the Vite plugin. It lets the client and server find a page's module by its component name, whatever the file is called, and keeps those names through minification:
ts// vite.config.ts import { nestjsSsr } from '@nestjs-ssr/react/vite'; export default defineConfig({ plugins: [react(), nestjsSsr()], // ... });Replace
src/views/entry-client.tsxwith the current template:bashcp node_modules/@nestjs-ssr/react/src/templates/entry-client.tsx src/views/entry-client.tsxThe only change is how views are discovered:
import.meta.glob(..., { eager: false })plusloadViewModules()for the rendered page and its layouts.
Keep the old entry-client and nothing changes: the server only adds preload tags when the client entry loads views lazily.
Optional: no restarts when you edit a view
nest start --watch restarts Nest on every compile, and every view edit compiles. nestjs-ssr dev restarts only when something outside views changes; view edits appear through Vite without a restart (0.13–0.2 s instead of a full reboot on the example app). Swap your dev:nest script:
{
"scripts": {
"dev:nest": "NODE_ENV=development nestjs-ssr dev --watchAssets"
}
}The server finds an edited view by its component name, so file names do not have to follow the component (SpecialsList in recipe-list.tsx is fine).
Monorepos with several Nest projects should keep nest start <project> --watch for now.
Optional: error pages in string mode
When a page throws while rendering in string mode, the error has always gone to Nest's exception filters (a JSON 500 by default). Set showErrorPage to answer with an error page instead: in development it shows the error, the failing source lines and a source-mapped stack; in production it is the generic error page with no details.
RenderModule.forRoot({ showErrorPage: true });Leave it off if your own exception filter handles render errors.
Fixes you get automatically
Catch-all routes (a 404 page, an SPA fallback such as
@Get('*path')) answered for every built asset in production and every Vite module request in development, because static serving and the Vite proxy were registered after the application's routes. They now run ahead of the routes.Graceful shutdown dropped requests in flight. Every shutdown closed all open connections, including requests still being answered, so a rolling deploy cut responses off. Production shutdowns now leave draining to Nest; development still closes connections at once so hot reloads stay fast.
ES module apps could not start:
RenderModule.forRoot()threw aReferenceErrorfrom the package's ESM build, and production pages rendered without their root layout. Both are fixed.allowedCookieswithout a cookie parser: cookies were only read fromrequest.cookies, which exists only withcookie-parseror@fastify/cookie. Without one,useCookie()returned nothing. Allowlisted cookies are now read from theCookieheader when no parser is installed.npm, yarn and bun projects:
initwrote pnpm-onlybuildandstart:devscripts. It now writes scripts for your package manager.Server performance: server-rendered
/recipestakes 33% less time per request than on 0.3.31, a 50-item list page 35% less, the JSON response 17% less, measured in a single interleaved run — see Performance. Rendered markup is unchanged; the response differences are listed under the next heading.
Response changes to check
These change what goes over the wire. None affects what the page renders, but check them if you have tests or a proxy that inspect raw responses.
- No
ETagonno-storeresponses (Express). Express computed a SHA-1ETagfor every page, even though the library's defaultCache-Control: no-storemeans no client can use it. It is now omitted when the sentCache-Controlcontainsno-store. Responses you make cacheable keep theirETagand still answer304. Fastify never sent one. window.__HEAD__is omitted when a page has no head data.- JSON hydration state for plain values is serialized by a faster path; the bytes can differ, the hydrated value does not.
- Head and layout props are checked like page props. The merged head (module defaults included) and each layout's props go through the same payload limits. In the default
warnmode a violation is logged and served; inenforcemode it is a 500. Valid values reach your layouts as frozen copies, as page props already do, so a layout that mutates its props now throws. - Client navigation refuses cross-origin redirects. If a segment request is redirected to another origin, the client falls back to a full page load of the original URL instead of applying the response.
Production static files are indexed at startup
On Express in production, the client build directory (dist/client) is listed once at startup, and request paths that are not in it skip the static file handler. A file added to or removed from dist/client while the server is running is not picked up until the process restarts. A normal build-then-deploy is unaffected. If the directory cannot be listed at startup, every request falls back to checking the filesystem, as in 0.3.
Deprecations
Still working in 0.4, scheduled for removal in a later release:
jsonApiinRenderModule.forRoot(): userepresentation.json. When both are set,representation.jsonwins.- Static
.layout/.layoutPropson page components (and thePageComponentWithLayouttype): only the client reads them; the server builds the layout chain from decorators, so a page using only this form hydrates with a different layout chain than it was rendered with. Use@Layout()or@Render(View, { layout }). JsonApiResponse<T>: a no-op alias forT. UseT.