@arponascension/express-inertia

Inertia.js adapter and middleware for Express.js — build SPAs with Vue 3, React, or Svelte using classic server-side routing, EJS templates, Vite, and server-side rendering (SSR).
1,620 2
Install
npm install @arponascension/express-inertia
Latest Version:1.6.2
Vue:3
License:MIT
Last Updated:Oct 2, 2026
Links: GitHub  ·  npm
Maintainer: arponascension

Express-Inertia — Inertia.js Middleware, Adapter & SSR for Express.js

npm version License: MIT TypeScript CI Bundle Size

@arponascension/express-inertia is a production-ready Inertia.js adapter for Express.js. It brings the official Inertia.js protocol to Node.js and Express apps so you can build modern single-page applications (SPAs) with Vue 3, React, or Svelte using classic server-side routing and controllers — without the complexity of a REST API or a client-side router.

It ships with a native EJS template engine with Inertia/Vite helpers, zero-config Vite integration (HMR in development, hashed manifest.json in production), server-side rendering (SSR) with circuit-breaker resilience, security hardening, edge-runtime support (Cloudflare Workers, Vercel Edge, Netlify Edge), and first-class TypeScript types — all in a tree-shakeable ~40KB bundle.

New to Inertia.js? Read What is Inertia.js? or the official documentation.


Table of Contents


What is Inertia.js?

Inertia.js is a protocol (by Jonathan Reinink) for building modern single-page applications (SPAs) with classic server-side routing and controllers. Instead of building a JSON API and a separate JavaScript frontend, you keep writing server-side routes and controllers exactly as you do today — but render your pages with Vue, React, or Svelte components.

Inertia requests return plain JSON to the client (no full page reloads); real full-page visits return your server-rendered HTML template. Page components, props, versions, and partial reloads are all handled automatically by the Inertia protocol.

express-inertia is the Inertia.js server-side adapter for Express.js / Node.js — the Node equivalent of the official Laravel and Rails adapters. The client side stays 100% compatible with the official @inertiajs/vue3, @inertiajs/react, and @inertiajs/svelte packages.

Why use Inertia.js with Express?

  • No API layer, no client router — classic MVC controllers on the server, components on the client.
  • SEO-friendly — full-page visits serve real HTML; enable SSR to pre-render pages for search engine indexing.
  • Fast navigation — Inertia requests are small JSON payloads with automatic prop merging, lazy evaluation, and deferred data.
  • One codebase — policies, validation, and DB queries stay where they belong, in your Express routes.

Features

Feature What you get
Native EJS template helpers Call <%- inertia() %>, <%- vite('src/main.ts') %>, <%- csrf() %>, and <%- inertiaHead() %> directly inside .ejs templates
Zero-config Vite integration Automatic HMR in development; hashed manifest.json asset resolution in production
Server-side rendering (SSR) Express + Inertia SSR endpoint proxying with retry/backoff and circuit breaker fallback to client rendering
Security hardening Component name validation against path traversal, viewData sanitization, SRI hash generation
Form helpers Semantic postForm, putForm, patchForm, deleteForm with flash messages
Prefetch & preload Auto-generate <link rel="prefetch"> / <link rel="preload"> tags from the Vite manifest
Edge compatible Works on Cloudflare Workers, Vercel Edge, Netlify Edge via injected manifest/config (no fs)
Structured logging Pluggable logger abstraction with request correlation IDs (X-Request-ID)
TypeScript first Full type definitions, ESM + CJS builds, tree-shakeable ~40KB bundle

Installation

Requirements: Node.js >=18 (the runtime globals fetch, AbortController, crypto.randomUUID, and fs.rmSync are used by the SSR and middleware layers). Express ^4.18 || ^5 is a peer dependency.

npm install @arponascension/express-inertia ejs express

# Choose one client adapter — Vue 3:
npm install @inertiajs/vue3 vue
npm install -D vite @vitejs/plugin-vue

# Or React:
npm install @inertiajs/react react react-dom
npm install -D vite @vitejs/plugin-react

Peer dependencies:

npm install express

Quick Start: Express + Inertia + Vue 3

1. Configure the Express Inertia middleware

import express from 'express';
import path from 'path';
import { inertia, createInertiaEngine } from '@arponascension/express-inertia';

const app = express();

