Nitro Renderer
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:
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)
}
})
| Option | Type | Description |
|---|---|---|
template | string | Path to an HTML file used as the renderer template. |
handler | string | Path to a custom renderer handler module. |
static | boolean | When 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>
import { defineHandler } from "nitro";
export default defineHandler((event) => {
return { hello: "API" };
});
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 withindex.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'
}
})
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Custom Template</title>
</head>
<body>
<div id="root">Loading...</div>
<script type="module" src="/src/main.js"></script>
</body>
</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:
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:
| Variable | Description |
|---|---|
$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 |
Built-in functions
| Function | Description |
|---|---|
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 |
<!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>
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:
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:
import { defineNitroConfig } from "nitro/config";
export default defineNitroConfig({
renderer: {
handler: './renderer.ts'
}
})
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)
[...].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.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:
<!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: