Server rendering
With ssr: true, the plugin renders every page on the server in the dev server, and vite build builds a server bundle next to the browser one. This site is the worked example: an Angular app compiled by vite-plugin-ngp, rendered to static HTML with Angular SSR at build time, and hydrated in the browser. This page shows how to set up the same for your app, and what the plugin doesn't do for you.
Turn it on#
import {defineConfig} from 'vite';
import {ngp} from 'vite-plugin-ngp';
export default defineConfig({
plugins: [ngp({ssr: true})],
});ssr: true expects the server entry at src/main.server.ts. To use another file, write ssr: {entry: 'src/server.ts'}.
The server entry#
The entry renders one page. Its default export takes the URL and the contents of index.html, and resolves to the rendered HTML. This site's src/main.server.ts:
import {mergeApplicationConfig} from '@angular/core';
import {bootstrapApplication, type BootstrapContext} from '@angular/platform-browser';
import {provideServerRendering, renderApplication} from '@angular/platform-server';
import {App} from './app/app';
import {appConfig} from './app/app.config';
const serverConfig = mergeApplicationConfig(appConfig, {providers: [provideServerRendering()]});
export default function render(url: string, document: string): Promise<string> {
return renderApplication((context: BootstrapContext) => bootstrapApplication(App, serverConfig, context), {
document,
url,
allowedHosts: ['localhost'],
});
}The browser side adds hydration, so Angular reuses the server's DOM instead of rendering the page again:
provideClientHydration(withEventReplay())What the plugin does#
In the dev server, a middleware renders every page request through your entry, loaded by Vite's ssr environment. It passes the entry index.html as Vite transformed it, and returns the rendered page for the browser to hydrate. Edit a template and the browser updates in place, while the next server render shows the edit too.
The server's Angular libraries are pre-bundled and cached, like the browser's. Vite leaves node_modules to Node in server environments and turns its dependency optimizer off there, so without this every Angular package was linked and transformed again on each start. The plugin turns the optimizer on for the ssr environment, scanning your server entry, and the linked bundle lives in node_modules/.vite/deps_ssr next to the browser's deps. It's rebuilt when the lockfile or the Vite config changes, when a library is first imported while the server runs, or with vite --force; DEBUG=vite:deps shows when. Only Angular libraries go into the bundle: rxjs and everything else they import stay with Node, so your app and the bundle share one copy. On the playground, a warm restart's first page went from 1.3 s to 0.74 s with the cache, measured on 17 October 2026 on an M2 Max as the median of six restarts. The first start after an install pays about 0.35 s to build the bundle.
One thing you give up: the server bundle has no source maps, because Vite read and combined them on every start, about 170 ms of the playground's first page. A server stack trace from inside Angular points into the readable, unminified file under deps_ssr, not into @angular/core/fesm2022. Your own modules keep their maps.
In server environments, the plugin compiles your components with their own ngp compiler, without HMR code, because HMR code imports updates from URLs only a browser can load. It defines ngServerMode as true. It also bundles every Angular library into the server build and links it: @angular/*, plus every package in your dependencies that depends on @angular/core, such as Angular Material or NgRx. Vite usually leaves node_modules for Node to load, and Node would load those libraries unlinked, which only works with the JIT compiler. An Angular library listed under devDependencies isn't found; add it to resolve.noExternal yourself.
In vite build, it builds both environments: the browser bundle into dist/client, and your entry into dist/server/main.server.js. Serving them is up to you, with any Node server that calls render for page requests and serves dist/client for everything else. With the i18n option, both folders get one subfolder per locale, such as dist/server/de/main.server.js, and your server picks the bundle by the URL's first segment. Internationalization has the details.
Prerendering for static hosting#
GitHub Pages serves files, not a Node server, so this site renders every route at build time instead. A short script imports the server bundle, renders each route into the built index.html, and writes the result where Pages expects it:
const template = await readFile('dist/client/index.html', 'utf8');
const {default: render, routes} = await import('./dist/server/main.server.js');
for (const route of routes) {
const html = await render(`http://localhost/vite-plugin-ngp/${route}`, template);
await writeFile(`dist/client/${route}/index.html`, html);
}routes is a named export from the same server entry. It comes from the list that builds the site's router and sidebar, so a new page gets prerendered without touching the script. The site also renders a 404.html, which GitHub Pages serves for any unknown path. Rendering the site's 16 pages took 5 to 29 ms each in the build of 17 October 2026, on an M2 Max.
Limits#
- No status codes.
renderreturns HTML only. Behind a Node server, a not-found page answers 200. Angular's way to set the status,@angular/ssr's request and response tokens, isn't wired up. - No
@angular/ssrfeatures. Per-route render modes andAngularNodeAppEnginearen't supported. You write the server, or the prerender loop, yourself. - Two analyzers in dev. The browser and server compilers each hold an ngp analyzer, so SSR roughly doubles the dev server's analyzer memory.