// EJS view engine with Inertia/Vite helpers for Express
app.engine('ejs', createInertiaEngine());
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// Parse request bodies and serve static files
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));

// Inertia.js middleware for Express
app.use(
  inertia({
    rootView: 'base.ejs',
    version: '1.0.0',
    shared: (req) => ({
      appName: 'My Express App',
      auth: { user: (req as any).user || null },
    }),
  })
);

// Classic server-side routes
app.get('/', (req, res) => {
  res.inertia('Home', { title: 'Welcome Home' });
});

app.listen(3000, () => console.log('Server running on http://localhost:3000'));

2. Create the root view (views/base.ejs)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title inertia><%= page?.props?.title || 'App' %></title>

    <%- inertiaHead() %>
    <%- vite('src/main.ts') %>
  </head>
  <body>
    <%- inertia() %>
    <%- csrf() %>
  </body>
</html>

Keep the inertia attribute on the title element. It allows the Inertia client adapter to replace the server-rendered title during client-side navigation.

3. Configure the Vue 3 client (src/main.ts)

import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';

createInertiaApp({
  resolve: (name) => import(`./Pages/${name}.vue`),
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) }).use(plugin).mount(el);
  },
});

4. Configure Vite (vite.config.ts)

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { inertiaVitePlugin } from '@arponascension/express-inertia/vite';

export default defineConfig({
  plugins: [vue(), inertiaVitePlugin({ input: 'src/main.ts', refresh: true })],
});

Start Express and Vite in separate terminals during development; inertiaVitePlugin() writes public/hot so the EJS root view automatically uses the Vite dev server.

When an input is provided, the plugin auto-configures the production build: it sets build.manifest, build.outDir (default public/build), build.rollupOptions.input, and a command-aware base (root-relative in dev, /build/ at build time) — so vite build emits public/build/.vite/manifest.json ready for Express with zero manual Vite config. The refresh: true option makes the browser full-reload when an EJS view under views/ changes.

Using Inertia.js with React

Install the React client adapter, configure the React client (src/main.tsx):

import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';

createInertiaApp({
  resolve: (name) => import(`./Pages/${name}.tsx`),
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />);
  },
});

Set page titles with Inertia's Head component, not only with a server prop. The EJS root view is rendered only on the initial full-page request; subsequent <Link> navigations receive JSON and do not render the EJS template again.

import { Head } from '@inertiajs/react';

export default function Dashboard() {
  return (
    <>
      <Head title="Dashboard" />
      <h1>Dashboard</h1>
    </>
  );
}

For a shared suffix, configure the callback once:

createInertiaApp({
  titleCallback: (title) => `${title} | My App`,
  // resolve and setup...
});

For Vue, use the equivalent Head component from @inertiajs/vue3.

For React, use a .tsx entrypoint in the root view and include <%- viteReactRefresh() %> before <%- vite('src/main.tsx') %>:

<%- viteReactRefresh() %>
<%- vite('src/main.tsx') %>

Replace the Vue plugin with @vitejs/plugin-react in vite.config.ts and keep the same inertiaVitePlugin():

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { inertiaVitePlugin } from '@arponascension/express-inertia/vite';

export default defineConfig({
  plugins: [react(), inertiaVitePlugin({ input: 'src/main.tsx', refresh: true })],
});

EJS Template Helpers for Express

Call the Inertia and Vite helpers as normal EJS expressions, powered by a custom Express view engine:

EJS helper Output
<%- inertia() %> Renders <div id="app"></div><script data-page="app" type="application/json">
<%- inertia('root') %> Custom root ID
<%- inertiaScript() %> JSON script only (manual root container)
<%- inertiaHead() %> Injects SSR <head> tags
<%- vite('src/main.ts') %> Dev HMR or production manifest
<%- viteReactRefresh() %> React Fast Refresh preamble
<%- csrf() %> CSRF hidden input
<%- routes() %> Ziggy / route definitions
<%- json(myVar) %> Safely stringifies a JS object

Vite Integration for Express

Auto-detects the Vite dev server via the hot file (public/hot) and reads manifest.json in production.

Zero-config: inertiaVitePlugin({ input, refresh })

The recommended setup requires nothing but your client entrypoint:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { inertiaVitePlugin } from '@arponascension/express-inertia/vite';

