# Vite SSR BOOST > SSR and streaming for existing Vite + React Router apps in Data mode, without Framework mode Package: `@lomray/vite-ssr-boost` Version: `8.4.2` ## Public entrypoints - Vite plugin: `@lomray/vite-ssr-boost/plugin`. - Cloudflare Workers: `@lomray/vite-ssr-boost/cloudflare` (`createWorkerHandler`, `getHtmlFromAssets`, `RouteAssets`, `TRouteAssetsManifest`); see [Cloudflare Workers](https://lomray-software.github.io/vite-ssr-boost/guide/cloudflare). - Browser entry: `@lomray/vite-ssr-boost/browser/entry`. - Managed CLI server entry: `@lomray/vite-ssr-boost/adapters/express/entry`. - Fetch core: default export `createHandler` from `@lomray/vite-ssr-boost/core/handler`. - Transport adapters: `@lomray/vite-ssr-boost/adapters/node`, `adapters/express`, `adapters/fastify`, `adapters/hono` and `adapters/edge`, all under the package prefix. - Renderers: `@lomray/vite-ssr-boost/node/render-to-stream` and `@lomray/vite-ssr-boost/edge/render-to-stream`. - SSR route testing: `@lomray/vite-ssr-boost/testing`; optional browser assertions: `@lomray/vite-ssr-boost/testing/playwright`. See [Testing](https://lomray-software.github.io/vite-ssr-boost/guide/testing). - Node production helpers: `@lomray/vite-ssr-boost/node/production`. - CLI binary: `ssr-boost`. ## Data loading contract Loader and action promises stream by default in Data mode. Return `{ fast, slow: fetchSlow() }` and consume `slow` inside Suspense with `` or React 19 `use()`. The browser reconstructs native promises before creating the router; client navigation loaders keep their native promises. See [Stream loader data](https://lomray-software.github.io/vite-ssr-boost/guide/data-streaming) for the supported value matrix, errors and opt-in `hydration: 'early'`. Custom `getState` snapshots still use JSON. --- Source: https://lomray-software.github.io/vite-ssr-boost/ai-usage # AI Usage Use this page as a grounding reference for tools working with the package. ## AI agents Two [Agent Skills](https://agentskills.io/specification) are available: `ssr-boost-migrate` for an existing Vite SPA and `ssr-boost-new-app` for a new application. They include entry/data references and verification scripts. **Claude Code** — install the [plugin](https://code.claude.com/docs/en/plugins): ```sh claude plugin marketplace add Lomray-Software/vite-ssr-boost claude plugin install ssr-boost@lomray ``` Invoke `/ssr-boost:ssr-boost-migrate` or `/ssr-boost:ssr-boost-new-app`. The GitHub install uses the repository's default branch; it requires the skill files to be present there. To try a checkout before release, run `claude --plugin-dir /absolute/path/to/vite-ssr-boost`. **Codex CLI** — from a clone of this repository, copy the complete folders (including references/scripts) into the personal skill directory: ```sh mkdir -p ~/.codex/skills cp -R skills/ssr-boost-migrate skills/ssr-boost-new-app ~/.codex/skills/ ``` Or copy them into the target repository: ```sh mkdir -p /path/to/app/.codex/skills cp -R skills/ssr-boost-migrate skills/ssr-boost-new-app /path/to/app/.codex/skills/ ``` The [current Codex documentation](https://developers.openai.com/codex/skills) specifies `.agents/skills` for discovery. For those versions, copy into `~/.agents/skills` or the application's `.agents/skills` instead, or expose the personal copies above with per-skill symlinks: ```sh mkdir -p ~/.agents/skills ln -s ~/.codex/skills/ssr-boost-migrate ~/.agents/skills/ssr-boost-migrate ln -s ~/.codex/skills/ssr-boost-new-app ~/.agents/skills/ssr-boost-new-app ``` Use one discovered copy of each skill. For repository copies, use the equivalent symlinks from `.agents/skills/` to `../../.codex/skills/`. Invoke `$ssr-boost-migrate` or `$ssr-boost-new-app`; check `/skills` and restart Codex if the skills do not appear. Review existing folders before copying updates. **Cursor** — copy the skill folders into the app's `skills/` directory, then add a [project rule](https://cursor.com/docs/context/rules) at `.cursor/rules/ssr-boost.mdc`: ```md --- description: SSR Boost migration and new application workflows alwaysApply: false --- For a Vite SPA migration, read @skills/ssr-boost-migrate/SKILL.md. For a new SSR Boost app, read @skills/ssr-boost-new-app/SKILL.md. Follow the selected skill's references and verification procedure. ``` **Other agents** — start with [llms.txt](https://lomray-software.github.io/vite-ssr-boost/llms.txt), or use [llms-full.txt](https://lomray-software.github.io/vite-ssr-boost/llms-full.txt) for the documentation and both skill procedures. The [benchmark reference](/reference/benchmarks) points to current measurements without embedding result numbers. ## Package identity `@lomray/vite-ssr-boost` adds SSR to React Router apps in Data mode, without moving to Framework mode and without rewriting the app. Keep the Vite configuration, route objects and components; use the same application for SSR or SPA output. For [incremental SSR](/guide/incremental-ssr), managed Express entries and Fetch `createHandler` accept an `ssr` policy with include/exclude patterns and per-request decisions, default crawler SSR, and a restart-only `SSR_BOOST_SSR_ROUTES` rollback override. Data mode, not Framework mode. The server uses `createStaticHandler` and `StaticRouterProvider`, and the browser uses route objects with `createBrowserRouter`; see React Router's [mode definitions](https://reactrouter.com/start/modes). The package declares `engines.node: ">=22.12.0"` and peers for Vite `>=5`, React and React DOM `>=18.2.0`, and React Router `>=7.0.1`. The template tooling uses Node 22.23.2. Check the selected React Router and build tool versions for further engine requirements. ## Public entrypoints - Vite plugin: `@lomray/vite-ssr-boost/plugin`. - Cloudflare Workers: `@lomray/vite-ssr-boost/cloudflare` (`createWorkerHandler`, `getHtmlFromAssets`, `RouteAssets`, `TRouteAssetsManifest`); see [Cloudflare Workers](/guide/cloudflare). - Browser entry: `@lomray/vite-ssr-boost/browser/entry`. - Managed CLI server entry: `@lomray/vite-ssr-boost/adapters/express/entry`. - Fetch core: default export `createHandler` from `@lomray/vite-ssr-boost/core/handler`. - Transport adapters: `@lomray/vite-ssr-boost/adapters/node`, `adapters/express`, `adapters/fastify`, `adapters/hono` and `adapters/edge`, all under the package prefix. - Renderers: `@lomray/vite-ssr-boost/node/render-to-stream` and `@lomray/vite-ssr-boost/edge/render-to-stream`. - SSR route testing: `@lomray/vite-ssr-boost/testing`; optional browser assertions: `@lomray/vite-ssr-boost/testing/playwright`. See [Testing](/guide/testing). - Node production helpers: `@lomray/vite-ssr-boost/node/production`. - CLI binary: `ssr-boost`. ## Default workflow and transport ownership Prefer the managed CLI for Vite development, HMR, asset manifests and production static files. It uses Express, and managed request/render hooks retain the live Express `req` and `res` objects. `node/entry` was removed in 8.0.0; use `adapters/express/entry` for v8 and follow [Upgrade from 7 to 8](/guide/upgrade-v8). A Fetch transport owns its development server, bundling, static assets and route-asset injection. It can initialize `ServerConfig` from `@lomray/vite-ssr-boost/services/server-config` and inject manifest assets with `SsrManifest` from `@lomray/vite-ssr-boost/services/ssr-manifest`; see [Runtime adapters](/guide/runtime-adapters). The [custom-server example](https://github.com/Lomray-Software/vite-template/tree/example/custom-server) demonstrates this integration with Fastify in production and the managed CLI in development. ## Data loading contract Loader and action promises stream by default in Data mode. Return `{ fast, slow: fetchSlow() }` and consume `slow` inside Suspense with `` or React 19 `use()`. The browser reconstructs native promises before creating the router; client navigation loaders keep their native promises. See [Stream loader data](/guide/data-streaming) for the supported value matrix, errors and opt-in `hydration: 'early'`. Custom `getState` snapshots still use JSON. ## Application guidance For an existing Vite + React Router app, run `ssr-boost init --dry-run` first and review every diff before `--apply`. After changing entries, routes, plugins, scripts, or dependencies, run `ssr-boost doctor --json`; resolve errors and inspect version warnings before building. See the [CLI reference](/api/cli) for `--root`, overrides and support bundles. - Keep lazy route imports statically analyzable. - Align Vite `base` with the server static middleware basename. - Use `onRequest` for app props scoped to a request. - Use `onRouterReady` to choose streaming or a complete render. - Use `getState` with `getServerState` to restore application state. - Use `OnlyClient` for browser-only widgets and lazy `onlyClient` routes for pages. - Read the [migration guide](/guide/migrate-existing-spa) for entries copied from the minimal example. ## Details to preserve - The browser entry resolves matched lazy routes and waits for the SSR state before creating the router and hydrating. - `data-force-spa="1"` on the root forces SPA mounting. - Rendering and pending router promises abort on timeout or request cancellation. - Early hydration requires an async client entry and custom state available at `onShellReady`; buffered bot rendering waits for all promises. - CLI focus selection uses `--focus-only`; use `--focus-only client` for SPA build and start. - Plugin `entrypoint` configures additional build surfaces. - The package does not implement RSC, Server Actions or file-system routing conventions. - Use the [comparison guide](/guide/choosing) and its official sources for claims about other projects. ## Machine-readable documentation The docs build generates [llms.txt](https://lomray-software.github.io/vite-ssr-boost/llms.txt) and [llms-full.txt](https://lomray-software.github.io/vite-ssr-boost/llms-full.txt) from the Markdown sources. The short file includes the package identity, the resolved release version when available, the public entrypoints and data-loading contract from this page, and an absolute URL for every documentation page. The full file concatenates those pages and both `skills/*/SKILL.md` procedures with their source URLs; relative skill links become absolute repository links. Run `npm run docs:build` to regenerate both files in `docs/.vitepress/dist`. The version comes from `git describe --tags --match 'v*' --abbrev=0`, with the leading `v` removed. If tags are unavailable, the build falls back to `npm view @lomray/vite-ssr-boost version`; if that also fails, it omits the version line. The build reports which source it used. The docs workflow fetches full Git history and tags so CI can use the tag path. The source manifest's placeholder version is never used; semantic-release assigns the published package version in `lib/package.json` separately. Generation checks that every indexed URL has a built HTML page and reports the page/link counts. GitHub Pages deploys these files with the rest of the docs; generated copies are not checked in. ## Keeping the Context7 index fresh [`context7.json`](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/context7.json) selects the `prod` branch and `docs/`. Context7 also includes root Markdown automatically; the filename exclusions leave `README.md` as the root source. Tests, scripts, package output and non-documentation examples are excluded, while `docs/examples/` stays indexed. The rules identify the v8 entrypoints and the removed `node/entry` path. The `v7.1.0` tag is configured as a separate previous version for v7 users. See the [Context7 configuration guide](https://context7.com/docs/library-owners). Maintainers set the optional repository Actions secret `CONTEXT7_API_KEY`. The [refresh workflow](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/.github/workflows/context7-refresh.yml) calls the [documented refresh API](https://context7.com/docs/integrations/github-actions) on published releases or manual dispatch. The `prod` release job also calls it as a reusable workflow: [events created with `GITHUB_TOKEN` do not start another workflow](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow). This also refreshes docs-only changes after a successful `prod` release job. The API step uses a job environment variable in its condition, following [GitHub's optional-secret pattern](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), so a missing key skips the request. The request refreshes the library's configured source; `context7.json` selects `prod`, so publishing a prerelease does not switch the main index to `staging`. After promoting documentation to `prod`, maintainers can dispatch the workflow to retry a failed refresh or refresh without a release. Check the workflow result and the [Context7 listing](https://context7.com/lomray-software/vite-ssr-boost) after indexing completes; a successful API request queues refresh work and does not prove parsing has finished. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/browser-entry # Browser Entry ## Import ```ts import entryClient from '@lomray/vite-ssr-boost/browser/entry'; ``` ## Signature ```ts entryClient(App, routes, options?) ``` Options: ```ts interface IEntryClientOptions { init?: (params: { isSSRMode: boolean; router: DataRouter; }) => Promise; routerOptions?: Parameters[1]; createRouter?: typeof createBrowserRouter; rootId?: string; } ``` ## What it does While the document is loading, the entry waits for serialized router state (SSR only) or `DOMContentLoaded` before creating the router and rendering. The browser entry: - resolves currently matched lazy routes before router creation - creates the browser router - runs optional async initialization - either hydrates SSR HTML or mounts a pure SPA root That lazy-route preload step is important. It keeps SSR hydration from diverging when a route module was lazy on the server but not yet loaded on the client. ## `init` Use `init` to create client-only props for your top-level app wrapper. ```tsx void entryClient(App, routes, { init: async ({ isSSRMode, router }) => ({ isSSRMode, pathname: router.state.location.pathname, }), }); ``` Those values are passed to `App` as `client`. `isSSRMode` reflects the current document: it is `false` for an [incremental SSR](/guide/incremental-ssr) SPA shell even when the shared browser bundle was built for SSR. ## `createRouter` The default is `createBrowserRouter`. Override it when your app uses a wrapped router factory from another integration, such as monitoring or instrumentation tooling. ## `rootId` Defaults to `root`. Change it only if your HTML template uses a different root node id. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/cli # CLI ## Binary ```bash ssr-boost ``` The CLI is the operational face of the package. It drives development, builds, preview mode and deployment-oriented packaging. ## Main commands ### `ssr-boost init` Adds SSR to an existing Vite + React Router Data-mode app. The default is a dry run: ```bash npx ssr-boost init --dry-run npx ssr-boost init --apply npm install ``` Before installing the library, invoke it with `npx --package @lomray/vite-ssr-boost ssr-boost init`. | Option | Meaning | | --- | --- | | `--dry-run` | Print a unified diff of every proposed file; write nothing (default). | | `--apply` | Write the complete plan after validating it. Cannot be combined with `--dry-run`. | | `--root ` | Project directory; defaults to the current directory. | | `--entry ` | Browser file relative to the project; must match the HTML module script. | | `--routes ` | Module with one exported route array, relative to the project. | Example output excerpt: ```diff --- a/index.html +++ b/index.html @@ -1 +1 @@ -
+
``` ```text Dry run: no files written. Use --apply to write these changes. Next: npm install; then npx ssr-boost doctor. ``` The remaining diffs show the Vite plugin, browser/server entries, scripts and dependency, plus an extracted route module when the routes were inline. The CLI preserves surrounding source, quotes and line endings where possible; it does not require Prettier. It never installs dependencies. A second `--apply` prints `No changes: SSR is already initialized. Run ssr-boost doctor.` An existing `dev` script becomes `ssr-boost dev`; no duplicate `develop` is added. Without `dev`, the command updates or adds `develop`. It always sets `build` to `ssr-boost build` and adds `start:ssr` as `ssr-boost start`; a stock `vite preview` script becomes `ssr-boost preview`. Existing routes imports keep their module specifier and binding (for example, `import { routes } from './routes'`). `StrictMode`, Fragment, and unwrapped roots get an explicit App component in both entries to preserve the render tree without forwarding entry props to React built-ins. Unsupported layouts exit 1 before writing and include the [manual migration guide](/guide/migrate-existing-spa). See that guide for supported layouts and constraints, including custom bases and provider initialization. ### `ssr-boost doctor` Inspects the project without importing application modules, executing Vite plugins, or reading environment files: ```bash npx ssr-boost doctor npx ssr-boost doctor --json --root ./my-app npx ssr-boost doctor --bundle support.json ``` | Option | Meaning | | --- | --- | | `--json` | Print a JSON report instead of the check table. | | `--root ` | Project directory; defaults to the current directory. | | `--bundle ` | Write a support bundle JSON, relative to the project directory (absolute paths also work). | Each table/JSON check has `name`, `status` (`ok`, `warn`, `error`), a message and a one-line `fix`. Exit status is **1 if any check is an error**, otherwise **0**. An `ok` fix describes how to maintain that condition. Table excerpt: ```text status check message fix ok node Node 22.23.2 satisfies engines Use a Node version satisfying the package and app engines (CI uses 22.23.2). ok vite-plugin SsrBoost() is in the Vite plugins Add SsrBoost() from @lomray/vite-ssr-boost/plugin to Vite plugins. error html-outlet index.html: expected one outlet, found 0 Place exactly one inside the root element. ``` JSON check excerpt: ```json { "name": "routes", "status": "ok", "message": "2 route IDs resolved", "fix": "Use statically analyzable route objects; follow the file and line in the parser error." } ``` Checks cover readable package metadata; installed `@lomray/vite-ssr-boost`, React, React DOM, React Router and Vite versions; Node against the library, app and installed runtime engines; physical duplicate React copies via `npm ls react react-dom --json --all --long`; the imported Vite plugin; the HTML module script and exactly one outlet; both library entries; the actual route parser; and development/build/SSR scripts. Per-package `version:*` checks verify installation and peer ranges; missing packages or unsatisfied peer ranges are errors. A single `compatibility` check is `ok` only when React and React DOM, React Router, and Vite exactly match a row in the [compatibility workflow](https://github.com/Lomray-Software/vite-ssr-boost/blob/staging/.github/workflows/react-compatibility.yml). Otherwise it warns with the installed trio and closest tested row. The closest row has the most matching components, with ties resolved by proximity of React, Router, then Vite versions. Those rows ship with the CLI and a test prevents drift. ```text warn compatibility React 19.2.8 + Router 7.18.3 + Vite 8.2.2 is not a tested row; closest: React 19.2.8 + Router 7.18.3 + Vite 7.3.6 fix: Use React and React DOM 19.2.8, React Router 7.18.3, and Vite 7.3.6 for a tested combination. ``` The `robots` and `size-budget` checks have `info: true`: they report missing/custom/blocked crawl policies and the presence of a size budget script without changing policy or failing the check. Doctor uses static configuration analysis; configuration it cannot resolve gets an actionable error instead of executing arbitrary config code. The support bundle (`schemaVersion: 1`) includes versions, adapter, route IDs and literal paths, check names/statuses/fixes, and known diagnostic codes from the last recorded build. It omits raw error messages, npm trees, source code, cookies, environment values, loader/state data, and other application data. A pathless route or a dynamic path helper is represented by `path: null`; the tool does not execute path helpers. Successful managed builds record only known codes in `/ssr-boost-diagnostics.json`, resetting the list for each build. An absent or invalid record yields an empty list. See [development diagnostics](/reference/diagnostics) for code meanings. ### `ssr-boost dev` Runs the development server. Common flags: - `--host` - `--port` - `--reset-cache` - `--mode` - `--entrypoint` ## `ssr-boost build` Creates a production build. Common flags: - `--focus-only [all|app|client|server|entrypoint]` - `--mode` - `--client-options` - `--server-options` - `--unlock-robots` - `--eject` - `--serverless` - `--throw-warnings` ## `ssr-boost start` Runs the production server. Common flags: - `--host` - `--port` - `--focus-only` - `--build-dir` - `--module-preload` ## `ssr-boost preview` Builds in watch mode and boots the production server once output is ready. Common flags: - `--focus-only` - `--host` - `--port` - `--mode` - `--build-dir` ## Deployment helpers ### `ssr-boost build-docker` Flags: - `--image-name` - `--docker-options` - `--docker-file` - `--focus-only` - `--mode` ### `ssr-boost build-amplify` Flags: - `--manifest-file` - `--is-optimize` - `--mode` ### `ssr-boost build-vercel` Flags: - `--config-file` - `--config-vc-file` - `--is-optimize` - `--mode` ## Focus modes `--focus-only` controls which part of the app should be built or started: - `all` - `app` - `client` - `server` - `entrypoint` This is the main switch when you want a narrower production action instead of rebuilding everything every time. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/components-and-helpers # Components And Helpers ## Components ### `Navigate` ```tsx import Navigate from '@lomray/vite-ssr-boost/components/navigate'; ``` Works like React Router navigation on the client, but on the server it writes a `Response` with `Location` and status. Useful props: - normal `NavigateProps` - `status?: number` with default `301` ### `ResponseStatus` ```tsx import ResponseStatus from '@lomray/vite-ssr-boost/components/response-status'; ``` Sets the server response status from inside the route tree. Example: ```tsx ``` ### `ScrollToTop` ```tsx import ScrollToTop from '@lomray/vite-ssr-boost/components/scroll-to-top'; ``` Scrolls to top on pathname changes. Prop: - `shouldReloadReset?: boolean` ### `OnlyClient` ```tsx import OnlyClient from '@lomray/vite-ssr-boost/components/only-client'; ``` Loads and renders a component only on the client. Props: ```ts { load: () => Promise<{ default: ComponentType } | ComponentType>; children: (Component: ComponentType) => ReactNode; fallback?: ReactNode; errorComponent?: ReactNode; isMemorized?: boolean; } ``` Example: ```tsx import('./heavy-chart')}> {(Chart) => } ``` ### `withSuspense` ```tsx import withSuspense from '@lomray/vite-ssr-boost/components/with-suspense'; ``` Wraps a component with a provided suspense boundary component and hoists non-React statics from the original component. ## Helpers ### `getServerState` ```ts import getServerState from '@lomray/vite-ssr-boost/helpers/get-server-state'; ``` Reads serialized server state written during SSR and exposed to the client. Use it when `getState` in the server entry injects data that the browser should pick up during boot. ## Interfaces Useful route-related imports: ```ts import type { FCRoute, FCCRoute } from '@lomray/vite-ssr-boost/interfaces/fc-route'; import type { TRouteObject } from '@lomray/vite-ssr-boost/interfaces/route-object'; ``` Use these when you want typed route components or route object declarations aligned with the package conventions. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/node-production # Node production helpers Use these helpers when your application owns its Node HTTP server and serves a build produced by the SSR BOOST CLI. They supply the `getHtml` and `prepare` options for `core/handler`. ```ts import { createRouteAssetPreparer, loadHtmlShell, } from '@lomray/vite-ssr-boost/node/production'; import type { IHtmlShell, ILoadHtmlShellOptions, IRouteAssetPreparerOptions, TRouteAssetsManifest, } from '@lomray/vite-ssr-boost/node/production'; ``` The explicit `.js` import path also works. These helpers use Node's filesystem APIs. ## `loadHtmlShell` ```ts interface ILoadHtmlShellOptions { indexFile: string; outlet?: string; // Default: '' } function loadHtmlShell(options: ILoadHtmlShellOptions): Promise<() => IHtmlShell>; interface IHtmlShell { header: string; footer: string; } ``` Reads the UTF-8 file once and splits it at the outlet. The file must contain exactly one outlet; an empty outlet, missing outlet or repeated outlet throws an error naming the file. Filesystem read errors propagate to the caller. The returned function creates a new `{ header, footer }` object each time, so request hooks can change the shell without affecting other requests. It can be passed directly as `getHtml`. Load a new shell when deploying a new build. ## `createRouteAssetPreparer` ```ts type IRouteAssetPreparerOptions = ( | { buildDir: string; manifest?: never } | { manifest: TRouteAssetsManifest; buildDir?: never } ) & { modulePreload?: boolean }; // Default: false function createRouteAssetPreparer( options: IRouteAssetPreparerOptions, ): NonNullable['prepare']>; ``` `ICreateHandlerOptions` is the type exported by `core/handler`. Pass the build directory containing `client/` and `server/`. The preparer resolves this path when created, then reads `server/assets-manifest.json` when a request first has matched routes. Each preparer caches its own manifest and shares the managed server's asset injection logic. It does not discover builds from the working directory or use the managed server's manifest singleton. Relative paths resolve against the working directory at creation; use an absolute path to start the application from any directory. Alternatively, import the parsed **`build/client/assets-manifest.json`** and pass `{ manifest }`: ```ts import manifest from './build/client/assets-manifest.json'; const prepare = createRouteAssetPreparer({ manifest }); ``` The CLI emits this alongside the identical `build/server/assets-manifest.json` after the normal server build. It is not Vite's `.vite/manifest.json`. The exported `TRouteAssetsManifest` type accepts a JSON import directly. `RouteAssets` from `services/route-assets` also accepts the object as its first constructor argument, with `modulePreload` as the second. Manifest objects are not mutated. For Workers use the edge-safe [Cloudflare entry](/guide/cloudflare), which uses the same injection logic without importing the Node helpers. The hook runs after React Router matches the request. It inserts matching route styles and scripts before `` in `context.html.header`; the shell must contain that closing tag. Lazy route IDs must match the routes used for the build. `modulePreload: true` also inserts module preload links. Asset URLs come from the manifest, including any base path configured during the build. When the assets produce `Link` headers, the hook awaits `executionContext.onEarlyHints` if provided. It works without that callback. The Node and Fastify adapters send these headers as HTTP 103 Early Hints when the transport supports them. They are separate from the final response headers. A missing manifest or a route without assets adds nothing, matching the managed server. Invalid manifest JSON and read errors propagate. Create a new preparer when deploying a new build. See the [Fastify launcher](/guide/runtime-adapters#adapters) for the complete server setup, including static files and compression. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/plugin # Plugin ## Import ```ts import SsrBoost from '@lomray/vite-ssr-boost/plugin'; ``` ## Minimal usage ```ts plugins: [SsrBoost(), react()]; ``` ## Options ```ts interface IPluginOptions { indexFile?: string; serverFile?: string; clientFile?: string; routesPath?: string; spaIndex?: boolean | { filename?: string; rootId?: string; }; tsconfigAliases?: boolean | { root?: string; tsconfig?: string; }; customShortcuts?: { key: string; description: string; action: (cliContext) => Promise | void; isOnlyDev?: boolean; }[]; entrypoint?: IBuildEntrypoint[]; } ``` ## Defaults ```ts { indexFile: 'index.html', serverFile: 'server.ts', clientFile: 'client.ts', tsconfigAliases: true, spaIndex: false, } ``` ## What the plugin does At a high level it: - sets `__IS_SSR__` - prepares SSR build config behavior - enables tsconfig alias import support - can generate an extra SPA index - can swap client entrypoints for custom builds - normalizes route handling for manifest generation ## `spaIndex` Set `spaIndex: true` to emit `index-spa.html`. Custom form: ```ts SsrBoost({ spaIndex: { filename: 'index-spa.html', rootId: 'root', }, }); ``` The generated file marks the root with `data-force-spa="1"` so the browser entry mounts instead of hydrating. ## `tsconfigAliases` Enabled by default. Use `false` if you do not want aliases from tsconfig copied into Vite config. Use the object form if your alias resolution needs custom parameters from the alias plugin. ## `entrypoint` `entrypoint` defines additional app surfaces for build time. ```ts interface IBuildEntrypoint { name: string; type: 'spa' | 'ssr'; indexFile?: string; clientFile?: string; serverFile?: string; buildOptions?: string; } ``` Example: ```ts SsrBoost({ entrypoint: [ { name: 'mobile', type: 'spa', clientFile: './src/mobile.tsx', buildOptions: '--mode mobile', }, ], }); ``` ## `customShortcuts` Lets you extend interactive CLI keyboard actions in development. Use this when your team has dev-only restart, cache or environment actions that should be one keypress away from the running dev process. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/server-entry # Server Entry ## Import ```ts import entryServer from '@lomray/vite-ssr-boost/adapters/express/entry'; ``` ## Signature ```ts entryServer(App, routes, options?) ``` Options: ```ts interface IEntryServerOptions { ssr?: ISsrPolicy; abortDelay?: number; init?: (params: { config: ServerConfig }) => IEntrypointOptions | Promise>; loggerProd?: Logger; loggerDev?: Logger; middlewares?: { compression?: CompressionOptions | false; expressStatic?: (ServeStaticOptions & { basename?: string }) | false; }; routerOptions?: Parameters[1]; } ``` ## What it returns The entry returns a render definition consumed by the runtime server. It includes: - `render` - `init` - `routes` - `abortDelay` - optional loggers - optional middleware config ## `ssr` Select SSR or the SPA shell per incoming URL, before route loaders run: ```ts interface ISsrPolicy { mode?: 'all' | 'include' | 'exclude'; routes?: (string | RegExp)[]; bots?: 'ssr' | 'policy'; decide?: (params: { request: Request; url: URL; isBot: boolean }) => 'ssr' | 'spa' | undefined; } entryServer(App, routes, { ssr: { mode: 'include', routes: ['/', '/articles/:slug'] }, }); ``` `all` is the default. `include` uses SSR only for matching URL pathnames; `exclude` serves matches as SPA. Strings use path-to-regexp 8 syntax, including named wildcards and brace optional groups; RegExp patterns use their own flags. Include the router basename in patterns. `decide` overrides the configured mode per request, with `undefined` falling back to the route policy. `bots: 'ssr'` defaults to forcing detected crawlers to SSR ahead of all other decisions; use `'policy'` to opt out. At entry creation, `SSR_BOOST_SSR_ROUTES` overrides `mode`, `routes` and `decide`, preserving `bots`. Comma-separated positive patterns form an include list; `!` patterns exclude URLs and win over includes. With only exclusions, other URLs stay SSR. An empty value selects SSR everywhere. Restart after changes; no rebuild is needed. SPA responses retain `onRequest` and route asset preparation, use status 200 and the `data-force-spa` mount marker, and omit router/custom state and SSR render hooks. Active policies default documents to `Cache-Control: no-store` unless `onRequest` supplies a cache policy. The same `ssr` option is available on Fetch `createHandler`. See [Incremental SSR](/guide/incremental-ssr) for the full reference, lifecycle trade-offs and rollback recipe. ## Request lifecycle hooks `init` resolves to a request lifecycle configuration: ```ts interface IEntrypointOptions { hydration?: 'footer' | 'early'; nonce?: string; bootstrapScriptContent?: string; onServerCreated?; onServerStarted?; onRequest?; onRouterReady?; onShellReady?; onShellError?; onResponse?; onError?; getState?; } ``` See [Server Lifecycle](/guide/server-lifecycle) for the flow and intent of each hook. ## Hook context `onRouterReady`, `onShellReady`, `onShellError`, `onError`, `onResponse` and `getState` receive `{ context }`, with these fields: - `request`: the Fetch `Request` built from the Express request; use it for headers, URL and method so hooks stay portable to other adapters. - `response`: mutable Fetch `headers` and optional `status`; update these before the shell is sent. - `req` / `res`: the live Express request and response, [deprecated in 8.x](/guide/upgrade-v8#deprecated-in-8-x) with removal planned for 9.0. - `appProps`: request-scoped props returned by `onRequest`. - `html`: the template `header` and `footer`. - `routerContext` / `serverContext`: router and SSR metadata, once available. - `isStream`, `hasEarlyHints` and `didError`: rendering mode, early-hints preference and error metadata. `request` is the same object used by the Fetch core throughout a render; its `signal` tracks request cancellation. ```ts onRouterReady: ({ context: { request } }) => ({ isStream: !request.headers.get('user-agent')?.includes('Googlebot'), }), ``` ## `onRequest` The most important request hook. It receives `(req, res)` before the render context is created. It can return: ```ts { appProps?: TAppProps; hasEarlyHints?: boolean; shouldSkip?: boolean; shouldCancel?: boolean; } ``` That lets you shape app props, skip a request, or stop the rendering path entirely. ## `onResponse` ```ts onResponse?: (params: { context: IRequestContext; html: string; isEnd: boolean; }) => string | undefined | void; ``` Regular HTML chunks arrive with `isEnd: false`. Returning `undefined` (or nothing) keeps the original chunk; a string replaces it. Returning `''` withholds the chunk, allowing an incremental transform to retain unfinished tokens. After the composed body stream finishes, including the footer, the hook receives one final call with `html: ''` and `isEnd: true`. Its returned string is appended to the response; `undefined` or `''` appends nothing. This also applies to buffered rendering (`isStream: false`). Hooks that ignore `isEnd` remain supported. For example, using `@lomray/consistent-suspense`: ```ts onResponse: ({ context: { appProps: { streamSuspense }, isStream }, html, isEnd }) => { if (!isStream) return; return isEnd ? streamSuspense.end() : streamSuspense.analyze(html); }, ``` ## Middleware config Production middleware can be configured without replacing the whole server pipeline: ```ts export default entryServer(App, routes, { middlewares: { compression: {}, expressStatic: { basename: '/static', }, }, }); ``` Set either option to `false` when you want it disabled. ## Logging Use `loggerProd` and `loggerDev` to provide custom Vite-compatible loggers instead of the package default logger. --- Source: https://lomray-software.github.io/vite-ssr-boost/api/testing # Testing API ```ts import { createTestHandler, TestResponse, createDeferred, crawlerRequest, browserRequest, } from '@lomray/vite-ssr-boost/testing'; ``` See [Test SSR routes](/guide/testing) for complete Vitest and Playwright examples. ## createTestHandler(options) Returns `{ fetch(input, init?): Promise }`. It renders in process through `createHandler` and `createStaticHandler`, without starting an HTTP server or following redirects. Relative paths resolve against `http://localhost`; absolute URLs and Fetch `Request` objects are accepted. The wrapper consumes the response stream immediately. | Option | Description | | --- | --- | | `routes` | Required application route objects, including loaders, actions and lazy routes. | | `App` | Optional React component receiving `children` and `server: context.appProps`. Without it, routes render directly. | | `shell` | `{ indexFile, outlet? }` uses Node's cached `loadHtmlShell`; `{ header, footer }` works in any Fetch runtime. Omit for a minimal full HTML document and inert module-script placeholder. | | `routerOptions` | Options forwarded to `createStaticHandler`, including `basename`. | | `renderToStream` | Override the renderer with the core `TRenderToStream` contract. Defaults to Node pipeable streams or Web streams under edge export conditions. | | `signal` | Default request cancellation signal, combined with the input Request and per-fetch signal. | | `timeout` | Optional finite non-negative whole-request deadline in milliseconds. Includes routing, preparation and body reading. | | Handler options | `diagnostics`, `hydration`, `abortDelay`, `onRequest`, `onRouterReady`, `onShellReady`, `onShellError`, `onError`, `onResponse`, `prepare`, `getState`, `nonce`, `bootstrapScriptContent`, `routerRequestContext` and `onContext` pass through to the Fetch core. | `onContext({ context })` observes initialized request metadata before routing, including a bypass `onRequest` response (whose shell is empty). It can read `context.timeline?.events`. Each fetch has independent metadata and timeline; the route handler and cached HTML shell are shared by the kit instance. `fetch(input, init?)` accepts `RequestInit` plus `isStream?: boolean` and `timeout?: number`. A supplied `isStream` overrides the result of `onRouterReady` for that request; the hook still runs. It never infers streaming from the user agent. Per-fetch timeout replaces the default timeout. Signals are combined, so either can cancel the request. Before headers, cancellation rejects fetch; after headers, body accessors reject with the signal reason. The `abortDelay` render deadline is separate and can finish a connected document with deferred rejection frames. ## TestResponse You can also wrap an existing Fetch response with `new TestResponse(response)`. The `response` property exposes its original metadata; its body belongs to the wrapper's reader. All asynchronous accessors are repeatable. | Member | Result | | --- | --- | | `status` | Numeric HTTP status. | | `headers` | Fetch `Headers`. | | `text()` / `html()` | `Promise` containing the full raw document. | | `chunks()` | `Promise>`; UTF-8 is decoded as chunks arrive, and `at` is milliseconds since fetch began. Empty decoder fragments are omitted. | | `routerState()` | `Promise` with `loaderData`, `actionData`, `errors`. Deferred fields become settled native values; rejected fields reject the accessor. Also reads legacy JSON hydration state. Missing or duplicate stream settlements throw; scripts are never executed. | | `pendingBoundaries()` | `Promise` counting raw `` markers in final HTML. It does not execute React replacement scripts. | | `streamFrames()` | `Promise` in document order. Tuples are `['init', payload, isEarly]`, `['resolve' \| 'reject', id, payload]` or `['shell']`; payloads retain their encoded strings. | | `timeline()` | `Promise` after reading completes, including after an abort. Empty when recording is disabled or an independently wrapped Response has no supplied timeline. | | `cookies()` | `Array<{ name: string; value: string; attributes: Record }>`; each Set-Cookie is separate. Attribute names are lowercase, flags are `true`, and values (including Expires and encoded cookie values) remain strings. | `new TestResponse(response, { started?, timeline?, signal?, onComplete? })` also accepts a `performance.now()` request start, a core `RequestTimeline`, a cancellation signal, and a reader completion callback. These options are normally supplied by the kit. ## createDeferred<T>() Returns `{ promise: Promise, resolve(value: T | PromiseLike): void, reject(reason?: unknown): void }`. Use it for loader/action fields that must settle after the shell. An attached rejection observer prevents unhandled-rejection noise while the router is still preparing; consumers still receive the original rejected promise. ## crawlerRequest(path) and browserRequest(path) Return Fetch `Request` objects using the kit's default origin for relative paths. The former supplies a Googlebot user agent; the latter a browser user agent. They do not change render options. ## Playwright entry ```ts import { collectStreamTimeline, expectHydrated, expectStreamed, } from '@lomray/vite-ssr-boost/testing/playwright'; ``` The optional peer `@playwright/test >=1.40.0` is required when running these assertions. It is loaded only by this entry's assertion functions. | Function | Contract | | --- | --- | | `collectStreamTimeline(page)` | Call and await before navigation. Returns `Promise<{ read(): Promise }>` and installs one observer for subsequent documents. | | `expectHydrated(page, { root = '#root', timeout = 5000 } = {})` | Waits for the SSR Boost router event, document load, settled Suspense boundaries and browser rendering. Asserts state consumption, no observed React hydration errors, and no increased multiplicity of server text. `root` is a CSS selector for the root element. | | `expectStreamed(page, selector)` | Waits for one matching visible element and asserts its first observed visibility is later than the shell. Uses Playwright's configured assertion timeout. | Use the SSR Boost browser entry so router creation is observable. Call collection before `page.goto()`; installing it after hydration cannot reconstruct the original DOM or early console errors. The observer leaves stream delivery intact when the browser receiver replaces the queue's `push` method. Browser timeline offsets use navigation's `performance.now()` clock, not the server request clock. ## Timeline types `ISsrRequestContext.timeline` is an optional request-local `RequestTimeline` with an `events` array. `ITimelineEvent` has `stage`, `at`, optional `id`, optional `placement: 'early' | 'footer'`, and optional `reason`. See the [stage table](/guide/testing#request-timeline) for semantics and the [diagnostics reference](/reference/diagnostics#ssr_boost_timeline) for environment configuration. --- Source: https://lomray-software.github.io/vite-ssr-boost/examples/ # Example projects The [vite-template repository](https://github.com/Lomray-Software/vite-template) contains the application examples. Start with `example/minimal` for the entries used in the [migration guide](/guide/migrate-existing-spa), or choose `prod` for state management and deployment workflows. Use `npm create @lomray/ssr-app@latest my-app -- --template ` to create an app from a branch: `full` selects `prod`, `minimal` (the default) selects `example/minimal`, `custom-server` selects `example/custom-server`, and `localization` selects `example/localization`. See [Create a new app](/guide/getting-started#create-a-new-app) for the flags. ## `prod` The [prod branch](https://github.com/Lomray-Software/vite-template/tree/prod) combines a MobX manager, consistent-suspense, a route manager and meta tags. It includes component-level data streaming, Docker, Amplify and Vercel build scripts, and deployment workflows. Use it when the application needs the state and stream wiring shown in its server and client entries. ## `example/minimal` The [minimal branch](https://github.com/Lomray-Software/vite-template/tree/example/minimal) has six direct runtime dependencies: React, React DOM, React Router, vite-ssr-boost, `@lomray/react-head-manager` and `isbot`. It demonstrates loaders, a lazy route with CSS, redirects, 404 responses, metadata and a browser-only route. Its README includes the five-file change from a Vite SPA. ## `example/custom-server` The [custom-server branch](https://github.com/Lomray-Software/vite-template/tree/example/custom-server) uses the managed CLI in development and an application-owned Fastify 5 launcher in production. Its `server/index.mjs` serves assets with `@fastify/static`, connects SSR through `adapterFastify(handler, { compression: true })`, injects route assets and emits Early Hints. Its `src/server.ts` exports the managed entry as the default and a `createHandler` handler as a named export, sharing the app between both servers. See [Runtime adapters](/guide/runtime-adapters) for the transport responsibilities. ## `example/localization` The [localization branch](https://github.com/Lomray-Software/vite-template/tree/example/localization) creates an i18next instance per request and selects the language from the `lang` cookie, then `Accept-Language`. The server sets `` and transfers `{ language }` through `getState`; the client loads that language from bundled resources before hydration. A cookie-based switcher changes the language while keeping the server and browser in agreement. ## Data loading across examples Follow the [data-loading contract](/guide/migrate-existing-spa#data-loading) for loader data at first paint and the Suspense pattern used for streamed data. See [Recipes](/examples/recipes) for individual integrations. --- Source: https://lomray-software.github.io/vite-ssr-boost/examples/recipes # Recipes ## Packages that import CSS during SSR If Node reports `Unknown file extension ".css"` from a dependency, let Vite bundle that package: ```ts export default defineConfig({ ssr: { noExternal: ['the-package-that-imports-css'] }, }); ``` ## Keep server and browser state in sync Create locale, authentication and store state per request. Serialize the initial values with `getState`, then read them with `getServerState` before calling `entryClient`. Rendering a different language or initial value in the browser causes hydration mismatches; log failures with `onError`. ## Change `basename` Client: ```tsx void entryClient(App, routes, { routerOptions: { basename: '/custom', }, }); ``` Server: ```tsx export default entryServer(App, routes, { routerOptions: { basename: '/custom', }, }); ``` ## Change static asset `base` Vite config: ```ts export default defineConfig({ base: '/static', }); ``` Server entry: ```tsx export default entryServer(App, routes, { middlewares: { expressStatic: { basename: '/static', }, }, }); ``` Keep these values aligned. ## Generate an extra SPA shell ```ts export default defineConfig({ plugins: [ SsrBoost({ spaIndex: true, }), react(), ], }); ``` This emits `index-spa.html`, which is useful for service worker routing such as `createHandlerBoundToURL("index-spa.html")`. ## Add a Capacitor or mobile entrypoint Vite config: ```ts export default defineConfig({ plugins: [ SsrBoost({ entrypoint: [ { name: 'mobile', type: 'spa', clientFile: './src/mobile.tsx', buildOptions: '--mode mobile', }, ], }), react(), ], }); ``` Mobile entry: ```tsx const AppMobile: FC = (props) => { return ; }; void entryClient(AppMobile, routes, {}); ``` ## Redirect with server status ```tsx return ; ``` On the server this produces a redirect response instead of only changing client navigation state. ## Set HTTP status from a route ```tsx const NotFound = () => ( <>

Page not found

); ``` ## Load a widget only on the client ```tsx import('./MapWidget')} fallback={
Loading map...
} > {(MapWidget) => }
``` Use this for browser-only integrations that should not participate in SSR. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/caching # Document headers and caching Cache guest HTML at the CDN with a short freshness lifetime and a bounded stale-while-revalidate window. Bypass **both cache reads and writes** whenever the session cookie or Authorization header is present. Authenticated documents use `private, no-store`; cache their underlying data with appropriate user or tenant keys instead of sharing rendered pages. Only opt URLs into a shared cache when their HTML, loader data and serialized custom state are public and independent of unkeyed inputs. Put locale or other public variants in the URL, or design a separate cache key. Login, account, cart and mutation routes should bypass the page cache. Purge guest entries when content must disappear sooner than their freshness plus stale window. `private` excludes shared caches; `no-store` also excludes browser storage. Setting Set-Cookie alone does not prohibit caching, so the document helper applies a privacy default explicitly. See [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html). Stale-while-revalidate permits a bounded stale response while refresh runs in the background; it does not extend freshness. See [RFC 5861](https://www.rfc-editor.org/rfc/rfc5861.html). ## HTTP helpers Import the server-side `@lomray/vite-ssr-boost/http` entry. It uses Fetch primitives, has no Node-only imports, and works with either renderer. Keep it out of browser entries. All snippets on this page are compiled fixtures; the helper, Express, Worker and Nginx examples also have executable tests. ### `cacheControl(policy)` The boolean fields are `public`, `private`, `noStore`, `noCache`, `mustRevalidate`, and `immutable`. The duration fields are `maxAge`, `sMaxAge`, `staleWhileRevalidate`, and `staleIfError`, in non-negative safe integer seconds. False and undefined fields are omitted; an empty object returns an empty string. Invalid types, unknown fields, public plus private, and contradictory storage/revalidation directives throw TypeError. These policies and ordered rules are shared by the following examples: <<< ../../__fixtures__/caching/policy.ts The SWR policy uses `max-age`, not `s-maxage`: RFC 9111 gives `s-maxage` the semantics of proxy-revalidate, which prevents shared caches from serving stale responses before validation. The `sharedPolicy` alternative gives browsers a zero freshness lifetime and shared caches 30 seconds when stale serving is unnecessary. The builder permits `sMaxAge` together with stale extensions, but does not change the cache's interpretation. Cloudflare documents this restriction in its [revalidation guide](https://developers.cloudflare.com/cache/concepts/revalidation/). ### `documentHeaders(rules, options?)` The helper returns a function taking the Fetch render context and returning a fresh Headers object. Each rule has `when({ request, url, isBot, hasCookie, routerContext })` and `set: HeadersInit`. All matching rules run in array order. Later rules replace ordinary fields; every Set-Cookie value appends independently. Existing hook headers are the starting point. Guests without a matching policy keep those headers. `isBot` is a User-Agent heuristic matching bot, crawler, spider or crawling; it is not an authentication check. `hasCookie(name)` compares exact, case-sensitive cookie names and treats empty values as present, without decoding the values. Set `sessionCookie` to your application's cookie name. After the rules, Set-Cookie on the resulting document, Authorization on the request, or that session cookie forces `private, no-store`. Configure the same cookie name at the CDN. A deliberate `protectPrivate: false` override disables this final protection; public responses with Set-Cookie or the configured session cookie then trigger the development [SSR_BOOST_CACHE_PRIVATE_LEAK diagnostic](/reference/diagnostics#ssr_boost_cache_private_leak). The helper removes the Cookie token from Vary, preserving other tokens. **It never emits Vary: Cookie**: varying on entire cookie strings fragments a CDN cache by session IDs and unrelated tracking cookies. Use a session-presence bypass instead. Do not use these URL-only recipes for pages personalized by other cookies; bypass those pages too. Removing Vary does not make personalized content public. Use the handler's `documentHeaders` option to apply the rules automatically after `onShellReady` in `prepareHtmlResponse`, before document metadata is committed. It is opt-in, including an empty rule list to enable only the privacy defaults. Explicit loader/action and server redirects keep their existing `mergeResponseHeaders` precedence; short-circuit `onRequest` responses and fatal shell errors are not rule targets. Give those responses their own cache headers. For manual use in `onRouterReady` or `onShellReady`: <<< ../../__fixtures__/caching/manual.ts The shell stage sees cookies written by earlier hooks. If you use the router stage, recompute after any later header changes. Headers and statuses cannot change once the streamed shell has been sent. ### `copyLoaderHeaders(routerContext, { allow })` Rendered loader/action headers remain separate from document headers until explicitly copied. This helper returns a new Headers object containing only the allowlist. It visits matched routes from root to leaf, loader before action at the same route. The first ordinary value wins, so the shallowest route controls Cache-Control and other allowed fields. All Set-Cookie values append in traversal order, including cookies with commas in Expires. Unmatched route headers are ignored. Content-Type is never copied, even when allowed: loader JSON is not the HTML document. The complete application factory below demonstrates copying without discarding existing hook headers. Document rules run afterwards and override copied ordinary fields. Supply the Node or edge renderer as shown in the recipes. This example renders server HTML only; an interactive application supplies its built browser script and static assets through its existing shell/asset pipeline. The cookie check is a demonstration of presence, not session validation. <<< ../../__fixtures__/caching/app.tsx The managed CLI entry passes the same policy options through its `init` result: <<< ../../__fixtures__/caching/managed.tsx ## Cloudflare Worker This Worker caches `/guest` by the full URL, including the query string. Credentialed requests bypass lookup and storage. Guest renders receive only the URL and a fixed Accept header, so unkeyed cookies or headers cannot affect the shared representation. HEAD, mutations, ranges, validators and explicit request cache directives go to the origin. Only successful responses with the exact guest policy, no cookies and no Vary are stored. The [Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) does not implement stale-while-revalidate. This wrapper stores the body for 90 seconds, returns the original 30-second policy and Age to clients, and refreshes during the next 60 seconds with `waitUntil`. At expiry it waits for a fresh response. Refresh errors cannot extend the stale window. Concurrent refreshes are coalesced within one isolate. Cache API storage is local to a data center and does not use tiered caching. <<< ../../__fixtures__/caching/guest-cache.ts Worker entry (bundle with the `workerd` and `worker` resolution conditions): <<< ../../__fixtures__/caching/worker.ts ### Equivalent Cache Rules for an origin server Use these settings instead of the Worker wrapper when Cloudflare fronts the Express origin directly. The origin must keep the same public representation contract. Create the guest eligibility rule first and the bypass rule last, since matching later rules override earlier settings. Scope both rules to your hostname. | Rule | Matching requests | Settings | | --- | --- | --- | | Guest page | GET or HEAD, path exactly `/guest` | Eligible for cache; respect origin Cache-Control and bypass if absent; browser TTL respects origin; full URL key including query; serve stale while revalidating enabled | | Credentials | Cookie string contains `session=` or Authorization header is present | Bypass cache | | Other requests | Other paths, methods, Range, conditional headers or explicit request cache directives | Bypass cache | The cookie substring match intentionally over-bypasses lookalike names; it does not miss an empty `session=` value. Use the rules editor's header-presence condition for Authorization, including empty values. Do not override origin private/no-store, strip Set-Cookie, or force an edge TTL on credentialed responses. The origin guest policy supplies max-age and stale-while-revalidate; using s-maxage disables stale serving on Cloudflare. See [Cache Rules settings](https://developers.cloudflare.com/cache/how-to/cache-rules/settings/) and [cookie bypass](https://developers.cloudflare.com/cache/how-to/cache-rules/examples/bypass-cache-on-cookie/). ## Express behind Nginx Create the Express app with the Node renderer: <<< ../../__fixtures__/caching/express.ts Run this launcher with your server TypeScript build/runtime: <<< ../../__fixtures__/caching/express-start.ts Run Nginx with this configuration in a writable prefix directory. It listens on 8080 and proxies to Express on localhost:3000; configure production TLS and hostnames for your deployment. Guest HTML is buffered into the proxy cache. The private path always reaches Express. Both bypass and no-cache directives matter: the former prevents reading a guest hit; the latter prevents storing a private response. <<< ../../__fixtures__/caching/nginx.conf{nginx} `$cookie_session` alone treats empty and `0` values as false, so the additional raw cookie presence map covers those values. Nginx honors origin Cache-Control, Set-Cookie and Vary by default. Background update uses the origin's bounded SWR window; avoid an unconditional `proxy_cache_use_stale updating` override that could outlive it. See the [Nginx proxy module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html). ## Conditional requests and streaming `conditionalRequest(request, { etag?, lastModified? })` returns a bodyless 304 for a matching GET or HEAD, otherwise undefined. ETags must be quoted (optionally W/); Last-Modified accepts a Date or date string. It supports weak ETag comparison, comma-separated tags and `*` for an existing representation. If-None-Match takes precedence over If-Modified-Since, including when no ETag matches. Modification times compare at HTTP's whole-second precision. Invalid supplied validators throw; invalid request validators are ignored. Other methods never return 304. Call it only for an existing successful representation after selecting and authorizing that representation. It does not implement mutation preconditions such as If-Match. Retain the 200 response's Cache-Control, Vary and Content-Location when applicable. The following buffered response uses an application-supplied version covering both article and template revisions: <<< ../../__fixtures__/caching/buffered.ts A streamed body cannot be hashed before sending it. Setting `isStream: false` waits for React and loader promises, but the Fetch response still exposes a body stream; buffer/consume that body yourself before hashing it and constructing a conditional response. This helper never reads a stream. For streaming applications, use an application-supplied representation version known before rendering, and make the conditional decision in a buffered/non-streaming path before committing the shell. The version must cover every output dependency, including serialized state and personalization. A build ID alone is insufficient for changing page data. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/choosing # When to use vite-ssr-boost and when not to Use vite-ssr-boost to add SSR to a Vite app while keeping React Router [Data-mode route objects](https://reactrouter.com/start/modes). Start with the [managed CLI](/guide/getting-started), or own the transport through [runtime adapters](/guide/runtime-adapters). The table describes configuration and migration steps, not measured effort or performance. The Next.js column covers the App Router; the Vike column covers React integration. Each cell about another project links to its official documentation. | Topic | vite-ssr-boost | React Router Framework mode | Next.js | Vike | TanStack Start | | --- | --- | --- | --- | --- | --- | | Routing model | [Data-mode route objects](/guide/routing) | [Route modules and `routes.ts`](https://reactrouter.com/upgrading/router-provider) | [App Router; file-system routing](https://nextjs.org/docs/app) | [Filesystem, route strings or route functions](https://vike.dev/routing) | [TanStack Router; file-system routing](https://tanstack.com/start/latest/docs/framework/react/guide/routing) | | Migration effort from an existing React Router SPA | [Add plugin, HTML outlet, entries and scripts](/guide/migrate-existing-spa) | [Convert routes to modules; add plugin, config and root entry](https://reactrouter.com/upgrading/router-provider) | [Migrate Vite setup to Next.js; retain React Router for SPA, move to App Router for streaming](https://nextjs.org/docs/app/guides/migrating/from-vite) | [Retain React Router during migration; migrate routing afterward](https://vike.dev/react-router) | [Convert routes, links and hooks to TanStack Router](https://tanstack.com/router/latest/docs/installation/migrate-from-react-router); [add Start routing setup](https://tanstack.com/start/latest/docs/framework/react/guide/routing) | | Data loading | [Route loaders; see contract below](#loader-data-in-vite-ssr-boost) | [`loader` and `clientLoader`](https://reactrouter.com/start/framework/data-loading) | [Fetch in Server Components; pass props to Client Components](https://nextjs.org/docs/app/getting-started/server-and-client-components) | [`+data` and `useData()`](https://vike.dev/data-fetching) | [`createServerFn` from loaders or components](https://tanstack.com/start/latest/docs/framework/react/guide/server-functions) | | Streaming SSR | [React streams and Suspense](/guide/runtime-adapters#streaming-and-cancellation) | [Suspense with loader promises](https://reactrouter.com/how-to/suspense) | [Server Components and streaming](https://nextjs.org/docs/app/getting-started/server-and-client-components) | [HTML streaming via `vike-react`](https://vike.dev/streaming) | [Document SSR and streaming](https://tanstack.com/start/latest/docs/framework/react/overview) | | SPA output from the same code | [`--focus-only client`](/guide/migrate-existing-spa#going-back-to-spa) | [`ssr: false`](https://reactrouter.com/start/framework/rendering) | [`output: 'export'`; excludes server features](https://nextjs.org/docs/app/guides/single-page-applications) | [`ssr: false` via `vike-react`](https://vike.dev/ssr) | [`spa: { enabled: true }`](https://tanstack.com/start/latest/docs/framework/react/guide/spa-mode) | | RSC | No implementation | [Opt in with `unstable_reactRouterRSC`](https://reactrouter.com/how-to/react-server-components) | [Pages and layouts use Server Components by default](https://nextjs.org/docs/app/getting-started/server-and-client-components) | [Via `vike-react-rsc`](https://vike.dev/react) | [Opt in with `rsc.enabled`](https://tanstack.com/start/latest/docs/framework/react/guide/server-components) | | Server ownership | [CLI or transport supplied by the application](/guide/runtime-adapters) | [Node server template or Express integration](https://reactrouter.com/start/framework/deploying) | [`next start` or server supplied by the application](https://nextjs.org/docs/app/getting-started/deploying) | [`+server: true`, `+server.js` or `renderPage()`](https://vike.dev/server) | [Hosting integration or server forwarding to the Fetch entry](https://tanstack.com/start/latest/docs/framework/react/guide/hosting) | | Hosting targets | [Node/Express CLI; Fetch runtimes via adapters; SPA files](/guide/runtime-adapters) | [Node/Docker, Vercel, Cloudflare Workers, Netlify](https://reactrouter.com/start/framework/deploying) | [Node, Docker, export files or platform adapters](https://nextjs.org/docs/app/getting-started/deploying) | [Node, Bun, Deno](https://vike.dev/self-host); [Cloudflare, Netlify, Vercel](https://vike.dev/server) | [Node/Docker, Bun, Cloudflare Workers, Netlify, Vercel](https://tanstack.com/start/latest/docs/framework/react/guide/hosting) | The RSC implementations linked above have different release contracts: React Router [labels its support experimental](https://reactrouter.com/how-to/react-server-components), as do [Vike](https://vike.dev/react) and [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/guide/server-components). Consult those pages before selecting an RSC integration. ## Loader data in vite-ssr-boost Loader and action promises stream by default in Data mode. Use Suspense with `` or React 19 `use()` for slow fields, and opt into `hydration: 'early'` when the shell should become interactive before slow boundaries finish. See [Stream loader data](/guide/data-streaming) for the value matrix and custom-state constraint. ## Choose a project - **Choose vite-ssr-boost when** you already have a Vite app with React Router route objects and want to keep that structure while adding SSR. The [migration guide](/guide/migrate-existing-spa) shows the entry changes; choose the Fetch path when you also want to own transport and asset delivery. - **Choose React Router Framework mode when** you want its route module API, generated route types and rendering configuration. Its [mode guide](https://reactrouter.com/start/modes) describes that integration, and its [adoption guide](https://reactrouter.com/upgrading/router-provider) explains the route conversion it requires. - **Choose Next.js when** you want the App Router's file-system routing, Server Components and Server Functions. Those are part of its [documented App Router model](https://nextjs.org/docs/app), while vite-ssr-boost does not implement them. - **Choose Vike when** you want its page configuration and routing with a choice of server integration. Its [routing guide](https://vike.dev/routing) documents filesystem routes, route strings and route functions, and its [server guide](https://vike.dev/server) documents the integration points. - **Choose TanStack Start when** you want TanStack Router with document SSR, streaming and server functions. The [Start overview](https://tanstack.com/start/latest/docs/framework/react/overview) describes that combination; use its [routing guide](https://tanstack.com/start/latest/docs/framework/react/guide/routing) to assess the route structure you would adopt. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/cloudflare # Cloudflare Workers Use `@lomray/vite-ssr-boost/cloudflare` to serve a built React Router Data mode application in Workers. It provides a Fetch handler, route asset injection, streaming HTML and access to bindings. Keep your Express server entry for `ssr-boost dev`. ## Build a Worker entry Install Wrangler in your application: ```bash npm install --save-dev wrangler ``` Add an extra SSR entry to your existing Vite config. This example uses `src` as the Vite root: ```ts import { defineConfig } from 'vite'; import SsrBoost from '@lomray/vite-ssr-boost/plugin'; export default defineConfig({ root: 'src', publicDir: '../public', build: { outDir: '../build' }, plugins: [ SsrBoost({ clientFile: 'client.tsx', serverFile: 'server.tsx', // Existing Express entry for development. entrypoint: [{ name: 'worker', type: 'ssr', serverFile: 'worker.ts' }], }), // Keep your existing React and other Vite plugins here. ], }); ``` Create `src/worker.ts` alongside your shared `App` and routes: ```ts import { createWorkerHandler, getHtmlFromAssets, } from '@lomray/vite-ssr-boost/cloudflare'; import manifest from '../build/client/assets-manifest.json'; import App from './App'; import routes from './routes'; export default { fetch: createWorkerHandler({ routes, App, manifest, getHtml: async (_request, env) => (await getHtmlFromAssets(env, '/index.html'))(), }), }; ``` The exact JSON import is **`build/client/assets-manifest.json`**, relative to the Worker source. It is the route-ID-to-assets manifest, not Vite's `.vite/manifest.json`. The CLI writes it alongside `build/server/assets-manifest.json` after building the client and regular server. The extra Worker entry is built afterward, so its JSON import exists on a clean build. Do not import the Worker entry from your client or regular server entry. Run all build entries: ```bash npx ssr-boost build --focus-only all ``` The default `ssr-boost build` builds only the app. `--focus-only all` also builds the configured Worker, emitting `build/worker/worker.js`. No separate Vite invocation or new server-entry flag is needed. Rebuild all entries after changing routes or assets. The managed watch preview does not regenerate this production manifest for a Worker. `App` receives `{ server: appProps, children }`, matching the managed server's wrapper convention. The handler also accepts `routerOptions`, `modulePreload`, `outlet`, and the Fetch core's lifecycle, state, nonce, timeout, hydration and router context options. `getHtml(request, env, ctx)` may load a custom shell. Alternatively, pass `indexHtml` containing the complete built HTML string with exactly one `` (or your custom `outlet`); do not pass a filename as `indexHtml`. ## Wrangler configuration Create `wrangler.jsonc` at the project root: ```jsonc { "$schema": "./node_modules/wrangler/config-schema.json", "name": "my-ssr-app", "main": "build/worker/worker.js", "compatibility_date": "2026-07-30", "assets": { "directory": "build/client", "binding": "ASSETS", "run_worker_first": true, "html_handling": "none", "not_found_handling": "none" } } ``` `run_worker_first` lets the handler render `/` and apply cache headers to static responses. `html_handling: "none"` lets the shell helper fetch `/index.html` without a redirect. Keep `not_found_handling: "none"` so a static miss reaches SSR and can return a real 404. See [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/) and [Wrangler configuration](https://developers.cloudflare.com/workers/wrangler/configuration/). GET and HEAD requests try the binding for static files, including public files such as `robots.txt` and extensionless files. `/` always renders; `/index.html` is also routed through SSR. A binding 404 falls through to React Router. Other binding responses retain their status and headers. Actions and other methods go directly to SSR. Static hits bypass SSR request hooks; wrap the returned fetch function if your application needs authentication for static files too. Successful files matching Vite's default `/assets/name-HASH.ext` convention (at least eight hash characters) receive `Cache-Control: public, max-age=31536000, immutable`, including conditional 304 responses. Unhashed public files retain the binding's cache policy. Keep Vite's hashed naming convention; configure your own cache policy if you customize asset names. To rename the binding, use `assets: 'STATIC'` in `createWorkerHandler`, `binding: 'STATIC'` in Wrangler, and `getHtmlFromAssets(env, '/index.html', 'STATIC')`. `assets: false` disables static routing when another layer serves the files; you still need a shell provider. `getHtmlFromAssets` returns `Promise<() => { header: string; footer: string }>`. It fetches once per binding, path and outlet, coalesces concurrent loads, and supplies a fresh object for every request. Failed loads are retried on the next call. Call it during a request, when Workers permits binding I/O. Its fourth argument changes the outlet. Build a new Worker deployment when the HTML changes. ## Preview and deploy ```bash # Build client, regular server, route manifest, then Worker. npx ssr-boost build --focus-only all # Preview the built app with local Workers bindings in workerd. npx wrangler dev --local # Optional: inspect the deployable bundle without publishing. npx wrangler deploy --dry-run # Publish the Worker and its static assets together. npx wrangler deploy ``` Wrangler bundles the emitted Worker and dependencies. The minimal template runs without `nodejs_compat`. Do not import `node/production`, the Express adapter, or filesystem helpers into the Worker. The `cloudflare` entry and its library dependency graph contain no Node builtin imports. ## Bindings in hooks and loaders Generate Worker globals with `wrangler types`, or use `@cloudflare/workers-types` in a Worker-specific TypeScript config. Avoid mixing DOM and Workers global Fetch types in that config. ```ts interface Env { ASSETS: Fetcher; MESSAGES: KVNamespace; } const fetch = createWorkerHandler({ App, routes, manifest, getHtml: async (_request, env) => (await getHtmlFromAssets(env))(), onRequest: ({ request, executionContext }) => { const { env, ctx } = executionContext.platform; executionContext.waitUntil(env.MESSAGES.put('last-path', new URL(request.url).pathname)); // ctx is the original Worker execution context. return { headers: { 'X-Runtime': 'workerd' } }; }, }); export default { fetch } satisfies ExportedHandler; ``` Add your KV namespace to Wrangler with `kv_namespaces: [{ binding: 'MESSAGES', id: 'YOUR_KV_ID' }]`. The local preview uses local KV data; seed it with `npx wrangler kv key put --binding MESSAGES --local greeting 'Hello from KV'`. All render hooks receive `context.executionContext`. `onRequest` and `prepare` also receive `executionContext` directly. The default loader context is the SSR request context: ```ts import type { LoaderFunctionArgs } from 'react-router'; import type { ISsrRequestContext } from '@lomray/vite-ssr-boost/core/render'; import type { IWorkerPlatform } from '@lomray/vite-ssr-boost/cloudflare'; export async function loader({ context }: LoaderFunctionArgs) { const requestContext = context as unknown as ISsrRequestContext; const { env } = requestContext.executionContext!.platform as IWorkerPlatform; return { message: await env.MESSAGES.get('greeting') }; } ``` An explicit `routerRequestContext` replaces the default loader context. Browser navigation runs Data mode loaders in the browser: use an HTTP endpoint for binding-backed data on client navigation. Do not serialize bindings or the execution context with `getState`. ## Streaming and runtime limits Streamed HTML defaults to `Content-Encoding: identity` and appends `Cache-Control: no-transform` to preserve early chunks. In local workerd, automatic gzip buffered a small pending shell until the loader resolved; identity delivered the shell immediately. Static assets retain normal platform compression. You can override these defaults in `onShellReady` if you prefer platform compression; verify chunk delivery with your application and deployment. See [Cloudflare compression](https://developers.cloudflare.com/speed/optimization/content/compression/). The default streams HTML and deferred loader frames. Bot user agents, including Googlebot, wait for all content; `onRouterReady` can override `isStream`. `hydration: 'early'` enables interactive pending shells with an async browser entry. See [Stream loader data](/guide/data-streaming). Workers has no Early Hints transport for this handler. It never installs `onEarlyHints`; route styles and preload links are still injected into the document. Rendering consumes CPU time; awaiting I/O and elapsed request time are separate constraints. Choose `abortDelay` for your rendering deadline, give loaders their own I/O deadlines, and check your plan's [CPU, memory and duration limits](https://developers.cloudflare.com/workers/platform/limits/). `abortDelay` begins after loader preparation and does not extend Cloudflare's limits. Use bound `waitUntil` only for background work; its lifetime is limited too. See [Worker execution context](https://developers.cloudflare.com/workers/runtime-apis/context/). Workers cannot read your local build filesystem. Bundle the JSON manifest and fetch static files through the binding. Add `nodejs_compat` only when an application dependency needs supported Node APIs, then test that dependency in workerd. It does not turn a Worker into a Node server or give access to local build paths. See [Node compatibility](https://developers.cloudflare.com/workers/runtime-apis/nodejs/). ## Development Use `ssr-boost dev` for Express + Vite development, lazy route styles and HMR. Use a full build and `wrangler dev --local` to exercise Worker bindings and streaming before deployment. The Cloudflare Vite plugin assessment is recorded here after running the reproducible `node scripts/probe-worker-vite.mjs` probe. It uses `cloudflare({ viteEnvironment: { name: 'ssr' } })` with `SsrBoost()` and a source Worker with an empty development manifest. The production manifest is generated by a build and cannot supply current development route styles. Tested with Vite 8.2.2 and `@cloudflare/vite-plugin` 1.54.4: | Capability | Observed result | | --- | --- | | Worker modules and SSR | Loaded and rendered after restarting with inline `indexHtml` and `assets: false`. | | Assets-backed shell | The plugin rejected the helper's synthetic `/index.html` request with 403. | | Bindings | KV reads and bound `waitUntil` worked in the inline-shell configuration. | | Lazy route CSS | Absent from the initial SSR document. With static routing disabled, CSS requests returned 404. | | Browser entry | Returned 404 in the inline-shell configuration; the source shell had no Vite client injection. | | SSR reload | Editing the lazy route did not update subsequent SSR responses during the probe. | | Browser hydration / HMR | Not verified in a browser; the missing entry and styles already prevent recommending this configuration. | Use the managed development path. The probe's temporary inline-shell variant isolates module loading and bindings from the asset failures. Cloudflare's plugin supplies a Workers environment and HMR support, but the SSR BOOST integration must also provide the development HTML, route styles and hydration behavior. See [Cloudflare Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/) and [Vite environments](https://developers.cloudflare.com/workers/vite-plugin/reference/vite-environments/). ## Tests From the SSR BOOST repository, after `npm run build`: ```bash npm run test:worker:packed npm run test:worker:browser ``` The packed test installs the tarball into a temporary minimal template, builds all entries, checks Worker types, bundles with Wrangler, and boots workerd with Static Assets and KV. It checks routes, cookies, deferred frames, bot buffering, binding reads, `waitUntil`, cache headers and HEAD requests. The browser suite checks hydration, navigation, lazy CSS and interactive deferred boundaries. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/data-streaming # Stream loader data Loader and action promises stream to the browser by default in React Router **Data mode**. Return critical data immediately and leave slower fields as promises. Both `` and React 19 `use()` receive native promises during hydration. Client navigations run your loaders in the browser, where their promises remain native. ## Return a promise ```tsx import { Suspense } from 'react'; import { Await, useLoaderData } from 'react-router'; export function loader() { return { title: 'Deferred', slow: new Promise<{ users: number }>((resolve) => { setTimeout(() => resolve({ users: 3 }), 1500); }), }; } export default function DeferredPage() { const data = useLoaderData() as ReturnType; return (

{data.title}

Loading users…

}> Could not load users.

}> {(value) =>

Users: {value.users}

}
); } ``` The same structure works in an `action`, read with `useActionData()`. React Router awaits the loader/action's own return promise before rendering: return an object containing a promise to defer a field. The transport also handles a promise at the root of an individual `loaderData` or `actionData` entry, promises nested in plain objects and arrays, and promises introduced by resolved values, including further nesting. Repeated references to a promise share one browser promise within that response. For real requests, pass the loader's `request.signal` to `fetch`. Keep loader code usable during client navigation; put server-only work behind an API. Attach rejection handlers to work that might reject **before** `staticHandler.query()` finishes; transport handlers are installed after the query. This follows React Router's [Suspense usage](https://reactrouter.com/how-to/suspense) and [custom Data framework architecture](https://reactrouter.com/start/data/custom). ## React 19 `use()` Keep the consumer inside its Suspense boundary: ```tsx import { Suspense, use } from 'react'; import { useLoaderData } from 'react-router'; function Users({ promise }: { promise: Promise<{ users: number }> }) { const value = use(promise); return

Users: {value.users}

; } export default function DeferredPage() { const data = useLoaderData() as { slow: Promise<{ users: number }> }; return ( Loading users…

}>
); } ``` Use `` on React 18. Rejected `use()` promises go to the nearest React error boundary; `` can render local failure UI and `useAsyncError()` reads its rejection. ## Supported values Initial router data and every streamed resolution use the same [devalue](https://github.com/sveltejs/devalue) value codec, with custom promise and error reducers. The transport does not depend on React Router's internal codec exports. | Value | Browser result | | --- | --- | | JSON primitives, plain objects, arrays | Same values; own string keys are preserved, including `__proto__`. | | `undefined` | Preserved in objects and arrays. | | `Date` | A `Date`, including invalid dates. | | `Map`, `Set` | Native collections, with recursively decoded keys and values. | | `bigint` | A `bigint`, without numeric precision loss. | | `RegExp` | Same source and flags; `lastIndex` resets to zero. | | `NaN`, positive/negative infinity, `-0`, sparse arrays | Preserved by the codec. | | `Promise` | One native promise per request-local identity. | | Errors and route error responses | The fields described below; arbitrary custom properties are omitted. | | Functions, symbols, other class instances, WeakMap/WeakSet | Unsupported; diagnostics warn and these values decode as `undefined`. | | Circular references | Outside the supported contract; diagnostics warn even where the codec can preserve a cycle. Prefer acyclic route data. | Object identity is preserved within a value frame. Across separate settlement frames only promise identity is guaranteed. Codec features beyond this table, such as typed arrays and arbitrary custom types, are not part of the router-data contract. `getState` continues to use **JSON**, so its custom state must contain plain snapshots with no promises or rich values. ## Rejections and aborts Ordinary errors retain `message` and `name`. React Router error responses and rejected `data(value, { status, statusText })` values retain `message`, `status`, `statusText` and `data`; reconstructed response errors also satisfy `isRouteErrorResponse`. Stacks are sent only when diagnostics are enabled. Rejections after the shell was sent cannot change the HTTP status. `abortDelay` defaults to 15 seconds and starts after routing/preparation. It remains active until both React and pending loader/action promises finish, including promises the component never reads. On timeout, pending browser promises reject with `SSR loader/action promise aborted before it settled.` before the response closes. Development diagnostics report `SSR_BOOST_STREAM_PROMISE_ABORTED`. Disconnects cancel rendering and discard queued data when the consumer is gone. Aborting the render does not stop arbitrary application promises; use request cancellation for their underlying work. ## Hydrate the shell early Opt in on the managed entry's lifecycle configuration: ```ts export default entryServer(App, routes, { init: () => ({ hydration: 'early', getState: ({ context }) => ({ app: { locale: context.appProps.locale } }), }), }); ``` For a Fetch handler, pass `hydration: 'early'` alongside `getHtml` and other `createHandler` options. Use an **async** client module script (` ``` ## Replace scripts ```json { "scripts": { "develop": "ssr-boost dev", "build": "ssr-boost build", "build:spa": "ssr-boost build --focus-only client", "start:ssr": "ssr-boost start", "start:spa": "ssr-boost start --focus-only client", "preview": "ssr-boost preview" } } ``` `start:spa` above is the explicit form. Older examples often used `--only-client`, but the current CLI works through `--focus-only`. Run `build:spa` before `start:spa`. For SSR, use `build` followed by `start:ssr`. ## Run it ```bash npm run develop ``` ### Development cold start On an empty Vite cache, dependencies discovered during the first page load can cause an `Invalid hook call` or a `useContext` error until Vite reloads the page. The plugin pre-bundles its browser entry, components and route helpers with their React dependencies before that first load. Existing optimization options are preserved. Use `optimizeDeps.exclude` to opt out for a package or a specific deep import. ## Recommended project shape ```txt src/ App.tsx client.ts server.ts routes.tsx index.html public/ vite.config.ts ``` You can move files around, but the defaults assume `client.ts`, `server.ts` and `index.html`. If you change that, configure the plugin options instead of relying on convention by accident. Without TypeScript aliases, use `SsrBoost({ tsconfigAliases: false })`. For JavaScript projects, also set `clientFile: 'client.js'` and `serverFile: 'server.js'`, and update the HTML script path. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/incremental-ssr # Incremental SSR Choose SSR or the SPA shell for each incoming URL in one application. Both modes use the same browser bundle and React Router route tree, so client navigation between them stays in the current document. ## Roll out public pages first After [migrating your SPA](/guide/migrate-existing-spa), start with `include` and the public pages that need search indexing or link previews: ```ts import entryServer from '@lomray/vite-ssr-boost/adapters/express/entry'; import App from './app'; import routes from './routes'; export default entryServer(App, routes, { ssr: { mode: 'include', routes: ['/', '/about', '/articles/:slug'], bots: 'ssr', }, init: () => ({ onRequest: (req, res) => { if (req.originalUrl.startsWith('/account') && !req.headers.cookie) { res.redirect('/login'); return { shouldCancel: true }; } return {}; }, }), }); ``` The cookie check above only illustrates where an auth redirect belongs; use your application's session validation. `onRequest` runs before the policy, including for crawlers. Add public URL patterns as you verify their HTML, metadata, data loading and browser behavior. Later, use `exclude` for the remaining SPA pages or remove the option to SSR every URL. Keep the default `bots: 'ssr'` so detected crawlers receive SSR even on URLs served as SPA to people. Those routes must still support server execution and enforce authentication. Existing `onlyClient` components keep their browser-only behavior; this option does not make them server-renderable. Bot detection uses the `isbot` user-agent matcher. To wait for all suspended HTML, keep your existing bot-aware `onRouterReady` streaming decision. ## Roll back without rebuilding Restart the same server build with a changed environment variable: ```bash SSR_BOOST_SSR_ROUTES='!/details' npm run start:ssr ``` This serves `/details` as SPA to people while keeping other URLs on SSR. Googlebot still receives SSR for `/details`. No rebuild or separate browser bundle is needed. Apply the variable to every server process and restart them; changing `process.env` after creating the entry/handler does not change its policy. Set the variable in the process environment before startup, rather than in a client-side `VITE_*` variable. `SSR_BOOST_SSR_ROUTES` replaces the configured `mode`, `routes` **and `decide`**, preserving `bots`. It accepts comma-separated path patterns, trims whitespace and ignores empty items: | Value | Result for people | | --- | --- | | `/,/articles/:slug` | SSR only for the listed URLs | | `!/details,!/account{/*rest}` | SPA for these patterns; SSR elsewhere | | `/,/articles/:slug,!/articles/draft` | Include the public URLs, with exclusions winning | | Empty string | SSR for all URLs; disable the configured `decide` | Unset the variable and restart to restore the application configuration. Environment values use string patterns, not JavaScript regular expression literals. Malformed patterns fail during entry/handler creation instead of silently changing the rollout. To keep an existing include rollout while rolling back one URL, repeat its include list and add the exclusion, for example `SSR_BOOST_SSR_ROUTES='/,/articles/:slug,!/articles/draft'`. This keeps unlisted URLs on SPA. A value containing only exclusions selects SSR for every other URL. Active policies default document responses to `Cache-Control: no-store`, so cached SSR/SPA documents do not obscure a rollback. If `onRequest` supplies a cache policy, it takes precedence: configure your CDN's cache keys and invalidation to account for bot and request-specific decisions before enabling document caching. Static asset caching is unchanged. [Document header rules](/guide/caching#documentheaders-rules-options) apply to SPA shells as well as SSR documents, including the private default for requests carrying the session cookie. ## Configuration reference Pass `ssr` in the third argument of the [managed Express entry](/api/server-entry#ssr), or in the options argument of Fetch [`createHandler`](/guide/runtime-adapters#create-a-fetch-handler): ```ts interface ISsrPolicy { mode?: 'all' | 'include' | 'exclude'; routes?: (string | RegExp)[]; bots?: 'ssr' | 'policy'; decide?: (params: { request: Request; url: URL; isBot: boolean; }) => 'ssr' | 'spa' | undefined; } ``` | Option | Default | Behavior | | --- | --- | --- | | `mode` | `'all'` | `all` renders every URL; `include` renders only matching URLs; `exclude` serves matching URLs as SPA. | | `routes` | `[]` | String or RegExp patterns matched against `url.pathname`, including any router basename. An empty include list makes every URL SPA; an empty exclude list makes every URL SSR. `all` ignores the list. | | `bots` | `'ssr'` | Detected crawlers always receive SSR, ahead of `decide` and the environment override. `'policy'` makes them follow the same decisions as other requests. | | `decide` | — | Synchronously override the configured mode for one request; return `undefined` to use the route policy. Receives the original Fetch Request, parsed URL and detected bot flag. | String patterns use path-to-regexp 8 syntax: `/articles/:slug` for one segment, `/account/*rest` for one or more segments, and `/account{/*rest}` for the account root and all descendants. Optional parts use braces, such as `/articles{/:slug}`. String matching covers the entire pathname, is case-insensitive and permits a trailing slash. Query strings are ignored by patterns and remain available to `decide`. Use a RegExp when you need different matching rules, for example `/^\/internal(?:\/|$)/`; repeated requests do not share its mutable `lastIndex`. For a request-dependent switch: ```ts ssr: { mode: 'include', routes: ['/', '/articles/:slug'], decide: ({ url }) => url.searchParams.get('render') === 'spa' ? 'spa' : undefined, }, ``` Fetch handlers use the same option: ```ts const handle = createHandler( { handler: createStaticHandler(routes), createApp, renderToStream }, { getHtml, prepare: createRouteAssetPreparer({ buildDir: './build' }), ssr: { mode: 'exclude', routes: ['/details'] }, onRequest: ({ request }) => initializeRequest(request), }, ); ``` `getHtml` supplies the existing document shell. On Node, [`loadHtmlShell`](/api/node-production) reads it once; on edge runtimes, supply an in-memory shell and your normal asset `prepare` hook. For a router mounted at `/app`, pass `basename: '/app'` to both `createStaticHandler` and `createHandler`, and write policy patterns such as `/app/details`. Managed Express takes the basename from `routerOptions`. ## What happens on SPA URLs The server runs `onRequest`, selects the policy, matches route structure without importing lazy server modules or running loaders/actions, and injects the matched route's CSS and module preloads. Fetch `prepare` hooks receive `context.matches` and `context.isSpa`; `routerContext` is absent on SPA responses. The managed server prepares Vite assets automatically. The response is the empty application shell with status 200, `Content-Type: text/html; charset=utf-8`, and `data-force-spa="1"`. It uses the same shell generation as `spaIndex`; enabling an extra `index-spa.html` build artifact is optional. Production file contents and generated shells are cached in memory per process. Each request gets a fresh mutable shell for asset injection; development template changes invalidate generation. Fetch `getHtml` still runs per request, so request-specific shells are not reused for a different request. SPA documents contain no router hydration data or `getState` snapshots. SSR render hooks (`onRouterReady`, `onShellReady`, `onResponse`, `getState`, and render error hooks) do not run for them. Keep authentication and HTTP redirects in `onRequest`. An Express hook can send a response and return `shouldCancel`; a Fetch hook can return a Response. HEAD returns the same metadata without a body. The browser entry mounts with `createRoot` and reports `isSSRMode: false` to its `init` hook. Loaders and loader redirects execute in the browser, as they already do during client navigation. Keep those loaders browser-compatible and use APIs for server-only work. A loader redirect on an initial SPA URL changes the client route after the 200 shell; it is not an HTTP redirect. Unknown SPA URLs also return the 200 shell and let the browser router render its fallback. Direct non-JavaScript form submissions to SPA-selected URLs do not run server actions. Links and router navigation between SSR and SPA routes use the shared browser router without reloading the document. Reloading a SPA URL mounts again; a direct link to an SSR URL still passes cookies through the Fetch request to its loaders. SPA routes render no application content server-side. Crawlers see only the shell when they follow the SPA policy, including with `bots: 'policy'`; the default bot override keeps detected crawlers on SSR. Check [policy diagnostics](/reference/diagnostics#ssr_boost_ssr_policy) during development for the chosen policy and misspelled patterns. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/migrate-existing-spa # Migrate an existing Vite SPA Add SSR while keeping your React Router [Data-mode route objects](https://reactrouter.com/start/modes) and components. This guide follows the five-file change in the [minimal example README](https://github.com/Lomray-Software/vite-template/tree/example/minimal#from-a-plain-vite-spa-to-this-project). ## Automatic: `npx ssr-boost init` For a Vite + React app using `createBrowserRouter` and route objects, preview the migration first: ```bash npx ssr-boost init --dry-run npx ssr-boost init --apply npm install npx ssr-boost doctor npm run build npm run start:ssr ``` If the library is not installed yet, use `npx --package @lomray/vite-ssr-boost ssr-boost init --dry-run` (then repeat with `--apply`). This downloads the CLI to npm's cache; `init` itself never installs dependencies. With neither flag, `init` defaults to a dry run and prints a unified diff without creating files. `--root `, `--entry ` and `--routes ` select the project, browser entry and exported route module; file overrides are relative to the project directory. The command makes the five integration changes below: adds `SsrBoost()` while preserving existing plugins, inserts the outlet inside `#root`, replaces the browser bootstrap with the library entry, creates an Express server entry next to it, and updates the dependency and scripts. An existing `dev` script becomes `ssr-boost dev` without adding a duplicate `develop`; otherwise it updates or adds `develop`. It always sets `build` to `ssr-boost build` and adds `start:ssr` as `ssr-boost start`. A stock `preview` script also switches to `ssr-boost preview`. It preserves an imported App wrapper around `RouterProvider`. For `StrictMode`, a Fragment, or no wrapper, it creates an explicit App component in both entries so the browser and server render the same tree. Inline route declarations and their dependencies move into an additional `routes.ssr.tsx` (or JS/TS equivalent) module shared by both entries. Existing exported route modules stay in place, preserving their import specifiers and local bindings. Repeating `--apply` makes no further changes. Automatic migration supports TypeScript and JavaScript, root `index.html` and Vite `root: 'src'`, literal plugin arrays, static routes, and conventional aliases. It stops before writing for JSX `BrowserRouter`/`Routes`, missing or multiple `createBrowserRouter` calls, multiple HTML/build entries, runtime-generated Vite configuration, an existing server file, custom router options, wrappers requiring extra props, additional browser startup statements, or a Vite `base` other than `/`. The error identifies the file and construct and links to this manual reference. It does not invent metadata providers or request hooks: adapt those with the steps below when your app needs them. Run [`doctor --json`](/api/cli#ssr-boost-doctor) after further changes. Review browser globals in your components using the [pitfalls below](#pitfalls). ## Prerequisites - Node 22: the package declares `engines.node: ">=22.12.0"`; use Node 22.23.2 for this example and its tooling. - Vite 5 or newer, React and React DOM 18.2 or newer, and React Router 7 route objects (`react-router >=7.0.1`). The source example uses Vite 8, React 19 and React Router 8; match your dependencies' peer and engine requirements. - Install `@lomray/vite-ssr-boost`; the copied entries also use `@lomray/react-head-manager` and `isbot`. The example's [package.json](https://github.com/Lomray-Software/vite-template/blob/example/minimal/package.json) lists all six direct runtime dependencies and the build tools, including `@vitejs/plugin-react` and `vite-plugin-devtools-json`. These entries assume the example's shared [App wrapper](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/app.tsx), [routes](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/routes/index.ts), [state keys](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/constants/state-key.ts) and [tsconfig aliases](https://github.com/Lomray-Software/vite-template/blob/example/minimal/tsconfig.json). `App` accepts a meta manager through its `client` or `server` props and provides it to the route tree. Add that wrapper and metadata provider first if your SPA does not have them, then adapt the import paths to your application. ## The five-file change The five after blocks below are copied in full from `example/minimal` at commit `789816f29efa9e25280486085470a5b2fa419e84`. The before files are linked beside each change; they are documentation fixtures in the template. This comparison starts with an app whose Vite root is already `src` and whose shared wrapper already supplies metadata. ### `vite.config.ts` Before: [Vite SPA configuration](https://github.com/Lomray-Software/vite-template/blob/example/minimal/docs/spa-before/vite.config.ts.txt). Add the plugin import and `SsrBoost()` to `plugins`; the plugin supplies alias handling and the CLI handles build cleanup, replacing `resolve.tsconfigPaths` and `emptyOutDir` in the before file. After — source: [vite.config.ts](https://github.com/Lomray-Software/vite-template/blob/example/minimal/vite.config.ts). ```ts import SsrBoost from '@lomray/vite-ssr-boost/plugin'; import devtoolsJson from 'vite-plugin-devtools-json'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; // https://vitejs.dev/config/ export default defineConfig({ root: 'src', publicDir: '../public', envDir: '../', build: { outDir: '../build', }, plugins: [devtoolsJson(), SsrBoost(), react()], }); ``` ### `src/index.html` Before: [SPA HTML](https://github.com/Lomray-Software/vite-template/blob/example/minimal/docs/spa-before/src/index.html.txt). Add the SSR outlet inside the root element and keep the client script. After — source: [src/index.html](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/index.html). ```html Vite + React + TS
``` ### `src/client.ts` Before: [SPA client entry](https://github.com/Lomray-Software/vite-template/blob/example/minimal/docs/spa-before/src/client.ts.txt). Replace the direct router and `createRoot` setup with `entryClient`, which hydrates SSR HTML or mounts the SPA and restores metadata state. After — source: [src/client.ts](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/client.ts). ```ts import { Manager as MetaManager } from '@lomray/react-head-manager'; import entryClient from '@lomray/vite-ssr-boost/browser/entry'; import getServerState from '@lomray/vite-ssr-boost/helpers/get-server-state'; import StateKey from '@constants/state-key'; import routes from '@routes/index'; import App from './app'; void entryClient(App, routes, { init: () => Promise.resolve({ metaManager: new MetaManager(getServerState(StateKey.metaManager, import.meta.env.PROD)), }), }); ``` ### `src/server.ts` Before: the SPA has no server entry. Add the managed Express entry, create a meta manager per request, inject its tags before the shell, and transfer its state to the browser. After — source: [src/server.ts](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/server.ts). ```ts import { Manager as MetaManager } from '@lomray/react-head-manager'; import MetaServer from '@lomray/react-head-manager/server'; import entryServer from '@lomray/vite-ssr-boost/adapters/express/entry'; import { isbot } from 'isbot'; import StateKey from '@constants/state-key'; import routes from '@routes/index'; import App from './app'; export default entryServer(App, routes, { init: () => ({ onRequest: () => ({ appProps: { metaManager: new MetaManager() } }), onRouterReady: ({ context: { request } }) => ({ isStream: !isbot(request.headers.get('user-agent') ?? '') && !/(?:^|;\s*)isCrawler=1(?:;|$)/.test(request.headers.get('cookie') ?? ''), }), onShellReady: ({ context: { appProps, html } }) => ({ header: MetaServer.inject(html.header, appProps.metaManager), }), getState: ({ context: { appProps } }) => ({ [StateKey.metaManager]: MetaServer.getState(appProps.metaManager), }), }), }); ``` Crawler user agents and the `isCrawler=1` cookie select a complete response before sending it. The client entry restores the metadata state from the same request. ### `package.json` scripts Before: [Vite scripts](https://github.com/Lomray-Software/vite-template/blob/example/minimal/docs/spa-before/package.json.txt). Replace the development, build and preview commands and add SSR/SPA start scripts. This is the complete `scripts` object; keep the rest of `package.json` and retain your own tooling scripts if their tools differ. The `smoke` script requires the example's [scripts directory](https://github.com/Lomray-Software/vite-template/tree/example/minimal/scripts). After — source: [package.json](https://github.com/Lomray-Software/vite-template/blob/example/minimal/package.json), the `scripts` object printed with `JSON.stringify(scripts, null, 2)`. ```json { "develop": "ssr-boost dev", "build": "ssr-boost build", "build:spa": "ssr-boost build --focus-only client", "start:ssr": "ssr-boost start", "start:spa": "ssr-boost start --focus-only client", "preview": "ssr-boost preview", "smoke": "node scripts/smoke.mjs", "lint:check": "eslint \"src/**/*.{ts,tsx,*.ts,*tsx}\" --max-warnings=0", "lint:format": "eslint --fix \"src/**/*.{ts,tsx,*.ts,*tsx}\"", "style:check": "stylelint \"src/**/*.{css,scss}\"", "style:format": "stylelint --fix \"src/**/*.{css,scss}\"", "ts:check": "tsc --project ./tsconfig.json --skipLibCheck --noemit", "prepare": "husky" } ``` Run `npm run dev` if your app has a `dev` script, or `npm run develop` for the example above. Run `npm run build` and then `npm run start:ssr` to serve the SSR build. ## Data loading Loader and action promises stream by default in Data mode. Return `{ fast, slow: fetchSlow() }` and consume `slow` inside Suspense with `` or React 19 `use()`. The browser reconstructs native promises before creating the router; client navigation loaders keep their native promises. See [Stream loader data](/guide/data-streaming) for the supported value matrix, errors and opt-in `hydration: 'early'`. Custom `getState` snapshots still use JSON. The [users page](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/pages/users/index.tsx) shows a loader that waits for its data before returning. Keep loader code usable in the browser for client navigation; call an API for server-only operations. ## Pitfalls ### Browser globals at module scope Server imports execute without `window` or `document`. Move browser access into effects, or use a lazy `onlyClient` route with a fallback as shown in the [example routes](https://github.com/Lomray-Software/vite-template/blob/example/minimal/src/routes/index.ts). ### Loader promises Use the [data-loading contract above](#data-loading) when deciding which work belongs in a loader. Check the first browser render as well as the server response when changing that boundary. ### CSS imported by dependencies If a dependency imports CSS that needs Vite's server transforms, include the package in `ssr.noExternal`. [Vite's SSR externals documentation](https://vite.dev/guide/ssr.html#ssr-externals) explains when dependencies bypass those transforms. ### Environment variables Read public values through `import.meta.env` and keep secrets out of `VITE_*` variables, which are exposed to client code. Restart development after changing env files; see [Vite's environment variable guide](https://vite.dev/guide/env-and-mode.html). ### Hydration mismatches Compare the server HTML with the initial browser render, including data, dates, randomness and browser-dependent branches. [React's hydration troubleshooting](https://react.dev/reference/react-dom/client/hydrateRoot#troubleshooting) requires those outputs to match; correct their inputs before hiding a warning. ## Roll out SSR one URL at a time Use [incremental SSR](/guide/incremental-ssr) to start with `ssr: { mode: 'include', routes: ['/', '/articles/:slug'] }` in your managed server entry, widen the public pages over time, and keep the remaining URLs on the SPA shell in the same build. Crawlers stay on SSR by default. Restart with `SSR_BOOST_SSR_ROUTES='!/details'` to roll a URL back to SPA without rebuilding; keep authentication redirects in `onRequest` because SPA route loaders execute in the browser. ## Going back to SPA Run `npm run build:spa`, then `npm run start:spa`; both scripts select `--focus-only client`. The app keeps the same route objects and browser entry, and the build omits the SSR server. Run `npm run build` again before returning to `npm run start:ssr`. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/rendering-modes # Rendering Modes ## SSR mode SSR mode is the default mental model of the package. In SSR mode: - the browser entry hydrates existing HTML - the server entry uses `createStaticHandler` - the server can stream the shell or wait for the full tree - redirect and status components can write to the server response context This is the mode to use when first paint, crawler behavior, response status control or request-aware rendering actually matter. ## SPA mode The same app can run as SPA without changing your route structure. There are two common paths: 1. run or build only the client side 2. generate an extra SPA-only index file and force client mount The package uses `data-force-spa="1"` on the generated SPA index to tell the browser entry to skip hydration and render as a pure client app. ## Switching between SSR and SPA For per-request selection inside one build, use the [incremental SSR policy](/guide/incremental-ssr): include public URLs, keep other pages on the SPA shell, and roll URLs back with a process environment override. This is one of the practical strengths of the package. - During normal SSR, `entryClient` hydrates. - During SPA fallback, `entryClient` mounts with `createRoot`. - During additional SPA entrypoint builds, the plugin can emit `index-spa.html`. That means one route tree can serve: - the main SSR app - a service worker shell - a mobile or embedded SPA entry - extra product surfaces from custom entrypoint builds ## Streaming or waiting for all HTML By default the renderer is stream-oriented. Inside `onRouterReady` you can return: ```ts { isStream: false, } ``` Use that when a specific request should wait for all React content, for example for a crawler. `onResponse` remains a chunk callback; `isStream: false` does not turn it into a full-document transform. ## When SPA is the better choice Use SPA output when: - you need a service-worker-friendly offline shell - you build an embedded app where server response codes are irrelevant - the route surface is private and does not benefit from SSR - you want a custom entrypoint for Capacitor or another app shell Use SSR when: - you need response status or redirect control - you care about crawler-visible HTML - you want request-aware rendering - you want stream rendering and Suspense on the server --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/routing # Routing ## Routing model The package is built around React Router route objects. Client side: - matched lazy routes are resolved before router creation - the browser router is created with your `routerOptions` Server side: - routes go through `createStaticHandler` - the request is translated into a fetch request - the static router renders through `StaticRouterProvider` The point is simple: you keep React Router as the routing source of truth. ## Supported route declaration patterns Supported: ```tsx import type { RouteObject } from 'react-router'; import HomePage from './pages/home'; const routes: RouteObject[] = [ { path: '/home', Component: HomePage, }, { path: '/layout', element: , }, { path: '/lazy', lazy: () => import('./pages/lazy'), }, ]; ``` Arrays and route objects can be wrapped with `satisfies`, `as`, parentheses or non-null assertions. The parser follows default and named import aliases, including `import boot from '.../browser/entry'` and `import { entry as boot } from '.../browser/entry'`. Both call `boot(App, routes)`. Shared static objects, literal computed keys, explicit IDs, and imported child arrays are supported: ```tsx import { childRoutes as children } from './children'; const shared = { handle: { title: 'Account' } }; export default [ { ...shared, ['id']: 'account', path: '/account', children }, ] satisfies import('react-router').RouteObject[]; ``` Object spreads follow JavaScript override order. Static bindings and named re-exports are resolved across local modules and Vite aliases. Explicit `id` values are used for assets; generated IDs retain the original array positions, including below explicitly named parents. Routes without asset imports never renumber their siblings. Lazy values can be arrow functions or function expressions returning one literal `import()`, with optional `async`/`await` or `.then()` to select the module's `Component`. For example: ```tsx { path: '/account', lazy: async function () { return await import('./pages/account'); } } { path: '/account', lazy: () => import('./pages/account').then(m => ({ Component: m.Account })) } ``` Runtime route factories, array spreads, computed keys that are not string literals, non-static IDs, cycles, conditional children and non-literal or multiple lazy imports produce one error naming the file, line and construct. Dynamic path helpers remain supported because paths do not select asset modules; doctor represents those paths as `null` in support bundles instead of executing application code. ## Loader and action promises Return promises for slow fields, such as `{ title, slow: fetchUsers() }`, and render them inside Suspense with `` or React 19 `use()`. The server streams their settlements and the browser reconstructs promises before router creation. See [Stream loader data](/guide/data-streaming), including `hydration: 'early'` for shell interaction while boundaries are pending. Keep loaders usable in the browser for client navigations. ## `routesPath` `routesPath` helps the plugin detect where route declarations live. Use it when your route files are not obvious from the default project shape. Example: ```ts SsrBoost({ routesPath: '/routes/', }); ``` ## `basename` Client side: ```tsx void entryClient(App, routes, { routerOptions: { basename: '/custom', }, }); ``` Server side: ```tsx export default entryServer(App, routes, { routerOptions: { basename: '/custom', }, }); ``` If you also serve static assets from a custom Vite `base`, keep your server static middleware aligned with it. ## Redirects and statuses inside routes Use built-in components when a route should control the HTTP response: - `Navigate` for redirects with server support - `ResponseStatus` for explicit status codes That gives you a React-level API while still writing the actual `Response` in server context. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/runtime-adapters # Runtime adapters The SSR core speaks the Fetch standard: ```ts type SsrHandler = ( request: Request, context?: SsrExecutionContext, ) => Promise; ``` `SsrExecutionContext` is optional and carries `onEarlyHints`, adapter-specific `platform` data and `waitUntil`. Hooks and default loader context can also access it as `context.executionContext`. See [Cloudflare Workers](/guide/cloudflare) for a complete Worker build and Static Assets path. Choose the managed CLI server for Vite development, HMR, asset manifests and production static files. It still uses Express. Choose a Fetch handler when your application or hosting platform owns the HTTP server, bundling and asset delivery; an adapter does not replace the CLI build pipeline. ## Existing Express applications Move your server entry import into the Express adapter: ```ts import entryServer from '@lomray/vite-ssr-boost/adapters/express/entry'; export default entryServer(App, routes, options); ``` The old entrypoints are removed in this major release. `onRequest` and render hooks receive the same live Express 5 `req` and `res` objects, so existing headers, cookies, and response takeover keep working. New Fetch handlers do not emulate Express objects. `onResponse` keeps the same signature and still transforms streamed HTML. The core decodes split UTF-8 chunks safely before invoking it. ### Migration notes See [Upgrade from 7 to 8](/guide/upgrade-v8) for the complete import map, `onResponse` contract, `context.request`, Node requirement and changed error handling. Express and compression are optional dependencies installed by default. If your install command uses `--omit=optional`, install them explicitly: ```bash npm i express compression ``` ## Create a Fetch handler ```tsx import createHandler from '@lomray/vite-ssr-boost/core/handler'; import renderToStream from '@lomray/vite-ssr-boost/node/render-to-stream'; import { createStaticHandler } from 'react-router'; const handler = createHandler( { createApp: (children) => {children}, handler: createStaticHandler(routes), renderToStream, }, { getHtml: () => ({ header: '
', footer: '
', }), }, ); ``` Use `@lomray/vite-ssr-boost/edge/render-to-stream` instead for Web-standard edge runtimes. This example returns server HTML. For an interactive app, put your built browser entry script in `footer`, after the closing root element, and serve its JS/CSS assets. The renderer writes escaped router and `getState` data before that footer so it is available when the browser entry runs. `getHtml` supplies a fresh shell per request; `onRequest` can return `{ appProps, headers, status }` or a `Response` to bypass rendering. These extensionless imports work directly in Node and bundlers; explicit `.js` imports remain supported. `createHandler` also accepts `diagnostics?: boolean` in its second argument, alongside `getHtml`. It defaults to `process.env.NODE_ENV !== 'production'`, or `true` in runtimes without `process`. `SSR_BOOST_DIAGNOSTICS=0` or `1` overrides the option wherever environment variables are available. Enabled checks warn once per distinct message about state serialization, shell boundaries and completed response output; disabled checks do no state walking or HTML accumulation. See [Development diagnostics](/reference/diagnostics) for the codes and fixes. For file-backed shells, `loadHtmlShell` validates exactly one outlet in every mode before producing `{ header, footer }`. ## Adapters The managed CLI is the default path. A custom transport owns the development server and static assets. For Node production servers, the production helpers supply the HTML shell and matched route assets. The [custom-server example](https://github.com/Lomray-Software/vite-template/tree/example/custom-server) demonstrates this integration with the managed CLI in development and Fastify in production. Native Node or connect-style: ```ts import http from 'node:http'; import adapterNode from '@lomray/vite-ssr-boost/adapters/node'; http.createServer(adapterNode(handler)).listen(3000); ``` The Node and Fastify adapters also accept HTTP/2 compatibility requests/responses, including `:authority`, separate cookies and Early Hints. Express: ```ts import adapterExpress from '@lomray/vite-ssr-boost/adapters/express'; app.use(adapterExpress(handler)); ``` Fastify production launcher (`server/index.mjs`): The built server in the custom-server example exports `handler` and `configureHandler`. The latter passes `getHtml` and `prepare` to its Fetch handler before Fastify starts accepting requests. Run `npm run build` first, then start this launcher with `node server/index.mjs`. ```js import { join, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import fastifyStatic from '@fastify/static'; import adapterFastify from '@lomray/vite-ssr-boost/adapters/fastify'; import { createRouteAssetPreparer, loadHtmlShell } from '@lomray/vite-ssr-boost/node/production'; import Fastify from 'fastify'; import { configureHandler, handler } from '../build/server/server.js'; const buildDir = fileURLToPath(new URL('../build/', import.meta.url)); const clientDir = join(buildDir, 'client'); configureHandler({ getHtml: await loadHtmlShell({ indexFile: join(clientDir, 'index.html') }), prepare: createRouteAssetPreparer({ buildDir }), }); const app = Fastify(); await app.register(fastifyStatic, { root: clientDir, index: false, wildcard: false, immutable: true, maxAge: '1y', setHeaders: (reply, filePath) => { if (!filePath.startsWith(`${join(clientDir, 'assets')}${sep}`)) { reply.header('Cache-Control', 'public, max-age=0'); } }, }); app.all('/*', adapterFastify(handler, { compression: true })); for (const signal of ['SIGINT', 'SIGTERM']) { process.once(signal, () => { void app.close().catch((error) => { console.error(error); process.exitCode = 1; }); }); } const address = await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' }); console.info(`Fastify listening at ${address}`); ``` `loadHtmlShell` reads the built HTML once and returns a fresh shell per request. `createRouteAssetPreparer` reads `build/server/assets-manifest.json` when a request first matches routes, caches it for that preparer, and forwards Early Hints through the adapter. Paths resolved from `import.meta.url` let the launcher start from any working directory. See the [Node production API](/api/node-production) for options. Fastify request hooks and their response headers are preserved. The adapter uses `reply.hijack()` to stream through the raw transport, so Fastify serialization and `onSend` hooks are bypassed. Use the adapter's compression option for this path. Hono: ```ts import adapterHono from '@lomray/vite-ssr-boost/adapters/hono'; app.all('*', adapterHono(handler)); ``` ### Cloudflare Workers The managed CLI is the default path for Node/Express applications. A custom Worker transport owns the development server, static assets and route-asset injection; the [custom-server example](https://github.com/Lomray-Software/vite-template/tree/example/custom-server) demonstrates those responsibilities with a Fastify production launcher. ```ts import adapterEdge from '@lomray/vite-ssr-boost/adapters/edge'; export default { fetch: adapterEdge(handler) }; ``` For Bun use `Bun.serve({ fetch: adapterEdge(handler) })`; for Deno use `Deno.serve(adapterEdge(handler))`. Bundle the edge renderer and supply the platform's static asset handling. The CLI's `build-vercel` and `--serverless` output remains a Node/Express deployment; it does not generate a Cloudflare Worker or Vercel Edge bundle. Keep the target runtime's package resolution conditions enabled. For a custom Cloudflare bundle, include `workerd` and `worker` conditions so React 19 selects its Worker renderer; the browser-only renderer requires APIs such as `MessageChannel` that workerd does not provide. ## Request bodies The Node adapter streams the original request body. Express and Fastify do the same when `request.body` is undefined, including untouched multipart uploads. Parsed JSON and flat URL-encoded fields (including arrays of scalar values) are serialized automatically. For nested URL-encoded objects or an already consumed custom/multipart body, provide `getBody`: ```ts app.use(adapterExpress(handler, { getBody: (request) => JSON.stringify(request.body), })); ``` This keeps parser-specific objects out of the core and makes conversion failures explicit. The managed Express server accepts `getBody` from `init` too. For middleware that already consumed a multipart body, rebuild the fields/files your router action needs as `FormData`: ```ts entryServer(App, routes, { init: () => ({ getBody: (req) => { const form = new FormData(); form.append('name', req.body.name); return form; }, }), }); ``` Fetch generates the new multipart boundary. Include uploaded files as `Blob` entries when needed; `req.files` and other parser-specific data are not copied automatically. Returning `null` explicitly supplies an empty body. The callback is only used for methods that can carry a request body. ## Early Hints 103 Early Hints are emitted out of band because a final `Response` cannot represent an informational response: ```ts prepare: async ({ executionContext }) => { const hints = new Headers(); hints.append('Link', '; rel=preload; as=style'); await executionContext?.onEarlyHints?.(hints); }; ``` Node and Fastify feature-detect `writeEarlyHints`. Unsupported runtimes safely ignore the hook. ## Compression Compression is an adapter concern and never runs in the core. The managed Express server keeps its existing `compression` middleware. The new Node, Express, Fastify, and edge adapters can opt into streaming compression: ```ts http.createServer(adapterNode(handler, { compression: true })); app.all('/*', adapterFastify(handler, { compression: true })); export default { fetch: adapterEdge(handler, { compression: true }), }; ``` The adapters negotiate `gzip` or `deflate`, preserve separate cookies, and skip partial, already encoded, and `no-transform` responses. Node transports flush compressed HTML incrementally, so the browser receives the shell before Suspense finishes. Edge compression uses `CompressionStream`, whose buffering depends on the runtime; leave it off when the hosting platform handles compression. ## Cookies Append each cookie independently: ```ts const headers = new Headers(); headers.append('Set-Cookie', 'session=one; Path=/'); headers.append('Set-Cookie', 'theme=dark; Path=/'); ``` Node transports use `headers.getSetCookie()` and emit distinct headers. Never split a `Set-Cookie` value on commas because an `Expires` attribute contains a comma. Loader/action and server `` redirects also preserve headers set by `onRequest` and hooks that ran before the redirect. Redirect headers override matching hook headers; `Set-Cookie` values from both are appended separately. For rendered routes, React Router exposes loader/action headers on `context.routerContext`; copy the headers your HTML document needs in `onRouterReady`. A JSON loader's `Content-Type` is not the document's content type. The server-side [HTTP helpers](/guide/caching) provide explicit `copyLoaderHeaders` allowlists and ordered `documentHeaders` policies. Both `createHandler` and the managed entry's `init` result accept `documentHeaders`, `sessionCookie` and `protectPrivate`. Document rules run after `onShellReady`; redirects retain the precedence above. ## Streaming and cancellation Follow the [data-loading contract](/guide/migrate-existing-spa#data-loading) for loader data at first paint and the Suspense pattern used for streamed data. The default sends the shell as soon as React makes it available. Return `{ isStream: false }` from `onRouterReady` to wait for the complete tree, for example for crawlers. `onResponse` still receives chunks in either mode; a chunk is not guaranteed to contain a complete HTML tag. `abortDelay` limits React rendering, starting after loaders and request hooks finish. Pass `request.signal` to loader fetches to cancel their work on disconnect. Node, Express and Fastify stop quietly if a loader rejects after the client disconnects; other handler errors still reach the framework's error handler. Once the handler finishes writing or cancelling its response, these adapters abort the request signal and remove transport listeners, releasing Fetch signal followers without waiting for garbage collection. Cleanup uses a `null` abort reason; an earlier disconnect keeps its original abort reason. Shell failures return 500; errors after the shell has been sent keep the committed status and let React recover on the client. HEAD and 204/205/304 responses have no body. Set redirects and statuses before the shell is sent; components inside a suspended boundary cannot change headers after that point. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/server-lifecycle # Server Lifecycle ## Mental model The server entry returns a render pipeline definition, not a started server. At runtime the package: 1. creates or reuses server config 2. loads your server entry 3. calls `onRequest` 4. applies the [SSR policy](/guide/incremental-ssr), serving the SPA shell when selected or querying the React Router static handler for SSR 5. decides whether to stream or wait 6. writes HTML, state and response mutations That is where the customization hooks fit. Policy-selected SPA responses run `onRequest` and asset preparation, then return the shell without loaders or SSR render hooks. Fetch asset preparation can use `context.matches` in both modes; `context.isSpa` identifies a SPA response and `routerContext` exists only after an SSR query. Render hooks receive a [context](/api/server-entry#hook-context) with the shared Fetch `request` and live Express `req` / `res`; `onRequest` receives `(req, res)` before that context is created. Development requests also run [diagnostics](/reference/diagnostics) for non-serializable state, invalid `onResponse` returns, missing hydration scripts and duplicate output, with stable warning codes emitted once per distinct message. They are enabled for `ssr-boost dev`, disabled for `ssr-boost start` and managed serverless, and controlled by the Fetch handler's `diagnostics` option; `SSR_BOOST_DIAGNOSTICS=0|1` overrides either setting. Invalid HTML outlet counts always throw for file-backed shells, including in production. ## `onServerCreated` Called once after the Express app exists. Use it for: - custom middleware - request logging middleware - metrics registration - extra endpoints outside the React app ## `onServerStarted` Called once after the HTTP server starts listening. Use it for: - boot logs - post-start probes - integration with surrounding process managers ## `onRequest` Called for every incoming request before rendering. Return shape: ```ts { appProps?: Record; hasEarlyHints?: boolean; shouldSkip?: boolean; shouldCancel?: boolean; } ``` Use it for: - request-scoped app props - auth or locale prep - per-request state manager creation - short-circuiting certain URLs `shouldSkip` passes control to the next Express middleware. `shouldCancel` stops the current handling path completely. ## `onRouterReady` Called after the static handler resolved and router context exists. Return: ```ts { isStream?: boolean; } ``` This is the place to switch between streaming and full-document rendering based on user agent, route match or any other request-level policy. ## Promise streaming and hydration Loader/action promises stream by default. Set `hydration: 'early'` in the lifecycle configuration (or Fetch handler options) to hydrate the parsed shell while boundaries are pending. The early block contains `getState` custom state before router state, so custom state must be available at `onShellReady`. Default footer ordering remains custom state, router state, footer. `nonce` applies to React and all generated scripts; `bootstrapScriptContent` is forwarded with the early shell marker prepended when enabled. See [Stream loader data](/guide/data-streaming). Default footer responses without loader/action data or errors use ordinary router hydration state. The stream decoder remains part of the browser entry for streamed responses. The prepared document header is available immediately after shell hooks and response metadata are finalized. Later React/data chunks remain pull-based, including with `onResponse` and diagnostics. Managed Express flushes each compressed chunk; see [streamed HTML and compression](/guide/deployment#streamed-html-and-compression). ## `onShellReady` Replaces the template header or footer around the React stream. Generated hydration state is preserved when replacing the footer. Custom state scripts from `getState` come first, followed by router state and then the footer, so custom state is available when router state unblocks hydration. Return: ```ts { header?: string; footer?: string; } ``` Typical uses: - analytics bootstrap - state container tags - request-specific metadata For final document policies, prefer the `documentHeaders` lifecycle option: it runs after this hook, including its cookie mutations. Configure `sessionCookie` to make authenticated documents private by default. Loader/action headers remain explicit; use `copyLoaderHeaders` with an allowlist. See [Document headers and caching](/guide/caching). ## `onResponse` Receives `{ context, html, isEnd }` for HTML chunks as they are written, with `isEnd: false`. Use it when you need to mutate generated HTML in transit, for example to inject payloads or patch chunks before they leave the server. Return `undefined` (or return nothing) to keep the original chunk. A string replaces the chunk, including `''`, which withholds it so an incremental transform can retain an unfinished token until more HTML arrives. After the composed body stream finishes, including its footer, the hook runs once more with `html: ''` and `isEnd: true`. Return a string to append any retained content to the response; `undefined` or `''` appends nothing. The same contract applies when `isStream` is `false`. Existing hooks can ignore `isEnd`. For example, with an `@lomray/consistent-suspense` stream transform stored in `appProps`: ```ts onResponse: ({ context: { appProps: { streamSuspense }, isStream }, html, isEnd }) => { if (!isStream) return; return isEnd ? streamSuspense.end() : streamSuspense.analyze(html); }, ``` ## `getState` Returns serializable state that should be exposed to the client. The package then writes it into the response payload so `helpers/get-server-state` can pick it up later on the client. ## `onShellError` and `onError` `onShellError` lets you replace the default fatal shell HTML. `onError` receives normalized stream error information, including timeout, abort or cancel scenarios. Use it for logging and observability, not for ad hoc HTML rendering. ## Abort behavior The React render aborts when: - `abortDelay` is exceeded - the client disconnects before the response finishes - a source or destination stream fails Pending loader/action promises also reject on abort; connected browsers receive rejection scripts before closing. The timer remains active until both React and router promises finish, including unused data. The timer starts after loaders and `onRouterReady` finish. Use the loader's `request.signal` for outbound requests. Finishing the incoming request body does not cancel a response still streaming. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/testing # Test SSR routes Use `@lomray/vite-ssr-boost/testing` to run your route objects through the real Fetch SSR core without opening a port. It exercises loaders, redirects, server HTML and the streamed hydration payload. React Router recommends [`createRoutesStub`](https://reactrouter.com/start/data/testing) for isolated components that need router context; this kit covers the server side of full-route integration tests. Use the browser helpers to check actual hydration and interactivity. ## Vitest and deferred data Install Vitest in your application (`npm install -D vitest`), use the Node test environment, and import your normal route components and providers. This complete 20-line test controls a deferred loader field: ```tsx // @vitest-environment node import React, { Suspense } from 'react'; import { Await, useLoaderData } from 'react-router'; import { expect, it } from 'vitest'; import { createDeferred, createTestHandler } from '@lomray/vite-ssr-boost/testing'; it('streams users after the shell', async () => { const users = createDeferred(); function Page() { const data = useLoaderData() as { users: Promise }; return Loading users

}> {(names) =>

{names.join(', ')}

}
; } const app = createTestHandler({ routes: [{ id: 'users', path: '/', Component: Page, loader: () => ({ users: users.promise }) }] }); const response = await app.fetch('/'); users.resolve(['Ada']); expect(await response.routerState()).toMatchObject({ loaderData: { users: { users: ['Ada'] } } }); expect(await response.html()).toContain('Ada'); }); ``` In streaming mode, `fetch()` resolves when React's shell is ready. The response starts reading immediately and retains decoded chunks with millisecond offsets. Release the deferred value after `fetch()`; then `html()`, `chunks()`, `routerState()` and the other asynchronous accessors wait for completion. You can call them repeatedly or concurrently. To synchronize on particular shell bytes, use the existing `onResponse` hook and another `createDeferred()`. `routerState()` replaces deferred placeholders with their settled values, including nested promises, Dates, Maps, Sets, bigints and undefined. A rejected deferred field makes `routerState()` reject with the transported error; inspect `streamFrames()` to assert rejection frames. No response scripts execute in the Node test process. `html()` returns the raw full document, so React's original pending markers can remain alongside their replacement scripts. `pendingBoundaries()` counts those raw `` markers; expect zero for a successful buffered crawler render. For a real app, pass `App` and initialize its server props through `onRequest`, just as with your server entry: ```tsx const app = createTestHandler({ routes, App, shell: { indexFile: new URL('../src/index.html', import.meta.url).pathname }, onRequest: () => ({ appProps: makeServerProps() }), hydration: 'early', }); ``` The kit uses `loadHtmlShell` to read and validate the file once. The default document uses `
` and an inert module-script placeholder. It does not serve or execute your client assets. Reuse application Vite aliases and transforms in your Vitest configuration when your real routes need them. ## Cookies, redirects and crawlers Requests preserve status and headers and do not follow redirects. `cookies()` returns each Set-Cookie separately, including cookies with the same name but different paths: ```tsx import { redirect } from 'react-router'; import { createTestHandler, crawlerRequest } from '@lomray/vite-ssr-boost/testing'; const app = createTestHandler({ routes: [ { path: '/login', loader: () => redirect('/account', { headers: { 'Set-Cookie': 'session=demo; Path=/; HttpOnly' }, }) }, { path: '/account', Component: AccountPage, loader: accountLoader }, ], onRouterReady: ({ context: { request } }) => ({ isStream: !request.headers.get('user-agent')?.includes('Googlebot'), }), }); const login = await app.fetch('/login'); expect(login.status).toBe(302); expect(login.headers.get('Location')).toBe('/account'); expect(login.cookies()[0]).toEqual({ name: 'session', value: 'demo', attributes: { path: '/', httponly: true }, }); const crawler = await app.fetch(crawlerRequest('/account')); expect(await crawler.pendingBoundaries()).toBe(0); ``` Bot detection belongs to your application. `crawlerRequest` only adds a Googlebot user agent, and `browserRequest` adds a browser user agent. The kit passes `onRouterReady` through; `app.fetch(path, { isStream: false })` explicitly overrides its result for one request. For a controlled deferred crawler test, resolve its data while `app.fetch()` is still pending, since buffering waits for every boundary and data promise. Pass `{ signal }` or `{ timeout: 500 }` to `fetch()` (or set defaults on `createTestHandler`) to bound the whole request, including loaders and reading the body. Cancellation rejects `fetch()` before a response exists, or rejects body accessors after the shell. `timeline()` remains readable after a cancelled body. The core's separate `abortDelay` starts after router preparation and produces rejection frames and closing HTML when the connected render times out. Forward the loader's `request.signal` to its underlying I/O. ## Playwright Install the optional peer with `npm install -D @playwright/test` and configure Playwright's `webServer` to run your application. The testing entry never imports Playwright; browser helpers live in `@lomray/vite-ssr-boost/testing/playwright`. This complete spec assumes a `/deferred` route like the one in [Data streaming](/guide/data-streaming), with its resolved element marked `data-resolved`. Install observation **before** navigation so it includes errors and server DOM from before hydration: ```ts import { test, expect } from '@playwright/test'; import { collectStreamTimeline, expectHydrated, expectStreamed, } from '@lomray/vite-ssr-boost/testing/playwright'; test('hydrates a streamed route', async ({ page }) => { const timeline = await collectStreamTimeline(page); await page.goto('http://localhost:5173/deferred', { waitUntil: 'commit' }); await expectStreamed(page, '[data-resolved]'); await expectHydrated(page, { root: '#root', timeout: 10_000 }); await expect(page.locator('[data-resolved]')).toHaveCount(1); console.info(await timeline.read()); }); ``` `expectHydrated` waits for the SSR Boost browser entry's router-ready event, document completion and settled Suspense boundaries. The entry captures router state and removes `window.__staticRouterHydrationData` before hydrating. The helper checks that consumption, console/page hydration errors (including React #418/#423/#425), and whether any server text occurs more often after hydration. Select a stable root if your application intentionally adds repeated text during mount. This is a text-duplication check, not a pixel or complete DOM equality assertion. Add application-specific interaction assertions, such as clicking a counter, to prove event handlers work. `expectStreamed` requires the selected element to first become visible after the observed shell; an element already in a buffered shell fails. Observation persists across full navigations and starts fresh in each document. `timeline.read()` returns browser offsets from navigation, with `shell`, `router.ready`, frame `init`/`resolve`/`reject` events and `response.end` (the load event). These are browser observations, separate from server timings. ## Request timeline Enable `diagnostics: true` in a test and inspect `await response.timeline()`. Server hooks can read the same request's `context.timeline?.events`. `SSR_BOOST_TIMELINE=1` enables recording independently of diagnostics and prints one JSON line per completed or cancelled request in development. It remains silent in production. No timeline instance, event array or timeline clock read is created when diagnostics and the environment override are off. ```ts const app = createTestHandler({ routes, diagnostics: true, hydration: 'early' }); const response = await app.fetch('/deferred'); console.table(await response.timeline()); ``` | Stage | Meaning of `at` (milliseconds since request start) | | --- | --- | | `router.query` | React Router finished loaders/actions and matching. | | `prepare` | The preparation hook finished. | | `shell.ready` | React produced its first shell, even when output is buffered. | | `state.emitted` | State entered the HTML stream; `placement` is `early` or `footer`. | | `stream.resolve` / `stream.reject` | A deferred value settled and its frame was queued; `id` identifies the promise within this request. | | `body.end` | React's body stream ended. Unconsumed loader data can still be pending. | | `response.end` | The final transformed Fetch body finished or was cancelled. This is not a socket-delivery timestamp. | | `abort` | Cancellation, deadline or failure; `reason` explains why. | These are completion/emission offsets, not separate durations. A large `router.query` offset points to blocking loaders. A long gap after `shell.ready` with late promise settlements points to deferred work. In early mode `state.emitted` precedes deferred settlements; footer state follows the React body. Redirects and bodyless responses omit stages they never execute. An abort followed by rejection frames means pending promises were rejected before the connected response closed. ## Edge tests The `workerd`, `worker`, `edge-light` and `browser` export conditions select the Web-stream renderer; Node selects the pipeable renderer. You can also import `@lomray/vite-ssr-boost/testing/edge` explicitly in a Node Vitest suite to exercise Web streams. Pass `shell: { header, footer }` in an edge runtime, since `indexFile` uses the Node filesystem. Both entries share the same Fetch-only kit and neither requires Express. See the [API reference](/api/testing) for all options. --- Source: https://lomray-software.github.io/vite-ssr-boost/guide/upgrade-v8 # Upgrade from 7 to 8 Use this guide when upgrading an existing `@lomray/vite-ssr-boost` v7 application. If you are adding SSR to a Vite SPA for the first time, start with [Migrate an existing SPA](/guide/migrate-existing-spa). ## Node requirement The package requires Node.js `>=22.12.0`, as declared by [`package.json` engines.node](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/package.json). Check the engine requirements of your chosen React Router and build tools as well. Raising this package requirement in the future is a breaking change under the [support policy](/reference/support). ## Update the package and imports ```bash npm install @lomray/vite-ssr-boost@^8 ``` The following paths were removed in **8.0.0**. For the managed CLI, update the server entry: ```ts import entryServer from '@lomray/vite-ssr-boost/adapters/express/entry'; export default entryServer(App, routes, options); ``` Update imports relative to `@lomray/vite-ssr-boost/` (also applies to explicit `.js` imports): | Removed path | New path | | --- | --- | | `node/entry` | `adapters/express/entry` | | `node/server` | `adapters/express/server` | | `node/render` | `adapters/express/render` | | `node/create-fetch-request` | `adapters/express/create-request` | | `services/prepare-server` | `adapters/express/prepare-server` | The old `node/write-response` and `helpers/handle-response` internals are removed. The renderer now handles response composition and redirects through the Fetch core; custom Node transports can send the resulting `Response` with `node/write-fetch-response`. - The default shell-error page returns a generic HTTP 500 without exception messages. Use `onError` for diagnostics or `onShellError` for a custom page. - Request/render hook failures reach Express error middleware through `next(error)`, rather than falling through to a 404. Register error middleware after the SSR handler. - Unless explicitly overridden, rendered responses use React Router's status, including 404 for unmatched routes. Previously these could be sent as 200. - Render timeouts and client/intentional cancellation report `onError` codes `timeout` and `cancel`. The managed Express server logs these at info level. Unexpected errors retain their original error. - Parsed JSON and flat URL-encoded bodies work automatically. Nested form values and unsupported custom or multipart parser results fail explicitly; use [`getBody`](/guide/runtime-adapters#request-bodies). - Package exports support extensionless and explicit `.js` imports for the new paths, with matching TypeScript declarations. Other module paths are unchanged. The browser entry remains `@lomray/vite-ssr-boost/browser/entry`. Applications that own their transport can use the default `createHandler` export from `@lomray/vite-ssr-boost/core/handler`; follow [Runtime adapters](/guide/runtime-adapters) for server, asset and bundling responsibilities. Express and compression are optional dependencies installed by default. If you install with `--omit=optional`, install `express` and `compression` explicitly for the managed CLI. ## Review `onResponse` The hook receives `{ context, html, isEnd }` and synchronously returns a string, `undefined` or nothing. It transforms decoded HTML chunks; split UTF-8 characters are preserved, but a chunk can still end partway through an HTML tag. - Regular chunks use `isEnd: false`. Return `undefined` or nothing to keep the original chunk, a string to replace it, or `''` to withhold it while buffering an unfinished token. - After the complete composed body, including the footer, the hook receives one final call with `html: ''` and `isEnd: true`. Return a string to append buffered content; `undefined` or `''` appends nothing. - This contract applies to streamed and buffered rendering (`isStream: false`). Hooks that ignore `isEnd` remain supported. For a request-scoped `@lomray/consistent-suspense` transform: ```ts onResponse: ({ context: { appProps: { streamSuspense }, isStream }, html, isEnd }) => { if (!isStream) return; return isEnd ? streamSuspense.end() : streamSuspense.analyze(html); }, ``` See the [server entry API](/api/server-entry#onresponse) for the full signature. ## Review `context.request` The Express adapter's render-hook context now includes `request`, the Fetch `Request` used by React Router and the core. Use its `url`, `headers` and `signal` for Fetch-based request handling and cancellation. When constructing typed render-hook contexts in application code or fixtures, include this field. `context.request` is available in render hooks, after the adapter converts the request; it is not an Express request and is not added to the earlier managed `onRequest(req, res)` parameters. Fetch-core hooks use Web-standard requests and responses and do not emulate Express objects. ## Deprecated in 8.x Managed Express `context.req` and `context.res` remain available in 8.x, with removal planned for **9.0** after the [support policy](/reference/support) notice periods. Reading either field in development emits [`SSR_BOOST_DEPRECATED_REQ_RES`](/reference/diagnostics#ssr_boost_deprecated_req_res) once per process. Production has no accessors or warning overhead. Use `context.request` for request data, `context.response.headers` and `context.response.status` for response metadata before shell readiness, and React Router HTTP helpers such as `redirect()` in loaders/actions. Keep Express-specific middleware or response takeover in middleware or the earlier `onRequest(req, res)` hook; its arguments are unaffected. ## Check the upgraded application Run development and production builds, then check hydration, lazy-route JS/CSS, redirects, unmatched-route 404s, error middleware and any HTML transform. If you use parsed request bodies, streaming or cancellation, exercise those paths too. Loader results must remain JSON-serializable for first-paint hydration; nested promises are not hydrated by `` or `use()`. See the [data-loading contract](/guide/migrate-existing-spa#data-loading). --- Source: https://lomray-software.github.io/vite-ssr-boost/ --- layout: home hero: name: Vite SSR BOOST text: SSR for React Router apps in Data mode. tagline: Keep your Vite config, route objects and components. Add SSR without moving to Framework mode or rewriting the app. image: src: /logo.png alt: Vite SSR BOOST logo actions: - theme: brand text: Migrate a SPA link: /guide/migrate-existing-spa - theme: alt text: Create an app link: /guide/getting-started#create-a-new-app - theme: alt text: GitHub link: https://github.com/Lomray-Software/vite-ssr-boost features: - icon: 🧩 title: Keep your app details: Reuse React Router route objects and components. Add the SSR plugin and entries to your Vite project. - icon: 🔀 title: SSR and SPA output details: Build and serve either mode with the same route tree and browser entry. - icon: 🛠️ title: Start with the managed CLI details: Express handles development with Vite and HMR, production static assets and route-asset injection. - icon: 🔌 title: Own the transport when needed details: Connect the Fetch core through Node, Express, Fastify, Hono or edge adapters, and supply the server and asset delivery. --- ## Who this is for `@lomray/vite-ssr-boost` adds SSR to Vite apps using React Router [Data mode](https://reactrouter.com/start/modes). You keep route objects and choose how to run the server. The package does not implement React Server Components, Server Actions or file-system routing conventions. ## Start with the managed server For an existing app, add `SsrBoost()` to the Vite plugins, use `@lomray/vite-ssr-boost/browser/entry` in the browser entry and `@lomray/vite-ssr-boost/adapters/express/entry` in the server entry. Create a new app with `npm create @lomray/ssr-app@latest my-app` to start from the minimal template. The CLI handles development, HMR, SSR builds and SPA builds. Use the [Fetch core and runtime adapters](/guide/runtime-adapters) when your application needs to own the transport and its asset integration. ## Read this first - [Choosing an SSR approach](/guide/choosing) compares routing, data and server ownership. - [Migrate an existing SPA](/guide/migrate-existing-spa) shows the five-file change from the minimal template. - [Upgrade from 7 to 8](/guide/upgrade-v8) covers changed imports, hooks and the Node requirement. - [Example projects](/examples/) describes the template branches. - [FAQ](/reference/faq) answers questions about RSC, SPA output and runtimes. - [Getting Started](/guide/getting-started) covers installation and entry files. - [Rendering Modes](/guide/rendering-modes), [Routing](/guide/routing) and [Server Lifecycle](/guide/server-lifecycle) explain application behavior. - [Hydration order and streaming](/reference/hydration-and-streaming) explains when the browser can safely create its router. - [Deployment](/guide/deployment) covers build targets; [Recipes](/examples/recipes) shows integrations. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/acceptance-gates # Acceptance gates Run these before a release. The same checks run in PR and release CI. | Command | What it checks | | --- | --- | | `npm test` | Shared React SSR tests across Node, Express, Fastify, Hono and edge; real React cancellation before/after shell; HTTP/2, redirects, cookies, HEAD/statuses, bounded buffering and compressed shell delivery | | `npm run lint:check` / `npm run ts:check` | Lint and public/internal TypeScript contracts | | `npm run build` | Published JavaScript and declaration output | | `npm run test:size` | Browser public-entry and combined gzip budgets; rejects server dependencies in browser bundles (requires `lib/`) | | `npm run test:edge:packed` | Built edge output running in Miniflare/workerd | | `npm run test:worker:packed` | Packed Worker template, Cloudflare types and static/lazy route assets | | `npm run test:bun` | Built Fetch SSR served by Bun, including cookies, HEAD, redirects and POST (requires Bun) | | `npm run test:core:no-optional` | Packed installation with `--omit=optional`; core and edge run without Express or compression | | `npm run test:template` | Template SSR in dev/production, cold styles, SSR module reload, streamed and crawler HTML, gzip, static JS/CSS, redirects, HEAD, 404, subpath deployment, standalone SPA, client gzip budget, production cold-start/RSS budgets and baseline TTFB comparison | | `npm run docs:build` | Documentation build and links | CI pins `vite-template` to `d15557c1d03a16a97521d38432a22cd8fe2b95c3`. Locally, install its dependencies in `../vite-template`, or pass its path to `node scripts/test-template.mjs`. The script works on a copy; route typing, HMR edits and subpath configuration changes stay in that copy. In development, production and production under a basename, `/deferred` must send its title and promise placeholder in the first HTML chunk's `__ssrBoostStream` init frame, then a resolve frame and the three rendered users in later chunks. A separate Googlebot request must contain the resolved lists with zero pending Suspense markers (``). The summary records the delay from first HTML to the resolve frame; the template's loader waits 1.5 seconds. Before shipping, also check the template in a browser: hydration and console errors, client navigation, Suspense, crawler mode, HMR and SPA deep links. HTTP acceptance checks do not replace browser checks. Use `SSR_BOOST_KEEP_TEMPLATE=1 npm run test:template` to retain the test copy for inspection. With Chromium installed (`npx playwright install chromium`), run `SSR_BOOST_TEMPLATE_BROWSER=1 npm run test:template` to also test the pinned template's deferred page in Chromium in all three SSR modes. This checks shell interactivity, both Await/use() lists, init/resolve frames and hydration/console errors. Browser failures fail this opt-in gate. PR and release CI run the full suite on React/React DOM 18.2.0 and 19.2.8. The release waits for both versions. Template TTFB comparisons are advisory, including the streamed/home ratio. The ratio compares 11 alternating pairs after three warm-up pairs, using the first decoded HTML chunk with the same browser user agent and identity encoding. A ratio above 1.5 is printed without failing acceptance; early-stream checks retry up to three times to tolerate shared-runner scheduling. Crawler rendering is checked through Suspense completion markers instead of comparing timings between separate requests. ## Production startup and memory budgets Both pinned and `SSR_BOOST_TEMPLATE_CURRENT=1` acceptance enforce these limits in the same run: | Metric | Failure threshold | | --- | --- | | Production cold start | At most **2 ×** the plain baseline median measured in the same run | | Production server RSS after TTFB | At most **1.6 ×** the plain baseline RSS measured in the same run | | Production retained heap growth after 10,000 additional requests | At most the plain baseline's retained-heap delta **+ 8 MiB**, both measured after forced GC | The plain baseline is an ESM Express server rendering one React element with `react-dom/server`'s `renderToPipeableStream`, using the template's installed Express/React versions. Both servers run with `NODE_ENV=production` and the same memory preload. Cold-start samples use npm; cold start is npm process spawn to the first **complete HTTP 200 on `/`**, polling every 25 ms. Five fresh processes per implementation are interleaved; the median is compared without rounding. Filesystem caches stay warm, matching the public benchmark's process-cold methodology. RSS comes from `process.memoryUsage()` in the listening Node process, immediately after the existing TTFB run (two warm-up requests and seven samples). The candidate also exercises the existing production HTTP checks before TTFB. The runner records the listening PID and requests a sample by signal; it does not measure npm's RSS or add an application endpoint, and it records RSS before the forced collections used for the retained-heap rows. After each server's TTFB sample, the runner completes exactly 10,000 additional `/` requests with the TTFB run's browser user agent, identity encoding and ten HTTP/1.1 keep-alive connections, consumes every body, then samples memory again in the same process. Every response must be HTTP 200. Both servers run with `--expose-gc`; each memory sample records resident memory as-is and then the heap in use after two forced collections. The budget compares the retained-heap deltas (after load minus after TTFB) because resident memory also grows with GC timing and allocator high-water marks: a plain Express server gains tens of MiB of RSS over 10,000 requests while its retained heap stays flat. The RSS rows after load stay in the summary as advisory values; **1 MiB = 1,048,576 bytes**. Use heap snapshots to attribute a retained-heap regression. The step summary prints both candidate/baseline cold-start medians and RSS values alongside the existing advisory **Production server ready** line, which retains the direct CLI spawn and its 100 ms readiness polling. Readiness and TTFB remain advisory; the new cold-start and RSS comparisons fail acceptance. A separate untimed production probe rejects tooling imports and makes Vite config evaluation fail, while checking the root and a lazy route. The missing-build error is also checked. See [deployment](/guide/deployment#production-startup) for the serving import path and profiling commands. ## Size budgets PR and release CI run `npm run test:size` immediately after the library build. The script expands the JavaScript targets in `package.json`'s exports map against `lib/` and deduplicates extensionless and `.js` aliases. It excludes server/core/edge/Node runtimes, adapters, CLI, plugins, build services, their server/tooling helper and constant paths, and declaration-only stubs. The precise exclusions are documented in `scripts/test-browser-size.mjs`; shared browser modules such as `context/server` and `helpers/get-server-state` remain covered. New browser entries fail until they have a budget. Each entry is bundled and minified with esbuild as browser ESM. React, React DOM (including `react-dom/client` and `react/jsx-runtime`) and React Router stay external. All other dependencies, including the client HOCs' `hoist-non-react-statics`, count toward size. The combined bundle imports and re-exports every entry's namespace to retain all public APIs while sharing dependencies. Any import of `node:`, `express`, `compression`, `isbot`, `chalk`, `commander` or `json5` (including package subpaths) fails the gate, even when the size is within budget. All sizes below use gzip level 9 and **KB = 1024 bytes**. Comparisons use unrounded byte counts. | Browser entry | Measured gzip KB | Budget KB | | --- | ---: | ---: | | `browser/entry` | 3.125 | 3.669 | | `browser/stream` | 2.469 | 3.25 | | `components/navigate` | 0.334 | 0.50 | | `components/only-client` | 0.324 | 0.50 | | `components/render-client` | 1.705 | 2.25 | | `components/response-status` | 0.172 | 0.25 | | `components/scroll-to-top` | 0.203 | 0.50 | | `components/with-suspense` | 1.628 | 2.25 | | `constants/common` | 0.094 | 0.25 | | `context/server` | 0.175 | 0.25 | | `helpers/get-server-state` | 0.101 | 0.25 | | `helpers/import-route` | 1.937 | 2.50 | | `interfaces/fc-route` | 0.091 | 0.25 | | Combined | 5.727 (5864 bytes) | 6.657 (6817 bytes) | | Template client total | 141.839 | 154 | The template gate sums the gzip size of each `build/client/assets/*.js` file after the candidate's production SSR build, including lazy chunks and framework/application dependencies. It excludes CSS, images, source maps and server output. This is the full pinned acceptance template, so its total is larger than the minimal example's bundle. Both pinned and current dependency acceptance runs use the same 154 KB limit. The browser table is printed and appended to `GITHUB_STEP_SUMMARY` when set. Template acceptance also prints and appends its client size/budget, production cold-start/RSS budgets, server readiness (process start through the first successful HTTP response, including readiness polling), baseline/candidate median TTFB, and development/production/subpath chunk counts and deferred settle delays. Decoded gzip chunks count HTML payload rather than gzip headers. Available measurements are reported even if a later acceptance check fails; unreached timings are marked `not measured`. Readiness and TTFB remain advisory. For an intentional increase: 1. Run `npm run build`, `npm run test:size` and template acceptance with the pinned template and current dependencies. Review the added code/dependencies and record the measured before/after sizes and the reason in the PR. 2. Update the table at the top of `scripts/test-browser-size.mjs`: each entry's budget is `Math.ceil(measuredGzipKB * 1.25 * 4) / 4` (25% headroom, rounded up to 0.25 KB). The combined budget is the measured combined gzip size plus exactly 1 KB; retain byte precision. 3. Set `TEMPLATE_CLIENT_GZIP_BUDGET_KB` near the top of `scripts/test-template.mjs` to `Math.ceil(measuredGzipKB * 1.05)` using the pinned template (5% headroom, rounded up to a whole KB). Confirm the current-dependency run also fits; investigate any difference before raising it. 4. Update this table, rerun the gates, and temporarily lower a budget to prove that CI would fail. Restore the reviewed budget before committing. Never update limits automatically on failure. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/benchmarks # Benchmarks [Lomray-Software/ssr-benchmarks](https://github.com/Lomray-Software/ssr-benchmarks) runs the same three-route React application on vite-ssr-boost, React Router Framework mode, Vike, TanStack Start and Next.js. The routes cover static content with a counter, a list with delayed data, and a detail page with an immediate field and a deferred field. Shared content, styling and deterministic data keep the workload comparable; each implementation uses its framework's routing and rendering conventions. See the [repository README for current tables and methodology](https://github.com/Lomray-Software/ssr-benchmarks#results). The measurements describe that workload, not a general framework ranking. Lomray maintains both the benchmark and vite-ssr-boost. ## What is measured - **Cold start:** fresh production process startup through its first completed successful response, including the npm launcher and first request. This does not measure serverless provisioning or an empty OS cache. - **RSS:** resident memory in the serving Node process after HTTP load, before browser measurements; not peak memory or the Chromium process. - **Emitted JavaScript:** all production JS and the client-output subset, excluding source maps and build caches. Emitted server code is not browser download cost. - **Fetched JavaScript:** actual external JavaScript fetched by a fresh browser for each route, plus inline executable scripts, reported with normalized gzip estimates. This includes hydration/bootstrap payloads. - **TTFB percentiles:** p50, p95 and p99 for each route under the documented warmup and request load. Raw results also contain full-response timings and response bytes. - **Browser milestones:** time to a counter that responds to a click and time until the deferred field is visible. These are application-specific observations, not a general Web Vitals score. Raw results include samples, settings, source/lockfile hashes, runtime versions and machine details. Compare complete runs on the same machine and mode; quick runs only check the harness. ## Runtimes The repository's [Runtimes section](https://github.com/Lomray-Software/ssr-benchmarks#runtimes) serves the same built boost application through seven servers: the managed Express server (`ssr-boost start`), the Fetch core on `node:http`, Fastify, Hono on Node, Hono on Bun, Elysia on Bun and `Bun.serve`. Every variant shares one Fetch handler and one static-file policy, and a parity check proves the HTML, static files, cache validators and status codes are identical before anything is measured. Per variant, and separately for identity and gzip responses, it records TTFB and full-response percentiles at 10 connections, requests per second with the p99 at 50 and 100 connections, cold start, RSS after the load and after 10,000 further requests (a leak indicator), and CPU per completed request sampled inside the server process. The methodology names which variants compress and how; workerd and Deno are not in the table because they need a different bundle and accounting environment. Use it to choose a transport for an existing boost application: the managed server is the convenient default, the Fetch adapters remove its overhead, and the Bun variants trade the managed CLI for the highest throughput and lowest memory in that workload. ## Regenerate or reproduce The repository's [weekly GitHub Actions workflow](https://github.com/Lomray-Software/ssr-benchmarks/blob/prod/.github/workflows/bench.yml) runs the pinned applications, uploads results, and commits successful full `results/` output and regenerated README tables. It also supports manual dispatch. Failed or incomplete runs are not published as successful results; dependency updates require an app/lockfile change. Clone the benchmark repository and, from its root, run: ```sh npm ci && npm run bench ``` Follow the [README prerequisites](https://github.com/Lomray-Software/ssr-benchmarks#reproduce) for Node, Chromium and Linux browser libraries. The runner builds and measures each app separately. Use its documented output option to keep an independent run without replacing the default results. ## Challenge a number Open a [pull request in ssr-benchmarks](https://github.com/Lomray-Software/ssr-benchmarks/pulls) with the app diff that reproduces or corrects the concern. Preserve the [shared application contract](https://github.com/Lomray-Software/ssr-benchmarks/blob/prod/SPEC.md), include the exact command, raw JSON and environment, and provide before/after results from the same machine. Disclose changed defaults. Framework maintainers can propose a more idiomatic implementation through the same process. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/diagnostics # Development diagnostics Run [`ssr-boost doctor`](/api/cli#ssr-boost-doctor) to check project setup before investigating a runtime warning. Use `doctor --json` in automation and `doctor --bundle support.json` to collect versions, route structure, checks and recorded build codes without request data. Diagnostics catch state serialization and response-hook mistakes before they reach the browser. Each distinct warning is logged once per process through the existing logger, with a stable code, the affected route/key or file, and a link to its section below; managed development uses your configured `loggerDev`. Reloading a development module does not reset this deduplication. Checks are on for `ssr-boost dev` and off for `ssr-boost start` and managed serverless deployments. Fetch [`createHandler`](/guide/runtime-adapters#create-a-fetch-handler) accepts `diagnostics?: boolean`, defaulting to `process.env.NODE_ENV !== 'production'` (on if `process` is unavailable). `SSR_BOOST_DIAGNOSTICS=0` disables the checks and `SSR_BOOST_DIAGNOSTICS=1` enables them, overriding both the option and managed CLI mode wherever environment variables are available. When enabled, diagnostics walk state before serialization and retain a copy of emitted HTML until the response finishes, without delaying streamed chunks. When disabled, they neither walk state nor accumulate HTML. Completed-output checks skip cancelled or failed streams, redirects, and bodyless responses; invalid file-backed shells always throw, even with diagnostics disabled. ## SSR_BOOST_CACHE_PRIVATE_LEAK {#ssr_boost_cache_private_leak} A final response is explicitly public while carrying Set-Cookie or while the request contains the configured `sessionCookie`. Use `documentHeaders` with its default credential protection and bypass shared cache reads and writes for that cookie. The warning is deduplicated and never prints cookie values. Like other development checks, it is disabled in production unless diagnostics are explicitly enabled. See [Document headers and caching](/guide/caching) for the helpers and tested recipes. ## SSR_BOOST_DEPRECATED_REQ_RES {#ssr_boost_deprecated_req_res} A managed Express hook or loader reads `context.req` or `context.res`. These fields still return the live Express objects, but are deprecated in 8.x with removal planned for **9.0**, subject to the [support policy](/reference/support) notice periods. One warning covers both fields across all requests, hooks and development module reloads in the process. Merely creating the context or reading `context.request` does not warn. Use `context.request` for the Fetch request's URL, headers, method and signal. Before the shell is sent, set `context.response.headers` and `context.response.status` for response metadata; use React Router's HTTP helpers such as `redirect()` in loaders/actions. The earlier managed `onRequest(req, res)` arguments and Express middleware are not deprecated by this diagnostic. Accessors are installed only in managed development with diagnostics enabled, using `loggerDev`. `SSR_BOOST_DIAGNOSTICS=0` disables them. Production keeps plain properties with no accessor or warning overhead, even when other diagnostics are enabled with `SSR_BOOST_DIAGNOSTICS=1`. ## SSR_BOOST_SSR_POLICY {#ssr_boost_ssr_policy} An info message explains whether a URL pattern selected `ssr` or `spa`, and whether the decision came from the configured include/exclude policy, `decide`, `SSR_BOOST_SSR_ROUTES`, or `bots: 'ssr'`. It appears for active [incremental SSR policies](/guide/incremental-ssr), once per pattern and distinct decision per process, using the development logger and diagnostics settings above. Plain default `all` mode stays silent; an `all` policy with `decide` is active. The message identifies patterns rather than concrete dynamic parameters, cookies or query values. Check it when a URL uses an unexpected mode. The environment override replaces the configured route policy and `decide`; bot protection has highest priority. This is informational and does not count as a warning. SPA shells intentionally have no hydration state and do not trigger `SSR_BOOST_HYDRATION_STATE_MISSING`. ## SSR_BOOST_SSR_POLICY_UNMATCHED {#ssr_boost_ssr_policy_unmatched} An include/exclude pattern has no match among known route ids' declared URL paths. The warning checks nested paths and router basenames without importing lazy route modules or running loaders. A global 404 catch-all does not hide typos. Each distinct unmatched pattern warns once per process under the diagnostics settings above. Check spelling, include the URL basename, and use path patterns rather than component filenames or generated numeric route ids. The check is advisory and compares declared paths, so a RegExp restricted to particular dynamic parameter values may need manual verification. Invalid path-to-regexp string syntax instead throws during entry/handler creation, even with diagnostics disabled. ## SSR_BOOST_TIMELINE {#ssr_boost_timeline} Set `SSR_BOOST_TIMELINE=1` to record request stages even when diagnostics are disabled and print one JSON line per completed or cancelled request in development. The line includes the method, pathname and millisecond offsets for routing, preparation, shell readiness, state emission, deferred settlements (with promise ids), body/response completion and abort reasons. Recording is also enabled by diagnostics; logging requires the timeline environment flag and stays off in production. Hooks can inspect `context.timeline?.events`, and the testing kit exposes `await response.timeline()`. When diagnostics are off and the flag is unset, no timeline instance or event array is allocated. See [Request timeline](/guide/testing#request-timeline) for stage definitions; `response.end` measures consumption of the Fetch body, not delivery over a socket. ## SSR_BOOST_LOADER_NOT_SERIALIZABLE {#ssr_boost_loader_not_serializable} ### When it appears A loader/action value or streamed resolution contains a function, symbol, unsupported class instance (including WeakMap/WeakSet), or circular reference. The warning identifies its route and key path. Promises, Dates, Maps, Sets, BigInts, RegExps and undefined are supported and do not trigger this warning. ### Why it matters The [router data codec](/guide/data-streaming#supported-values) restores supported types. Unsupported values become `undefined`; custom class behavior is not transferred. Circular values are preserved within a frame but still warn so route data remains easy to inspect and reuse. ### How to fix Return explicit data fields instead of functions or class instances, and remove cycles from your route data. Leave supported slow fields as promises and render them with Suspense and `` or React 19 `use()`. ## SSR_BOOST_STREAM_PROMISE_ABORTED {#ssr_boost_stream_promise_aborted} A render timed out or was cancelled with loader/action promises still pending. The diagnostic names the route and pending count. Connected browsers receive rejection scripts before the response closes; cancelled response consumers discard queued frames. Increase `abortDelay` when the work legitimately needs longer, handle rejections with an error boundary, and pass the loader's `request.signal` to outbound fetches. The deadline starts after routing/preparation and also covers promises the React tree never reads. This diagnostic follows the development/explicit diagnostics settings above. ## SSR_BOOST_STATE_NOT_SERIALIZABLE {#ssr_boost_state_not_serializable} ### When it appears The object returned by `getState` contains a promise, function, symbol, bigint, Date, collection, class instance or cycle, including nested values inside arrays and plain objects. The warning identifies the request route and a path such as `$.store.items[0].createdAt`, even when the state builder would otherwise omit the value. ### Why it matters Custom state is serialized into browser scripts using JSON. Its value rules differ from router data. A server store containing class instances or collections can therefore hydrate with missing fields or changed types. ### How to fix Return a plain snapshot of your store from `getState`, with awaited values and explicit fields. Convert Dates to ISO strings and collections to plain data, then rebuild the client store from that snapshot rather than returning the live store instance. ## SSR_BOOST_OUTLET_MISSING {#ssr_boost_outlet_missing} ### When it appears The managed server or `loadHtmlShell` finds zero or multiple `` markers (or an invalid custom outlet), and throws an Error naming the HTML file in every mode. For Fetch handlers, the pre-split `{ header, footer }` shell represents one insertion boundary; diagnostics warn if either half is missing or a raw outlet remains in either half. ### Why it matters The outlet determines where React content goes and where the hydration footer begins. An absent or duplicate boundary can put content outside the document or discard the footer entirely. ### How to fix Place exactly one `` inside the application's root element in `index.html`, and make sure HTML transforms preserve it. For file-backed Fetch shells use `loadHtmlShell({ indexFile })` to validate the source before splitting it; manually supplied halves must both be strings, which may be empty, and must not retain the marker. ## SSR_BOOST_HYDRATION_STATE_MISSING {#ssr_boost_hydration_state_missing} ### When it appears A completed development response has no script assigning `window.__staticRouterHydrationData`. This commonly follows an `onResponse` hook that withholds the footer or removes its hydration script. ### Why it matters The browser entry needs router hydration data to start with the same loader and action state as the server. Removing that script prevents the page from hydrating correctly even if its HTML looks complete. ### How to fix Preserve the generated hydration script when transforming footer chunks. If your hook retains chunks by returning `''`, return the remaining content on the final `isEnd: true` call, including the hydration state and document footer. ## SSR_BOOST_DUPLICATE_OUTPUT {#ssr_boost_duplicate_output} ### When it appears A completed development response repeats an `` or `` opening tag, a router hydration script, a React segment/boundary `id="S:…"` or `id="B:…"`, or a `$RC("B:…", …)` call for the same id. The warning names the repeated marker; one boundary id and its matching `$RC` call are expected and are counted separately. ### Why it matters Repeated document or hydration output can corrupt the page, and repeated React streaming markers can apply Suspense content more than once. A hook that retains a chunk but returns `undefined` accidentally emits it now and may emit it again at the end. ### How to fix `onResponse` must return `undefined` to keep a chunk and `''` to withhold one, then return retained content exactly once on the final call. Check head-manager integration too: insert head contents into the existing `` rather than adding a second opening tag. ## SSR_BOOST_ONRESPONSE_INVALID_RETURN {#ssr_boost_onresponse_invalid_return} ### When it appears `onResponse` returns something other than a string or `undefined`, including on its final call. The warning names the route and actual return type, such as Promise, Object, number, or null. ### Why it matters The hook is synchronous and its return value directly controls the outgoing chunk. An async hook returns a Promise that is not awaited, so the response can contain `[object Promise]` instead of HTML. ### How to fix Make `onResponse` synchronous and return a string to replace a chunk, `undefined` to keep it, or `''` to withhold it. Do asynchronous preparation in an earlier async lifecycle hook and let the final `isEnd: true` call return only any remaining HTML string. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/faq # FAQ ## How do I add SSR or check an existing setup? Start with `npx ssr-boost init --dry-run`, review the diff, then use `--apply` and install dependencies. Follow the [automatic migration guide](/guide/migrate-existing-spa#automatic-npx-ssr-boost-init) for invocation before the library is installed and for unsupported layouts. Run [`ssr-boost doctor`](/api/cli#ssr-boost-doctor) after changes; `--json` is suitable for scripts, and `--bundle support.json` collects structural support information without request or environment data. ## Do I need React Server Components? No. vite-ssr-boost renders your React component tree through its SSR renderer and hydrates it in the browser. It does not implement React Server Components or Server Actions; use the [migration guide](/guide/migrate-existing-spa) to add SSR to route objects. ## Is SSR without RSC obsolete? No. React documents [streaming HTML with `renderToPipeableStream` and Suspense](https://react.dev/reference/react-dom/server/renderToPipeableStream) as a server rendering API. vite-ssr-boost uses that rendering model and browser hydration; RSC is not a prerequisite for it. ## Can I switch back to SPA? Yes: run `npm run build:spa`, then `npm run start:spa` with the [example scripts](/guide/migrate-existing-spa#going-back-to-spa). Both commands use `--focus-only client`, keeping the same route objects and browser entry. Rebuild with `npm run build` before starting SSR again. ## Why not React Router Framework mode? Choose [Framework mode](https://reactrouter.com/start/modes) when you want its Vite plugin, route module API and rendering configuration. Its [adoption guide](https://reactrouter.com/upgrading/router-provider) converts route definitions to route modules and adds a root entry. vite-ssr-boost adds SSR while keeping your Data-mode route objects and HTML entry. ## Why not Next.js? Choose [Next.js App Router](https://nextjs.org/docs/app) when you want its file-system routing, Server Components and Server Functions. Its [Vite migration guide](https://nextjs.org/docs/app/guides/migrating/from-vite) starts with SPA behavior and describes moving from React Router to App Router for streaming. vite-ssr-boost keeps the Vite build and React Router route objects. ## Does it work on Bun, Deno, Cloudflare? Yes, through the Fetch core, `@lomray/vite-ssr-boost/edge/render-to-stream` and `@lomray/vite-ssr-boost/adapters/edge`. Use the [runtime adapter integration](/guide/runtime-adapters#cloudflare-workers) with `Bun.serve`, `Deno.serve` or a Cloudflare Worker Fetch entry, and provide bundling, static assets and route-asset injection. Cloudflare bundles need the `workerd` and `worker` resolution conditions. The managed CLI and its Vercel/serverless output use Node and Express; they do not produce a Worker bundle. ## Can `` and React 19 `use()` hydrate loader promises? Yes. Loader and action promises stream by default, including nested promises. The browser reconstructs them before creating the Data router. Enable `hydration: 'early'` to hydrate the shell while slow boundaries are pending; custom state must be ready at `onShellReady`. The default retains footer hydration. [Stream loader data](/guide/data-streaming) covers examples, errors and buffered crawler responses. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/hydration-and-streaming # Hydration order and streaming Hydration connects React to the HTML already rendered by the server. The browser needs the matching route code and serialized state before it creates the router and hydrates the root. ## Why the client can arrive first In a production build, Vite emits the client entry as a module script in ``. The [minimal example's HTML](/guide/migrate-existing-spa#src-index-html) marks that script `async`, so it can execute while the browser is still parsing the response. A module script without `async` normally waits for parsing to finish. With a warm HTTP cache, the async module and its imports may be ready before the server finishes streaming the HTML. The footer contains the custom state scripts and the `window.__staticRouterHydrationData` script, so both can still be missing when the client entry runs. Starting hydration at that point can produce this sequence: 1. React Router creates the browser router without hydration data. Routes whose loaders need to run can enter the initial fallback path, producing a `HydrateFallback` warning when no fallback is provided. 2. The browser's initial tree differs from the server HTML. React reports a hydration mismatch ([production error #418](https://react.dev/errors/418)) and regenerates the tree on the client. 3. The remaining server HTML arrives after React has taken over the root. The browser can append that late HTML alongside the client-rendered content, making the content appear twice. These symptoms describe the timing failure; a hydration mismatch can also have other causes. ## The wait added in 8.0.0 [`src/browser/entry.tsx`](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/src/browser/entry.tsx) calls `waitForDocument()` before creating the router: - It returns immediately when `document.readyState !== 'loading'`, or, in SSR mode, when `window.__staticRouterHydrationData` is already defined. - Otherwise it waits for `DOMContentLoaded`. In SSR mode it also installs a temporary accessor on `window.__staticRouterHydrationData`. - The first assignment to that accessor replaces it with a configurable, enumerable, writable plain data property holding the assigned value. It removes the `DOMContentLoaded` listener and resolves the wait. - If `DOMContentLoaded` arrives first, the listener removes the temporary accessor in SSR mode and resolves the wait. Document readiness does not verify that a custom server actually sent state. The entry starts preloading the matched lazy routes while it waits. It awaits document readiness and those preloads together with `Promise.all`, and copies each lazy route result onto the route object before calling `createRouter`. After that, it finds the root, runs the optional `init` callback and hydrates in SSR mode. SPA mode and roots marked `data-force-spa="1"` use `createRoot` instead. ## The footer order [`src/core/render.tsx`](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/src/core/render.tsx) builds the footer in this order: 1. Custom state from `getState`. 2. Router state assigned to `window.__staticRouterHydrationData`. 3. The `onShellReady` footer override, or the template footer. The composed response sends the header, the React body stream and then this footer. Assigning router state releases the browser entry's wait, so the custom state must already be available at that point. The entry's `init` callback can then read that state. ## Streamed loader and action data The [data stream](/guide/data-streaming) publishes stable promise placeholders in router initialization. Settlement scripts can arrive before initialization or the async entry: `window.__ssrBoostStream` buffers frames until its receiver is installed. The entry decodes the initial state and reconstructs native promises before `createRouter`. In default footer mode, custom state comes first in the footer, followed by the router hydration assignment and encoded initialization. Earlier settlement frames do not release the document wait. The assignment is an internal stream marker until the entry replaces it with decoded state. With `hydration: 'early'`, custom state and router state are emitted in the shell block before React body bytes. `getState` must already have everything hydration needs at `onShellReady`. The entry waits for React's bootstrap script at the end of the parsed shell before hydrating; it then allows slow boundaries to finish independently. This works across split shell chunks and requires an async client module. `isStream: false` still waits for complete React HTML and resolved data and uses the footer. ## Custom servers and `onResponse` Keep the generated state and settlement scripts in the same HTML response and complete it. Default mode keeps hydration state after the React body; early mode sends it with the shell. Do not send state in a separate request or withhold the final chunk. Replacing the footer through `onShellReady` preserves the generated state scripts and their order. [`src/core/transform-html.ts`](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/src/core/transform-html.ts) applies `onResponse` to decoded HTML chunks: | Return value | For a chunk with `isEnd: false` | | ------------------------ | ----------------------------------------------------------------------- | | `undefined` or no return | Keep the original chunk. | | A nonempty string | Replace the chunk with that string. | | `''` | Withhold the chunk. A transform retaining content must return it later. | After the body and footer have passed through the transform, it calls the hook once more with `html: ''` and `isEnd: true`. Return any retained content at that point; `undefined` or `''` appends nothing. If retained content includes state or closing HTML, discarding it can break hydration even with the browser wait. See [Server Lifecycle](/guide/server-lifecycle#onresponse) for the hook contract and an incremental transform example. ## Reproduce the timing locally Use a production build of an application, such as the [prod template](https://github.com/Lomray-Software/vite-template/tree/prod), behind a local proxy. Development mode changes script handling and does not reproduce the same timing. 1. Run `npm run build` and `npm run start:ssr` in the template. Use its listening address as the proxy's `UPSTREAM` below. 2. Choose a route with loader data and a streamed React body that stays open for more than a few hundred milliseconds, for example a component-level Suspense request. A slow loader alone delays the shell, so it does not create this interval. Keep streaming enabled for the browser request. 3. Save this proxy as `/tmp/ssr-timing-proxy.mjs`. It delays JavaScript module responses by 300 ms, leaves the HTML streaming and preserves the upstream cache headers. ```js import http from 'node:http'; const upstream = new URL(process.env.UPSTREAM || 'http://127.0.0.1:3000'); const moduleDelay = Number(process.env.MODULE_DELAY_MS || 300); http .createServer((request, response) => { const target = new URL(request.url, upstream); const upstreamRequest = http.request( target, { method: request.method, headers: { ...request.headers, host: upstream.host }, }, (upstreamResponse) => { const forward = () => { if (response.destroyed) return; response.writeHead(upstreamResponse.statusCode, upstreamResponse.headers); upstreamResponse.pipe(response); }; if (/\.m?js$/.test(target.pathname)) { setTimeout(forward, moduleDelay); } else { forward(); } }, ); upstreamRequest.on('error', () => { if (!response.headersSent) response.writeHead(502); response.end('Production server unavailable'); }); response.on('close', () => upstreamRequest.destroy()); request.pipe(upstreamRequest); }) .listen(4174, '127.0.0.1'); ``` ```bash UPSTREAM=http://127.0.0.1:3000 MODULE_DELAY_MS=300 node /tmp/ssr-timing-proxy.mjs ``` Open `http://127.0.0.1:4174` and navigate to the streamed route. Leave the browser's HTTP cache enabled, load once, then reload normally. Cached modules can bypass the proxy's delay on the second load. Adjust the module delay or the component's response time until the entry runs while `document.readyState` is `loading` and `window.__staticRouterHydrationData` is `undefined`. Delaying JavaScript alone is not enough if the whole HTML response has already arrived. Use browser breakpoints at the start of the entry and at `createRouter` to inspect that order. With the 8.0.0 wait in place, router creation waits for the state assignment or `DOMContentLoaded`, and for matched lazy routes. The missing-state warning, mismatch and duplicate content describe an entry without that wait; the current entry should wait through this interval. Stop the proxy with Ctrl+C when finished. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/support # Support and versions Policy effective **5 September 2026**. ## Supported versions | Release line | Maintenance commitment | Through (inclusive) | | --- | --- | --- | | Latest 8.x minor | Bug fixes and security fixes | 5 September 2027 | | 7.1.x | Security backports | 5 March 2027 | When a new 8.x minor ships, upgrade to that minor to continue receiving bug and security fixes. The 7.1.x line receives security backports during its stated window. ## Upstream compatibility For every new stable major of React, React Router or Vite, we will publish a **supported**, **investigating** or **unsupported** statement within **30 days** of its release. We will publish that statement in GitHub Discussions or Releases and link to the tested combinations where applicable. A permissive peer dependency range alone is not a support statement. ## Response commitments - We will provide a human acknowledgment of a GitHub issue within **five business days**. - We will provide a human acknowledgment of a security report within **two business days**. Follow the [security reporting policy](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/SECURITY.md) to report privately. These are acknowledgment targets; the time needed to investigate and resolve a report depends on its scope. Business days are Monday through Friday, excluding public holidays observed by the responding maintainer. Issues explicitly labeled `needs-reproduction` receive an inactivity reminder after 30 days and may be closed 14 days later. Issues labeled `bug`, `confirmed` or `enhancement`, and all pull requests, are exempt. Add the requested reproduction and reopen the issue, or ask a maintainer to reopen it in a comment. ## Deprecations and breaking changes We will announce deprecations at least **90 days** and **two minor releases** before removal, and remove deprecated APIs only in a **major release**. Both notice periods must be satisfied. Raising the package's Node.js requirement counts as a breaking change and requires a major release. ## Maintenance updates We will publish a short maintenance note **each month**, beginning in **September 2026**, in [GitHub Discussions](https://github.com/Lomray-Software/vite-ssr-boost/discussions) or [Releases](https://github.com/Lomray-Software/vite-ssr-boost/releases). Each note will cover maintenance activity and compatibility status, including months with no release. See the [repository support policy](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/SUPPORT.md), [tested compatibility combinations](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/README.md#compatibility) and [Upgrade from 7 to 8](/guide/upgrade-v8). --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/talking-points # Talking Points ## Short version `@lomray/vite-ssr-boost` adds SSR to React Router apps in Data mode, without moving to Framework mode and without rewriting the app. Keep your Vite configuration, route objects and components, and build SSR or SPA output from the same application. ## Routing model Data mode, not Framework mode. React Router's [mode guide](https://reactrouter.com/start/modes) describes the distinction; vite-ssr-boost uses `createStaticHandler` and `StaticRouterProvider` on the server and route objects in the browser. ## What you add - `SsrBoost()` in the Vite plugin list. - A browser entry for hydration or SPA mounting. - A server entry at `@lomray/vite-ssr-boost/adapters/express/entry`. - An HTML outlet and CLI scripts, shown in the [migration guide](/guide/migrate-existing-spa). ## Choose your server The managed Express CLI is the default path for Vite development, HMR, static assets and route-asset injection. Use `createHandler` from `@lomray/vite-ssr-boost/core/handler` with the Node, Express, Fastify, Hono or edge adapter when you want to own the transport. That path also requires your development server, bundling and asset delivery; see [Runtime adapters](/guide/runtime-adapters). ## Data loading Loader and action promises stream by default in Data mode. Return `{ fast, slow: fetchSlow() }` and consume `slow` inside Suspense with `` or React 19 `use()`. The browser reconstructs native promises before creating the router; client navigation loaders keep their native promises. See [Stream loader data](/guide/data-streaming) for the supported value matrix, errors and opt-in `hydration: 'early'`. Custom `getState` snapshots still use JSON. The browser entry waits for document readiness or the router state assignment, together with matched lazy route preloads, before creating the router. Custom state is written before router state in the footer by default. `hydration: 'early'` publishes both at shell-ready and waits for React's parsed-shell marker; see [Hydration order and streaming](/reference/hydration-and-streaming) for the timing and the `onResponse` contract. ## Who this is for - Teams adding SSR to a Vite app with React Router route objects. - Teams that need SSR and SPA output from one app. - Teams that own request handling and deployment decisions. ## When to choose another approach The package does not implement RSC, Server Actions or file-system routing conventions. SSR still requires server ownership, whether you use the CLI or a Fetch transport. Use the [comparison guide](/guide/choosing) to evaluate those requirements and the [example projects](/examples/) to inspect application wiring. --- Source: https://lomray-software.github.io/vite-ssr-boost/reference/useful-links # Useful Links - [GitHub repository](https://github.com/Lomray-Software/vite-ssr-boost) - [NPM package](https://www.npmjs.com/package/@lomray/vite-ssr-boost) - [Vite](https://vite.dev/) - [React Router](https://reactrouter.com/) - [Capacitor](https://capacitorjs.com/) - [Lomray Vite template](https://github.com/Lomray-Software/vite-template) - [Issue tracker](https://github.com/Lomray-Software/vite-ssr-boost/issues) --- Source: https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/SKILL.md --- name: ssr-boost-migrate description: Migrate an existing Vite and React Router SPA to vite-ssr-boost SSR in Data mode, preserving route objects and checking streaming, hydration, bundle size, and deployment readiness. --- # Migrate a Vite SPA Use this procedure in the application repository. Read its instructions and preserve its package manager, providers, routes and unrelated changes. The API examples target vite-ssr-boost 8.x; read the installed version before changing imports. Documentation links point to the upstream repository so they work when this skill is copied on its own. The testing kit, incremental SSR policy and Worker helper require a release containing those APIs; the stable 8.3.0 template dependency predates them. For a prerelease evaluation, the published `8.4.0-beta.4` was used for these workflows. Check installed exports and the project's release policy before adopting a prerelease; do not silently use one for a stable-only production rollout. ## 1. Establish the baseline - Inspect `package.json`, the lockfile, Vite config, HTML module script, browser entry, route definitions and deployment target. Run existing checks and record the current client JavaScript size before editing. - The migration baseline is Vite **6/7**, matching React/React DOM **18/19**, and **react-router 7 Data mode**. Tested combinations include React 18.2.0 + Router 7.18.3 + Vite 6.4.3 and React 19.2.8 + Router 7.18.3 + Vite 7.3.6. Node must satisfy the installed packages' engines (SSR Boost requires >=22.12.0). See [compatibility](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/README.md#compatibility). Newer templates also use the separately tested React 19 / Router 8 / Vite 8 row; peer ranges alone are not proof of compatibility. - If the SPA uses JSX `BrowserRouter`/`Routes`, first migrate it to one `createBrowserRouter(routes)` and `RouterProvider`. Preserve nested layouts, error boundaries and provider order. [Route and loader shapes](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/references/routes.md) include a minimal before entry. Verify the SPA still works before adding SSR. Do not move to React Router Framework mode. - Record browser-only imports, module-scope state reads, custom router options, multiple roots and non-root Vite `base`. Automatic init deliberately refuses ambiguous startup code; preserve it with the [manual entry shapes](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/references/entries.md) and [migration guide](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/guide/migrate-existing-spa.md). ## 2. Preview, review, apply Run from the application root: ```sh npx @lomray/vite-ssr-boost init --dry-run ``` With no flag, init also defaults to a dry run. Review the complete diff: existing Vite plugins remain, exactly one outlet is inside `#root`, client and server share routes and the same wrapper tree, and scripts/dependencies are correct. Then apply the reviewed plan: ```sh npx @lomray/vite-ssr-boost init --apply npm install npx ssr-boost doctor --json ``` Init does not install packages. It preserves the original browser filename (often `src/main.tsx`), creates `src/server.ts`, and sets `clientFile`/`serverFile` relative to the Vite root. It can extract inline routes into `routes.ssr.tsx`. Do not rename files just to match examples. Review the changed build script: init replaces `tsc -b && vite build`, so retain type checking as a separate `ts:check` script. A second apply should report no changes. For explicit paths use `--root --entry --routes `; the file overrides are relative to the project, not the Vite root. If analysis fails, use the reported file/line and the manual guide; do not repeatedly apply or delete custom code to force acceptance. ## 3. Complete request state and data loading Read [entries and createHandler options](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/references/entries.md) before editing either entry. Use `browser/entry` and `adapters/express/entry`; `node/entry` was removed in v8. Keep the managed CLI unless the chosen target requires an application-owned transport. Move `getServerState` reads and store construction into the browser entry's async `init`, after SSR state is ready. Use `isSSRMode` for the SPA fallback. Create server stores and head managers per request in `onRequest`; return JSON snapshots from `getState`. Keep browser globals in effects or client-only boundaries, and preserve equivalent first-render data on both sides. Use [streamed loader/action shapes](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/references/routes.md): return an object with a slow promise, then consume it with Suspense and `` (or React 19 `use`). Keep Data mode loaders/actions browser-compatible for navigation; server-only I/O belongs behind an API. Forward `request.signal`. Keep footer hydration unless early shell interaction is required; early mode needs an async module script and all custom state at shell readiness. See [streaming](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/guide/data-streaming.md). ## 4. Diagnose until green Run `npx ssr-boost doctor --json` after dependency, entry, route, config or script changes. Fix every error and rerun until there are none. Inspect compatibility warnings against the tested matrix; an exit code of zero does not mean every check was `ok`. Robots and size checks are informational; doctor does not measure bundle size or execute hydration. It statically checks the managed Express entry even for apps with an additional Worker entry. | Diagnostic | Fix | | --- | --- | | `SSR_BOOST_LOADER_NOT_SERIALIZABLE` | Replace functions, unsupported classes and cycles with explicit route data. Streamed promises and documented rich router values are supported. | | `SSR_BOOST_STATE_NOT_SERIALIZABLE` | Return a plain JSON snapshot from `getState`; await custom state, convert dates and collections, rebuild stores inside client `init`. | | `SSR_BOOST_OUTLET_MISSING` | Preserve exactly one `` inside the root; validate Fetch shells with `loadHtmlShell`. | | `SSR_BOOST_HYDRATION_STATE_MISSING` | Preserve generated state scripts and flush retained footer HTML on the final `onResponse` call. | | `SSR_BOOST_DUPLICATE_OUTPUT` | Emit each retained chunk once; inject head contents into the existing head. | | `SSR_BOOST_ONRESPONSE_INVALID_RETURN` | Make the hook synchronous; return string, `undefined` (keep) or `''` (withhold), never a promise. | | `SSR_BOOST_STREAM_PROMISE_ABORTED` | Bound/cancel loader I/O, handle rejection UI, and review `abortDelay` for legitimately slow work. | | `SSR_BOOST_DEPRECATED_REQ_RES` | Use `context.request` and `context.response` in render hooks; early Express `onRequest(req, res)` is unchanged. | | `SSR_BOOST_SSR_POLICY` | Inspect the selected URL policy and pattern spelling when a page unexpectedly mounts as SPA. | | `SSR_BOOST_CACHE_PRIVATE_LEAK` | Restore private/no-store handling and bypass shared cache reads and writes for personalized pages. | See [diagnostics](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/reference/diagnostics.md) and [v8 upgrades](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/guide/upgrade-v8.md). Hydration mismatches also require comparing dates, randomness, locale and browser-dependent branches in the initial render; do not suppress them. ## 5. Verify and roll out Set up [HTTP smoke, size, SSR and browser checks](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/references/verification.md), then run this skill's [verification script](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/scripts/verify.sh) from the app root using its installed absolute path: ```sh bash /path/to/ssr-boost-migrate/scripts/verify.sh --help bash /path/to/ssr-boost-migrate/scripts/verify.sh --dry-run . bash /path/to/ssr-boost-migrate/scripts/verify.sh . npm run test:ssr npm run test:browser ``` There is no `ssr-boost smoke` subcommand: `npm run smoke` is an application script. The verifier runs doctor, build, the application's enforced size budget and smoke checks, then restores the SSR build because the template smoke ends with SPA output. Compare the new gzip total to the pre-migration baseline; investigate changed chunks before intentionally setting the reviewed budget. Do not copy the library's own budget into an app or automatically raise a failing budget. For a large app, use [incremental SSR](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/guide/incremental-ssr.md): add `ssr: { mode: 'include', routes: ['/', '/articles/:slug'] }` beside `init` in the managed entry (or beside `getHtml` for Fetch). Widen the allow-list after direct-load, client navigation and hydration checks. Crawlers receive SSR by default, even outside the allow-list; use `bots: 'policy'` only when intended. Restart with `SSR_BOOST_SSR_ROUTES='!/details'` for a route rollback. SPA-selected requests skip server loaders/actions, so keep authentication and HTTP redirects in `onRequest`. Read [deployment choices](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-migrate/references/deployment.md) for Docker/Node, Vercel, Amplify or Cloudflare Workers. Before declaring done, require green existing lint/types/tests, doctor review, enforced size budget, SSR and SPA smoke, streamed data and buffered bot checks, real browser hydration with a working counter, lazy CSS/navigation, redirects/404s, and a build/preview for the selected target. Record commands, outcomes and any unavailable checks explicitly; an unrun browser check is still outstanding. --- Source: https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/SKILL.md --- name: ssr-boost-new-app description: Create and roll out a new Vite React Router Data mode application with the published create-ssr-app templates, including streamed data, request state, tests, CI, size checks, and target-specific deployment. --- # Create an SSR Boost application Work in the user's chosen parent directory and follow its repository instructions. Use the published scaffolder and inspect the resulting files; template branches evolve independently of this skill. The reference APIs target vite-ssr-boost 8.x. Links to upstream docs remain usable when this folder is installed alone. The testing kit, incremental SSR policy and Worker helper require a release containing those APIs; the stable 8.3.0 template dependency predates them. For a prerelease evaluation, the published `8.4.0-beta.4` was used for these workflows. Check installed exports and the project's release policy before adopting a prerelease; do not silently use one for a stable-only production rollout. ## 1. Choose and scaffold Check Node and the current template catalog: ```sh node --version npm view @lomray/create-ssr-app version engines npm create @lomray/ssr-app@latest -- --help ``` SSR Boost requires Node >=22.12.0; satisfy the template dependencies' engines too (the template tooling uses Node 22.23.2). The published [create-ssr-app catalog](https://github.com/Lomray-Software/create-ssr-app#templates) has these five choices: | Template | Choose it for | Command | | --- | --- | --- | | `minimal` | Small app with metadata, loaders, lazy CSS, redirects, 404 and client-only routes | `npm create @lomray/ssr-app -- --template minimal` | | `full` | MobX, consistent Suspense and the full reference application | `npm create @lomray/ssr-app -- --template full` | | `custom-server` | Managed development and a Fastify production server owned by the app | `npm create @lomray/ssr-app -- --template custom-server` | | `tanstack-query` | Per-request QueryClient, dehydration and streamed pending queries | `npm create @lomray/ssr-app -- --template tanstack-query` | | `localization` | Per-request i18next, cookie/header language selection and restoration | `npm create @lomray/ssr-app -- --template localization` | Use `minimal` unless the requested app benefits from another template. Those commands prompt for a directory on a TTY; non-TTY defaults to `my-ssr-app`. Put npm flags after `--`. To choose a directory explicitly, suppress prompts and leave Git initialization to the user/repository workflow: ```sh npm create @lomray/ssr-app@latest my-app -- --template minimal --no-git --yes cd my-app ``` Use `--no-install` when you need to inspect dependencies before installing. The normal command installs them already. `--no-git` also removes Husky and `scripts.prepare`; the generated CI workflow is still included. Do not use `--force` to scaffold over an existing application. ## 2. Inspect the structure and establish checks Read `package.json`, the lockfile, `.nvmrc`, Vite config, `.env` examples, `src/app.tsx`, `src/client.ts`, `src/server.ts`, `src/routes/index.ts`, `src/pages/`, `scripts/` and `.github/workflows/ci.yml`. These templates use Vite `root: 'src'`, public assets outside it, and `build/client` plus `build/server` output. Keep the actual aliases and filenames. Review [entries](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/references/entries.md) for the wrapper prop contract and exact managed/Fetch API shapes. ```sh npx ssr-boost doctor --json npm run develop ``` Review all doctor errors and version warnings; repeat until errors are fixed. Current templates also use the tested React 19 / Router 8 / Vite 8 combination. For Vite 6/7, React 18/19 and Router 7 compatibility choices, use the [tested matrix](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/README.md#compatibility), not just peer ranges. Keep matching React/React DOM versions and one physical copy of each. Inspect the homepage, direct nested routes and a lazy route during development, then stop the owned server. Keep the generated `size:check` budget as the starting baseline and the `smoke` script with its route expectations; update expectations when replacing sample pages. There is no package CLI `smoke` subcommand. ## 3. Implement the requested application - Add static Data mode route objects, stable IDs, layouts, error boundaries and literal lazy imports using [routes, loaders and actions](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/references/routes.md). There is no automatic file-system router, RSC or Server Actions layer. Data mode loaders/actions also run in the browser on navigation; use APIs for secrets, database calls and Worker bindings. - Return an object with a slow promise and consume it in Suspense/`Await`; use React 19 `use()` only inside a boundary. Forward `request.signal` to I/O. Test success, rejection, redirect and 404 paths. See [streaming](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/guide/data-streaming.md). - Preserve `@lomray/react-head-manager`: create a Manager per request, provide it through `App`, inject tags at shell readiness and transfer its JSON state to client `init`. Add route titles/descriptions with ``. [Entry shapes](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/references/entries.md) show every import and hook. - For localization, use the `localization` template's per-request i18next instance. Choose language from the request cookie, then Accept-Language, then an app default; transfer chosen language/resources and initialize the client before hydration. Do not reuse a mutable server i18next instance across requests or choose a different browser language for the initial render. See the [localization example](https://github.com/Lomray-Software/vite-template/tree/example/localization). - Keep public config in `import.meta.env.VITE_*`; these values are embedded into browser code. Keep secrets in server runtime variables or platform secrets, never in `VITE_*`, route data, `getState`, HTML or tracked env files. Follow the configured Vite `envDir`, add a safe `.env.example`, and restart development after env changes. Worker bindings stay in server-only modules. See [environment guidance](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/guide/migrate-existing-spa.md#environment-variables). - Restore state only inside the browser entry's async `init`. Default footer hydration is suitable unless early interaction is requested. Early mode requires `hydration: 'early'`, an async client script, and custom state ready at `onShellReady`. Keep browser globals in effects or client-only routes. ## 4. Tests, size and CI Use [verification recipes](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/references/verification.md) to add Vitest tests through `@lomray/vite-ssr-boost/testing` and Playwright tests through the separate `testing/playwright` entry. Test real application routes and providers, streamed settlements, bot buffering, redirect/status behavior, hydration and a counter click. Browser assertions must observe the page before navigation. The testing kit does not execute the browser entry in Node. Retain the generated `.github/workflows/ci.yml`: it installs with `npm ci --ignore-scripts` and runs the available lint, types, styles, warning-failing build, size and smoke scripts. It has no deployment secrets/jobs. Extend it with `npm run test:ssr`, a restored SSR build after smoke, `npx playwright install --with-deps chromium` and `npm run test:browser`; defining new scripts alone does not add them to an already generated workflow. Run this skill's [verification script](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/scripts/verify.sh) by its installed path: ```sh bash /path/to/ssr-boost-new-app/scripts/verify.sh --help bash /path/to/ssr-boost-new-app/scripts/verify.sh --dry-run . bash /path/to/ssr-boost-new-app/scripts/verify.sh . npm run test:ssr npm run test:browser ``` The verifier runs doctor, build, an enforced app size budget and `npm run smoke`, then restores SSR output because template smoke also builds SPA. Do not raise a failing size budget automatically: inspect emitted chunks and approve intentional app growth with a recorded baseline. Use [diagnostics](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/docs/reference/diagnostics.md) to fix `SSR_BOOST_*` warnings, especially custom JSON state versus rich router data and malformed/duplicated hydration output. ## 5. Prepare deployment and acceptance Read [deployment by target](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/references/deployment.md) and prepare the selected Node/Docker, Vercel, Amplify or Cloudflare build and local preview. For a custom transport, read the exact `createHandler` options in [entries](https://github.com/Lomray-Software/vite-ssr-boost/blob/prod/skills/ssr-boost-new-app/references/entries.md); the transport owns static files and route assets. Check private cache handling, secrets, redirects, 404s, lazy CSS and streaming through the selected adapter. Publish only within the user's deployment authorization. Before declaring done, run and record: - [ ] Generated lint, type and style scripts pass, along with the app's own tests. - [ ] Doctor has no errors and version/informational findings have been reviewed. - [ ] Build fails on warnings; the app's gzip size check passes against its reviewed budget. - [ ] HTTP smoke covers SSR and SPA, a streamed route, buffered crawlers, redirects, 404 and HEAD. - [ ] Testing-kit tests cover real routes and providers, deferred data, errors and statuses. - [ ] Playwright verifies hydration without mismatch/duplicate output, deferred content, a working counter, navigation and lazy CSS. If early hydration is selected, the counter works while data is pending. - [ ] Metadata and locale agree between server HTML and the first browser render; no secrets appear in browser output. - [ ] CI contains the checks above, SSR output is restored after smoke, and the target build/preview works. Report unrun checks as outstanding with their commands and reasons; do not treat HTTP-only tests as browser hydration evidence.