Nitro logoNitro

Nitro Renderer

Use a renderer to handle all unmatched routes with custom HTML or a templating system.

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.

Configuration

The renderer is configured using the renderer option in your Nitro config:

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

export default defineNitroConfig({
  renderer: {
    template: './index.html',  // Path to HTML template file
    handler: './renderer.ts',  // Path to custom renderer handler
    static: false,             // Treat template as static HTML (no rendu processing)
  }
})
OptionTypeDescription
templatestringPath to an HTML file used as the renderer template.
handlerstringPath to a custom renderer handler module.
staticbooleanWhen true, skips rendu template processing and serves the HTML as-is. Auto-detected based on template syntax when not set.

Set renderer: false in the config to explicitly disable the renderer entirely (including auto-detection of index.html).

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'
  }
})

Static templates

By default, Nitro auto-detects whether your HTML template contains rendu syntax. If it does, the template is processed dynamically on each request. If it doesn't, it's served as static HTML.

You can override this behavior with the static option:

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

export default defineNitroConfig({
  renderer: {
    template: './index.html',
    static: true // Force static serving, skip template processing
  }
})

In production, static templates are inlined into the server bundle and served directly for optimal performance.

Hypertext Preprocessor (experimental)

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

Output expressions

  • {{ expression }} — HTML-escaped output
  • {{{ expression }}} or <?= expression ?> — raw (unescaped) output
<h1>Hello {{ $URL.pathname }}</h1>
<div>{{{ '<strong>raw html</strong>' }}}</div>

Control flow

Use <? ... ?> for JavaScript control flow:

<? if ($METHOD === 'POST') { ?>
  <p>Form submitted!</p>
<? } else { ?>
  <form method="POST">
    <button type="submit">Submit</button>
  </form>
<? } ?>

<ul>
<? for (const item of ['a', 'b', 'c']) { ?>
  <li>{{ item }}</li>
<? } ?>
</ul>

Server scripts

Use <script server> to execute JavaScript on the server:

<script server>
  const data = await fetch('https://api.example.com/data').then(r => r.json());
</script>
<pre>{{ JSON.stringify(data) }}</pre>

Streaming content

Use the echo() function for streaming content. It accepts strings, functions, Promises, Response objects, or ReadableStreams:

<script server>
  echo("Loading...");
  echo(async () => fetch("https://api.example.com/data"));
</script>

Global variables

Access request context within templates:

VariableDescription
$REQUESTThe incoming Request object
$METHODHTTP method (GET, POST, etc.)
$URLRequest URL object
$HEADERSRequest headers
$RESPONSEResponse configuration object
$COOKIESRead-only object containing request cookies

Built-in functions

FunctionDescription
htmlspecialchars(str)Escape HTML characters (automatically applied in {{ }} syntax)
setCookie(name, value, options?)Set a cookie in the response
redirect(url)Redirect the user to another URL
echo(content)Stream content to the response
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Dynamic template</title>
  </head>
  <body>
    <h1>Hello {{ $REQUEST.url }}</h1>
    <p>Welcome, <?= $COOKIES["user"] || "Guest" ?>!</p>
    <script server>
      setCookie("visited", "true", { maxAge: 3600 });
    </script>
  </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.

The handler is a default export function that receives an H3 event object. You can access the incoming Request via event.req:

renderer.ts
export default function renderer({ req }: { req: Request }) {
  const url = new URL(req.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'
  }
})
When renderer.handler is set, it takes full control of rendering. The renderer.template option is ignored.

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.

Vite integration

When using Nitro with Vite, the renderer integrates with Vite's build pipeline and dev server.

Development mode

In development, the renderer template is read from disk on each request, so changes to index.html are reflected immediately without restarting the server. Vite's transformIndexHtml hook is applied to inject HMR client scripts and other dev-time transforms.

SSR with <!--ssr-outlet-->

When using Vite environments with an ssr service, you can add an <!--ssr-outlet--> comment to your index.html. Nitro will replace it with the output from your SSR entry during rendering:

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>SSR App</title>
  </head>
  <body>
    <div id="app"><!--ssr-outlet--></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Production build

During production builds, Vite processes the index.html through its build pipeline (resolving scripts, CSS, and other assets), then Nitro inlines the transformed HTML into the server bundle.

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.