Nitro logoNitro

Nitro Renderer

Use a renderer to handle all unmatched routes with custom HTML or a templating system.
Nitro v3 Alpha docs are a work in progress — expect updates, rough edges, and occasional inaccuracies.

The renderer is a special handler in Nitro that catches all routes that don't match any specific API or route handler. It's commonly used for server-side rendering (SSR), serving single-page applications (SPAs), or creating custom HTML responses.

HTML template

Auto-detected index.html

By default, Nitro automatically looks for an index.html file in your project src dir.

If found, Nitro will use it as the renderer template and serve it for all unmatched routes.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Vite + Nitro App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
When index.html is detected, Nitro will automatically log in the terminal: Using index.html as renderer template.

With this setup:

  • /api/hello → Handled by your API routes
  • /about, /contact, etc. → Served with index.html

Custom HTML file

You can specify a custom HTML template file using the renderer.template option in your Nitro configuration.

import { defineNitroConfig } from "nitro/config";

export default defineNitroConfig({
  renderer: {
    template: './app.html'
  }
})

Hypertext Preprocessor (experimental)

Nitro uses rendu Hypertext Preprocessor, which provides a simple and powerful way to create dynamic HTML templates with JavaScript expressions.

You can use special delimiters to inject dynamic content:

  • {{ content }} to output HTML-escaped content
  • {{{ content }}} or <?= expression ?> to output raw (unescaped) content
  • <? ... ?> for JavaScript control flow

It also exposes global variables:

  • $REQUEST: The incoming Request object
  • $METHOD: HTTP method (GET, POST, etc.)
  • $URL: Request URL object
  • $HEADERS: Request headers
  • $RESPONSE: Response configuration object
  • $COOKIES: Read-only object containing request cookies
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Dynamic template</title>
  </head>
  <body>
    <h1>Hello {{ $REQUEST.url }}</h1>
  </body>
</html>
Read more in Rendu Documentation.

Custom renderer handler

For more complex scenarios, you can create a custom renderer handler that programmatically generates responses.

Create a renderer file to define your custom rendering logic:

renderer.ts
export default function renderer({ req, url }: { req: Request; url: URL }) {
  return new Response(
    /* html */ `<!DOCTYPE html>
    <html>
    <head>
      <title>Custom Renderer</title>
    </head>
    <body>
      <h1>Hello from custom renderer!</h1>
      <p>Current path: ${url.pathname}</p>
    </body>
    </html>`,
    { headers: { "content-type": "text/html; charset=utf-8" } }
  );
}

Then, specify the renderer entry in the Nitro config:

nitro.config.ts
import { defineNitroConfig } from "nitro/config";

export default defineNitroConfig({
  renderer: {
    handler: './renderer.ts'
  }
})

Renderer priority

The renderer always acts as a catch-all route (/**) and has the lowest priority. This means:

Specific API routes are matched first (e.g., /api/users)

Specific server routes are matched next (e.g., /about)

The renderer catches everything else

api/
  users.ts        → /api/users (matched first)
routes/
  about.ts        → /about (matched second)
renderer.ts         → /** (catches all other routes)
If you define a catch-all route ([...].ts) in your routes, Nitro will warn you that the renderer will override it. Use more specific routes or different HTTP methods to avoid conflicts.
Read more in Lifecycle.

Server-Side Rendering (SSR)

Nitro supports full server-side rendering through Vite's environment API. With SSR, Nitro renders your application to HTML on the server, sends it to the browser, and the client hydrates the page to make it interactive.

This requires two entry files:

  • Server entry (entry-server) — renders the app to HTML on the server.
  • Client entry (entry-client) — hydrates the server-rendered HTML in the browser.

Auto-detected entry points

Nitro automatically detects entry-server and entry-client files in your project's app/, src/, or root directory. No Vite configuration is needed when you follow this convention.

app/
  entry-server.ts    ← auto-detected as SSR entry
  entry-client.ts    ← auto-detected as client entry
routes/
  api/hello.ts
When entry files are detected, Nitro logs the paths in the terminal:
ℹ Using app/entry-server.ts as vite ssr entry.
ℹ Using app/entry-client.ts as vite client entry.

Supported file extensions: .ts, .js, .mts, .mjs, .tsx, .jsx.

Server entry

The server entry must export an object with a fetch method that receives a Request and returns a Response:

src/entry-server.tsx
export default {
  async fetch(req: Request): Promise<Response> {
    const html = renderAppToHTML(req);
    return new Response(html, {
      headers: { "Content-Type": "text/html;charset=utf-8" },
    });
  },
};

Client entry

The client entry runs in the browser and hydrates the server-rendered HTML:

src/entry-client.tsx
import { hydrate } from "my-framework";
import { App } from "./app";

hydrate(document.querySelector("#app"), App);

Asset management with ?assets imports

In production, Nitro collects CSS and JS assets from each entry point. Use the ?assets query to import asset manifests in the server entry:

src/entry-server.tsx
import clientAssets from "./entry-client?assets=client";
import serverAssets from "./entry-server?assets=ssr";

export default {
  async fetch(req: Request) {
    const assets = clientAssets.merge(serverAssets);

    const html = `<!DOCTYPE html>
    <html>
      <head>
        ${assets.css.map((attr) => `<link rel="stylesheet" href="${attr.href}" />`).join("\n")}
        ${assets.js.map((attr) => `<link rel="modulepreload" href="${attr.href}" />`).join("\n")}
        <script type="module" src="${assets.entry}"></script>
      </head>
      <body>
        <div id="app">${await renderToString(req)}</div>
      </body>
    </html>`;

    return new Response(html, {
      headers: { "Content-Type": "text/html;charset=utf-8" },
    });
  },
};

The ?assets=client suffix tells Nitro to collect assets from the client environment, while ?assets=ssr collects assets from the SSR environment (such as CSS imported only in server components). The merge() method combines them into a single manifest with:

  • assets.entry — the client entry script URL
  • assets.css — an array of stylesheet attributes ({ href })
  • assets.js — an array of module preload attributes ({ href })

Framework examples

See working SSR examples for popular frameworks:

FrameworkExample
Reactvite-ssr-react
Vue + Vue Routervite-ssr-vue-router
Solidvite-ssr-solid
Preactvite-ssr-preact

Use Cases

Single-Page Application (SPA)

Serve your SPA's index.html for all routes to enable client-side routing:

This is the default behavior of Nitro when used with Vite.

Server-Side Rendering (SSR)

See Server-Side Rendering for more details.