export default defineConfig({
  plugins: [react(), inertiaVitePlugin({ input: 'src/main.tsx', refresh: true })],
});
Option Type Default Description
input string | string[] — Client entrypoint(s) for the build. When set, the plugin auto-configures the build: enables build.manifest, builds into public/{buildDirectory}, registers build.rollupOptions.input, and uses a command-aware base (root in dev, /{buildDirectory}/ at build time so hashed asset URLs resolve against the Express static mount).
refresh boolean | string | string[] false Auto full-page reload when a server-rendered template changes. true watches views/**/*.ejs; a glob string or array watches custom paths relative to the project root.
buildDirectory string 'build' Directory inside public/ that Vite builds into.
hotFile string 'public/hot' Hot file written by the plugin in dev mode.
devServerUrl string resolved dev URL Override for the URL written to the hot file.

Manual asset resolution

If you prefer to configure the build or engine yourself, the vite option on createInertiaEngine() (or the inertia() middleware, which is ignored there and only consumed by the engine) still gives you full control:

app.engine(
  'ejs',
  createInertiaEngine({
    vite: {
      publicDir: 'public',
      buildDir: 'build',
      devServerUrl: 'http://localhost:5173',
      hotFile: 'public/hot',
      base: '/build/',
    },
  })
);

For that manual route, configure the build yourself — and use a command-aware base (a static base: '/build/' would make the Vite dev server serve modules under /build/, breaking dev HMR):

export default defineConfig(({ command }) => ({
  plugins: [react(), inertiaVitePlugin()],
  base: command === 'build' ? '/build/' : '/',
  build: {
    manifest: true,
    outDir: 'public/build',
    rollupOptions: { input: 'src/main.tsx' },
  },
}));

Prefetch and preload helpers

import { createPrefetchHelper } from '@arponascension/express-inertia';

const prefetch = createPrefetchHelper(viteHelper);

// In your base template:
<%= prefetch.prefetch('src/Pages/Dashboard.vue') %>
// <link rel="prefetch" href="/build/assets/Dashboard.abc1234.js">

<%= prefetch.preload('src/main.ts') %>
// <link rel="preload" href="/build/assets/main.abc1234.js">

Server-Side Rendering (SSR) for SEO

Server-side rendering (SSR) pre-renders your JavaScript pages on the server, so visitors and search engines receive fully rendered HTML — better indexability, faster first paint, and a better core-web-vitals story. Inertia's official SSR server is a Node.js background process; express-inertia proxies render requests to it (default http://127.0.0.1:13714/render) with retries, exponential backoff, a circuit breaker, and graceful client-side fallback if SSR fails.

app.use(
  inertia({
    ssr: {
      enabled: true,
      url: 'http://127.0.0.1:13714/render',
      timeout: 2000,
      fallback: true,
      retry: { maxRetries: 2, baseDelayMs: 200, maxDelayMs: 2000 },
      circuitBreaker: { failureThreshold: 5, cooldownMs: 30000 },
    },
  })
);

Or pass a custom in-process render function (no HTTP endpoint needed):

app.use(
  inertia({
    ssr: {
      enabled: true,
      render: async (page) => {
        return {
          head: [`<title inertia>${page.props.title}</title>`],
          body: '<div id="app">...</div>',
        };
      },
    },
  })
);

Set up the SSR server for your client framework following the official Inertia.js SSR guide.


Response API: res.inertia()

res.inertia(component, props?, viewData?)

Renders an Inertia response. In AJAX (X-Inertia) requests it returns JSON; on full page loads it renders the root template with viewData passed only to the template:

app.get('/users', (req, res) => {
  res.inertia('Users/Index', {
    users: await getUsers(),
  }, {
    metaTitle: 'User Directory', // viewData: passed to base.ejs only
  });
});

Form helpers

Semantic form submission helpers with automatic method semantics:

app.post('/login', (req, res) => {
  res.inertia.postForm('Dashboard', { user: req.body.user });
});

app.put('/profile', (req, res) => {
  res.inertia.putForm('Profile', { name: req.body.name });
});

app.patch('/settings', (req, res) => {
  res.inertia.patchForm('Settings', { theme: req.body.theme });
});

app.delete('/account', (req, res) => {
  res.inertia.deleteForm('Goodbye');
});

Flash messages

