Skip to content

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.

bash
pnpm add @nestjs-ssr/react@^0.4.0

Node.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:

ts
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:

  1. 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()],
      // ...
    });
  2. Replace src/views/entry-client.tsx with the current template:

    bash
    cp node_modules/@nestjs-ssr/react/src/templates/entry-client.tsx src/views/entry-client.tsx

    The only change is how views are discovered: import.meta.glob(..., { eager: false }) plus loadViewModules() 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:

json
{
  "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.

ts
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 a ReferenceError from the package's ESM build, and production pages rendered without their root layout. Both are fixed.

  • allowedCookies without a cookie parser: cookies were only read from request.cookies, which exists only with cookie-parser or @fastify/cookie. Without one, useCookie() returned nothing. Allowlisted cookies are now read from the Cookie header when no parser is installed.

  • npm, yarn and bun projects: init wrote pnpm-only build and start:dev scripts. It now writes scripts for your package manager.

  • Server performance: server-rendered /recipes takes 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 ETag on no-store responses (Express). Express computed a SHA-1 ETag for every page, even though the library's default Cache-Control: no-store means no client can use it. It is now omitted when the sent Cache-Control contains no-store. Responses you make cacheable keep their ETag and still answer 304. 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 warn mode a violation is logged and served; in enforce mode 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:

  • jsonApi in RenderModule.forRoot(): use representation.json. When both are set, representation.json wins.
  • Static .layout / .layoutProps on page components (and the PageComponentWithLayout type): 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 for T. Use T.

Released under the MIT License.