The project
Takt is a freelance time-tracking and invoicing tool I built for my own use and opened up to other freelancers. The stack is Laravel 13 on the backend, Vue 3 + Inertia.js v3 on the frontend, deployed with FrankenPHP inside a Docker multi-stage build.
Inertia.js is a library that lets you build single-page applications using server-side routing β no API, no JSON endpoints, just Laravel controllers that return Vue components. The whole app is a classic SPA: authenticated pages (dashboard, timesheets, invoices) rendered client-side after login. The one exception is the landing page, which is public, static, and needs to be visible to search engines from the first byte. I wanted server-side rendering for it.
The problem
Inertia's built-in SSR solution works by running a persistent Node.js HTTP server alongside PHP in production. On every request, PHP calls that server to get the pre-rendered HTML. This is the right approach if you have many dynamic pages β but for a single marketing page that never changes, spinning up and babysitting a Node.js process felt like overkill.
I wanted build-time rendering only: generate the HTML once when I deploy, serve it fast from PHP, with zero runtime Node.js dependency.
How Inertia SSR works under the hood
The Node.js server
In production, Inertia runs a persistent Node.js HTTP server alongside PHP. You start it with
php artisan inertia:start-ssr, which executes bootstrap/ssr/app.js β a bundle that calls createServer(renderPage) from @inertiajs/vue3/server. This starts an HTTP server on port 13714 and keeps it alive indefinitely. You need Supervisor or a similar process manager to ensure it stays up.
On every SSR request, PHP's
HttpGateway does a synchronous HTTP call to that server:PHP β POST <http://127.0.0.1:13714/render> body: { component: "LandingPage", props: {β¦}, url: "/", version: "β¦" } Node β { head: ["<title>Takt</title>"], body: "<div>β¦rendered HTMLβ¦</div>" }
The Node server receives the full page data from PHP, calls
renderToString, and returns the result. It is completely stateless β no session, no request context. It also exposes GET /health, which HttpGateway::isHealthy() uses to check availability.In development, there is no separate Node.js process. When you run
yarn dev, the @inertiajs/vite plugin registers a /__inertia_ssr middleware directly on the Vite dev server. HttpGateway detects Vite::isRunningHot() and routes to that endpoint instead:$url = $isHot ? $this->getHotUrl('/__inertia_ssr') // β <http://localhost:5173/__inertia_ssr> : $this->getProductionUrl('/render'); // β <http://127.0.0.1:13714/render>
So in development, Vite handles both HMR and SSR rendering in one process β you never need to run
inertia:start-ssr locally.The PHP rendering pipeline
Before building anything, I traced exactly what Inertia does on the PHP side once the Gateway returns a response:
Browser GET / β PHP: Inertia::render('LandingPage') β Response::toResponse($request) β SsrState::setPage($page) β view('app', ['page' => $page]) Blade: <x-inertia::head> β SsrState::dispatch() // calls app(Gateway::class)->dispatch($page) β {!! $response->head !!} // injects <title>, <meta>β¦ Blade: <x-inertia::app/> β SsrState::dispatch() // cached, same response β {!! $response->body !!} // injects the pre-rendered HTML + data-page script
The
Gateway interface has a single method: dispatch(array $page): ?Response. HttpGateway is the default implementation β it does the HTTP call to Node.js described above. Returning null from any Gateway signals Inertia to fall back to client-side rendering.This is the extension point I needed.
The approach: a custom Gateway + a Vite plugin
Instead of calling a Node.js server at runtime, I read a pre-generated JSON file:
// app/Ssr/SSGGateway.php class SSGGateway extends HttpGateway { public function dispatch(array $page, ?Request $request = null): ?Response { if (! $this->ssrIsEnabled($request ?? request())) { return null; } // In dev with `yarn dev`, proxy to the Vite SSR server as usual if (Vite::isRunningHot()) { return parent::dispatch($page, $request); } $path = public_path("build/ssg/{$page['component']}.json"); if (! file_exists($path)) { return null; // silent CSR fallback for pages we do not prerender. } $data = json_decode(file_get_contents($path), true); return new Response( implode("\\n", $data['head'] ?? []), $data['body'] ?? '', ); } }
Bound in
AppServiceProvider::register():$this->app->bind(Gateway::class, SSGGateway::class);
Thats it. Your PHP code needs no more changes β it still does
Inertia::render('LandingPage'). The Gateway transparently intercepts the SSR call and returns the pre-rendered content. PHP renders app.blade.php normally: CSRF token, @vite asset tags, layout β all generated at request time.The
<script data-page="app"> element, which Inertia's client reads to hydrate the app, is part of $response->body and contains the correct page props for this request, generated by PHP.Generating the JSON at build time
The JSON file needs to be produced during
yarn build. I extracted a reusable Vite plugin that accepts a list of pages to pre-render, each with their component name, URL, and optional props:// vite-plugin-inertia-ssg.ts type SsgPage = { component: string url: string props?: Record<string, unknown> } export function inertiaSsg(pages: SsgPage[]): Plugin { let isSsr = false return { name: 'inertia-ssg', apply: 'build', configResolved(config) { isSsr = !!config.build.ssr }, closeBundle() { if (!isSsr) return const root = cwd() const ssrBundle = join(root, 'bootstrap/ssr/app.js') const outDir = join(root, 'public/build/ssg') const script = [ `import { mkdir, writeFile } from 'node:fs/promises'`, `import { pathToFileURL } from 'node:url'`, `const { default: render } = await import(pathToFileURL(${JSON.stringify(ssrBundle)}).href)`, `await mkdir(${JSON.stringify(outDir)}, { recursive: true })`, ...pages.flatMap(page => [ `{ const { head, body } = await render(${JSON.stringify({ ...page, version: null })})`, `await writeFile(${JSON.stringify(join(outDir, `${page.component}.json`))}, JSON.stringify({ head, body })) }`, ]), `process.exit(0)`, ].join('\\n') execSync('node --input-type=module', { input: script, cwd: root, stdio: ['pipe', 'inherit', 'inherit'] }) pages.forEach(({ component }) => console.log(`β SSG: public/build/ssg/${component}.json generated`)) }, } }
Used in
vite.config.ts:import { inertiaSsg } from './vite-plugin-inertia-ssg' // in plugins: inertiaSsg([ { component: 'LandingPage', url: '/' }, ])
The build script stays the Inertia SSR standard:
"build": "vite build && vite build --ssr"
The first pass builds the client SPA bundle. The second pass (
--ssr) builds bootstrap/ssr/app.js and triggers closeBundle, which renders each declared page and writes its JSON file.Why a child process?
Importing
bootstrap/ssr/app.js directly in the plugin would block forever. In Inertia's auto mode, the SSR bundle calls createServer() on import, which starts an HTTP server and keeps the Node.js event loop alive. The plugin would never finish.The fix: run the render in an isolated child process via
node --input-type=module with the script passed on stdin. The process.exit(0) at the end of the script kills the child even with the server running, leaving the Vite parent process unaffected.The script writes the components directly to
public/build/ssg/, the folder that SSGGateway looks for.How it fits in Docker
The project uses a multi-stage Dockerfile: a
front stage (Node.js only) and a back stage (PHP only).# Stage: front (Node.js only β no PHP) FROM node:alpine AS front # ... RUN yarn install --immutable && yarn build # β generates public/build/ including public/build/ssg/LandingPage.json # Stage: back (PHP β no Node.js) FROM dunglas/frankenphp:php8.5 AS back # ... COPY --from=front /usr/src/app/public/build ./public/build # β the SSG JSON is now available to PHP at runtime, like the other assets.
The Vite plugin runs entirely in the Node.js stage. PHP at runtime reads the file via
public_path(). No Node.js process runs in production, and no PHP is needed during the frontend build.Developer experience
yarn dev: Vite hot-reload mode,Vite::isRunningHot()is true,SSGGatewayproxies to the standard Inertia SSR dev server (or falls back to CSR if not running)
yarn build: generates the JSON, everything is baked in for production
- Missing JSON:
SSGGatewayreturnsnull, Inertia silently falls back to CSR β no errors, no special handling needed
- Other pages: unaffected, they continue as normal Inertia SPA pages
Result
The landing page is served as fully rendered HTML from the first byte β good for SEO, fast for users. After the JS bundle loads, Vue hydrates the page and all
<Link> components work as SPA navigations. Everything else (auth pages, dashboard) remains a standard client-side Inertia app.The key insight is that Inertia's
Gateway interface is exactly the right extension point for this: it sits between the PHP response lifecycle and the Blade template, so CSRF tokens, asset fingerprints, and shared props are all handled by Laravel at request time β the pre-rendered HTML is just the visual content.Would you like to see that shipped as a external package, or added in the Inertia core? Tell me!