res.inertia
  .withFlash('success', 'Profile updated!')
  .inertia('Dashboard');

Redirect helpers

// External redirect / full reload (409 + X-Inertia-Location)
res.inertia.location('https://stripe.com/checkout');

// Back to referrer (303 See Other)
res.inertia.back('/dashboard');

History encryption (Inertia v2)

res.inertia.encryptHistory(true);
res.inertia.clearHistory(true);
res.inertia('SecretReport');

Prop Helpers (Lazy, Deferred, Merge)

Fine-grained control over what props are sent to the client:

import { lazy, always, defer, merge, optional } from '@arponascension/express-inertia';

app.get('/dashboard', (req, res) => {
  res.inertia('Dashboard', {
    summary: await getSummary(),

    // Omitted on initial load; fetched only when explicitly reloaded
    heavyReport: lazy(async () => calculateComplexMetrics()),

    // Always included, even on partial reloads of other keys
    announcements: always(async () => getLatestAnnouncements()),

    // Inertia v2: appends to existing client-side list
    feed: merge(async () => getFeedItems()),

    // Inertia v2: fetched in background after initial render
    chartData: defer(async () => getChartData(), { group: 'analytics' }),

    // Inertia v2: included on initial load, then treated as optional on updates
    extraSetting: optional(() => getExtraSetting()),
  });
});

Security Hardening

Component name validation

Prevents path traversal and injection attacks:

app.use(
  inertia({
    security: {
      validateComponentNames: true,
      componentNamePattern: /^[\w\/-]+$/,
    },
  })
);

ViewData sanitization

Automatically strips functions and undefined values from template data. Independently configurable from component name validation via sanitizeViewData:

app.use(
  inertia({
    security: {
      validateComponentNames: false, // allow arbitrary component names
      sanitizeViewData: true, // still sanitize view data (default)
    },
  })
);

Set sanitizeViewData: false to preserve functions and undefined values in template locals (e.g. when passing helper functions to your root view by design).

SRI hash generation

import { generateSriHash } from '@arponascension/express-inertia';

const hash = await generateSriHash(assetContent);
// sha384-abc123...

Structured Logging

Replace console.warn with a pluggable logger:

import { createLogger, setGlobalLogger } from '@arponascension/express-inertia';
import pino from 'pino';

setGlobalLogger(createLogger({ prefix: 'my-app', logger: pino() }));

Request correlation IDs

import { requestIdMiddleware } from '@arponascension/express-inertia';

app.use(requestIdMiddleware());
app.use(inertia());

Correlates logs across the request lifecycle using X-Request-ID or auto-generated IDs.


Edge Runtime Compatibility

Works on Cloudflare Workers, Vercel Edge, and Netlify Edge by bypassing Node.js fs/path — inject the manifest, config, and template source directly:

const viteHelper = createViteHelper({
  manifest: await fetch('/build/.vite/manifest.json').then(r => r.json()),
  base: '/build/',
  isDev: false,
});

app.engine('ejs', createInertiaEngine({
  templateSource: await fetch('/templates/base.ejs').then(r => r.text()),
  vite: { manifest: await getManifest() },
}));

Resilience: Circuit Breaker & Retry

Circuit breaker (SSR)

Prevents cascade failures when the SSR endpoint is down:

import { CircuitBreaker, resetAllCircuitBreakers } from '@arponascension/express-inertia';

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  cooldownMs: 30000,
  probeDelayMs: 1000,
});

Retry with exponential backoff

import { calculateBackoff } from '@arponascension/express-inertia';

const delay = calculateBackoff(attempt, 200, 2000);

Performance

  • Tree-shakeable: import only what you need (ESM + CJS dual builds)
  • Template caching: compiled EJS templates cached (mtime-based in development, ejs cache: true in production)
  • Lazy props: defer expensive computations until needed
  • Prefetching: preload likely navigation targets from the Vite manifest
  • Minified bundle: ~40KB main entry

API Reference

Middleware

Export Description
inertia(options?) Inertia.js Express middleware factory
createInertia(options?) Same as inertia, explicit name
requestIdMiddleware() Assigns req.id for log correlation
createInertiaEngine(options?) EJS view engine with Inertia/Vite helpers
inertiaEngine Default engine instance

Prop helpers

Export Description
lazy(fn) Omit on initial load
always(fn) Always include
defer(fn, opts?) Inertia v2 deferred prop
merge(fn) Inertia v2 merge prop
optional(fn) Inertia v2 optional prop
resolveProps(props, req, isInertia) Resolve all prop wrappers

Utilities

Export Description
validateComponentName(name, pattern?) Prevent path traversal
sanitizeViewData(data) Strip functions from template data
serializePage(page) Safe JSON serialization for HTML attributes
safeStringify(obj) Circular-reference-safe JSON.stringify
generateSriHash(content) SHA-384 SRI hash
createLogger(opts?) Pluggable structured logger
setGlobalLogger(logger) Replace global logger
getGlobalLogger() Get current global logger

Resilience

Export Description
CircuitBreaker CLOSED / OPEN / HALF_OPEN state machine
shouldRetry(error, status, codes) Retry decision helper
calculateBackoff(attempt, base, max) Exponential backoff with jitter
resetAllCircuitBreakers() Bulk reset for testing

Vite

Export Description
createViteHelper(config?) Vite asset resolver
inertiaVitePlugin(opts?) Vite plugin — hot file + auto build config (input, refresh, buildDirectory)
createPrefetchHelper(viteHelper) Prefetch/preload tag generator

FAQ

What is Inertia.js?

Inertia.js is a protocol for building modern single-page applications (SPAs) using classic server-side routing and controllers, without building an API. Server routes render Vue, React, or Svelte page components; navigation between pages uses small JSON responses instead of full page reloads.

How do I use Inertia.js with Express?

Install @arponascension/express-inertia, register the inertia() middleware (plus the createInertiaEngine() EJS view engine), and return res.inertia('Component', { props }) from your routes. See the Quick Start above.

Does express-inertia support Vue 3, React, and Svelte?

Yes — the client side uses the official @inertiajs/vue3, @inertiajs/react, and @inertiajs/svelte packages, so whichever framework you use with Vite works with this adapter.

Does express-inertia support server-side rendering (SSR)?

Yes. Enable ssr: { enabled: true } and point it at the official Inertia SSR server (or pass a custom render function). SSR is protected by retries, exponential backoff, and a circuit breaker with client-side fallback.

What template engine does express-inertia use?

EJS. A custom createInertiaEngine() is provided so templates load through Express's normal view engine mechanism, with Inertia and Vite helpers exposed as template locals (inertia(), vite(), csrf(), inertiaHead(), and more).

Is express-inertia compatible with edge runtimes?

Yes. Provide manifest/isDev/devServerUrlOverride and a templateSource so no Node.js fs or path access is required — it runs on Cloudflare Workers, Vercel Edge, and Netlify Edge.

Which versions of Node.js and Express are supported?

Node.js >=18 (tested on 18, 20, 22, 25.8.x) and Express ^4.18 || ^5. See the compatibility matrix.


Compatibility Matrix

The following versions are exercised by the integration suite. Test your application before upgrading a major version.

Dependency Supported Tested
Node.js >= 18 18, 20, 22, 25.8.x
Express ^4.18 || ^5 4.22.x
@inertiajs/core 2.x 2.3.27
Vue 3.x 3.5.42

Testing

npm test

Run the full test suite with Vitest. The suite includes Vue/Inertia protocol integration coverage and a real Vite production-manifest build.

npm run typecheck   # TypeScript type checking
npm run lint        # ESLint
npm run test:coverage # Coverage report (v8)

Resources & Ecosystem

Looking for Inertia.js on another framework? Official adapters exist for Laravel and Rails, and community adapters cover Fastify, Hono, and more.


License

MIT © Arpon Ascension

Related Packages

@sctg/vite-plugin-github-pages-spa

Vite plugin to enable SPA (Single Page Application) support for GitHub Pages

4,991 17
@arkstack/inertia

InertiaJS server-side adapter for Arkstack, building the modern monolith with se...

12,191 6
express-vite-plugin

Zero-config Vite SSR integration plugin for Express and NestJS applications.

5
@frontman-ai/vite

Vite integration for Frontman - AI-powered development tools

21,987 719
vite-plugin-ssr-ssg

Vite plugin for SSR and SSG

10,890 28

Version History

Version Released License
1.6.2 MIT
1.6.1 MIT
1.6.0 MIT
1.5.2 MIT
1.5.1 MIT