When your frontend serves content that doesn't change per-request (docs, marketing pages, blogs), pre-rendering it to static HTML gives you instant first paint and full SEO without waiting for JavaScript.
Quick Start
Three steps to enable static rendering:
1. Set the render mode in your app configuration:
import type { AgentuityConfig } from '@agentuity/cli';
export default {
render: 'static',
} satisfies AgentuityConfig;2. Create src/web/entry-server.tsx with your render function and route tree:
import { renderToString } from 'react-dom/server';
import { StrictMode } from 'react';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
// Re-export for automatic route discovery
export { routeTree };
export async function render(url: string): Promise<string> {
const memoryHistory = createMemoryHistory({ initialEntries: [url] });
const router = createRouter({ routeTree, history: memoryHistory });
await router.load();
return renderToString(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
}3. Build or deploy:
agentuity bundle # local build
agentuity deploy # or deploy directlyEvery non-parameterized route is discovered automatically from the route tree. Each discovered path gets its own pre-rendered index.html.
Configuration
The render option in your app configuration controls how the frontend is built:
| Mode | Behavior |
|---|---|
'spa' (default) | Standard single-page app. Client-side routing, JavaScript renders everything |
'static' | Pre-renders all routes at build time. Full HTML in the initial response |
import type { AgentuityConfig } from '@agentuity/cli';
export default {
render: 'static', // 'spa' | 'static'
} satisfies AgentuityConfig;Entry Server
The src/web/entry-server.tsx file provides exports that the build pipeline uses:
render(url: string): Promise<string>
Takes a URL path and returns the rendered HTML string for that route. This is called once per path during the build.
routeTree (recommended)
Re-export your router's route tree. The CLI walks it to discover all non-parameterized routes automatically — no manual path listing needed. This is the easiest way to get all your static routes pre-rendered.
import { routeTree } from './routeTree.gen';
// Re-export for automatic route discovery
export { routeTree };getStaticPaths(): string[] (optional)
Returns additional paths to pre-render. Use this for parameterized routes that can't be auto-discovered (e.g., /blog/$slug → you list the concrete slugs). These paths are merged with the auto-discovered routes.
Routes not covered by either method fall back to the SPA shell (the base index.html without pre-rendered content).
For most apps, just exporting routeTree is enough. You only need getStaticPaths() when you
have parameterized routes like /blog/$slug or /users/$id that require concrete URLs.
// Only needed for parameterized routes like /blog/$slug
export function getStaticPaths(): string[] {
return ['/blog/hello-world', '/blog/getting-started'];
}You can also generate these paths programmatically. Use import.meta.glob to discover MDX files, read a CMS API, or build paths from any data source:
// Example: discover paths from MDX files for a parameterized /blog/$slug route
const posts = import.meta.glob('./content/blog/*.mdx', { eager: true });
export function getStaticPaths(): string[] {
return Object.keys(posts).map((file) => {
const slug = file.replace('./content/blog/', '').replace('.mdx', '');
return `/blog/${slug}`;
});
}How It Works
The build pipeline runs in sequence:
- Client build runs normally: Vite compiles your frontend into
.agentuity/client/ - SSR build compiles
entry-server.tsxusing Vite's SSR mode, resolvingimport.meta.glob, MDX imports, and other build-time features - Route discovery: routes are extracted automatically from the exported
routeTree(skipping parameterized routes like$id). IfgetStaticPaths()is also exported, those paths are merged in. - Rendering:
render(url)is called for each path - HTML injection: each rendered string replaces
<!--app-html-->in the client'sindex.htmltemplate - File output: pre-rendered pages are written to
.agentuity/client/[route]/index.html - Cleanup: SSR build artifacts are removed automatically
src/web/index.html (template)
│
▼
.agentuity/client/index.html ← / route
.agentuity/client/about/index.html ← /about route
.agentuity/client/blog/index.html ← /blog route
Template Placeholder
Your src/web/index.html needs a <!--app-html--> comment inside the root element. The build replaces this comment with the pre-rendered HTML:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/frontend.tsx"></script>
</body>
</html>Without <!--app-html--> inside your root element, the build has nowhere to inject the rendered HTML. The placeholder must be a direct child of the mount element (e.g., <div id="root">).
Hydration
Pre-rendered HTML appears instantly, but your app still needs JavaScript for interactivity. The client bundle loads and "hydrates" the static HTML, attaching event listeners and enabling React's reactivity.
Use hydrateRoot when the page has pre-rendered content, and fall back to createRoot for development or SPA mode:
import { hydrateRoot, createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root')!;
if (container.innerHTML.trim()) {
// Pre-rendered content exists: hydrate it
hydrateRoot(container, <App />);
} else {
// No pre-rendered content (dev mode or SPA fallback)
createRoot(container).render(<App />);
}If your UI depends on runtime values like the current theme or window.innerWidth, React may log
hydration warnings on first load. This is normal: React recovers and the UI updates to the
correct state. Guard browser-only values with useEffect or a client-only wrapper.
SPA vs Static
| SPA (default) | Static | |
|---|---|---|
| First paint | Blank until JS loads and renders | Instant HTML |
| SEO | Requires JavaScript execution | Full HTML in page source |
| Build time | Fast | Slower (renders each route) |
| Dynamic content | Everything is dynamic | Static shell + client hydration |
| Best for | Apps, dashboards, authenticated UIs | Docs, marketing sites, blogs |
Tips and Gotchas
import.meta.globworks: the Vite SSR build resolves it at build time, so you can use it to discover content files- Auto-discovery skips parameterized routes: paths containing
$(like/blog/$slug) are skipped during auto-discovery. ExportgetStaticPaths()to list the concrete URLs for these routes - Undiscovered routes fall back to SPA: any route not found via auto-discovery or
getStaticPaths()serves the baseindex.htmlwithout pre-rendered content. Client-side routing still works - Each path gets its own file:
/blog/hellobecomes.agentuity/client/blog/hello/index.html - Build time scales with routes: 10 routes is fast; 10,000 routes takes longer. The rendering itself is synchronous per-route
Full Example
A complete minimal setup with all four files:
import type { AgentuityConfig } from '@agentuity/cli';
export default {
render: 'static',
} satisfies AgentuityConfig;<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/frontend.tsx"></script>
</body>
</html>import { renderToString } from 'react-dom/server';
import { StrictMode } from 'react';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
// Re-export for automatic route discovery
export { routeTree };
export async function render(url: string): Promise<string> {
const memoryHistory = createMemoryHistory({ initialEntries: [url] });
const router = createRouter({ routeTree, history: memoryHistory });
await router.load();
return renderToString(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
}
// Optional: only needed for parameterized routes like /blog/$slug
export function getStaticPaths(): string[] {
return ['/blog/hello-world', '/blog/getting-started'];
}import { hydrateRoot, createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root')!;
if (container.innerHTML.trim()) {
hydrateRoot(container, <App />);
} else {
createRoot(container).render(<App />);
}Next Steps
- Build Configuration: Customize the Vite build with plugins and constants
- Deployment Scenarios: Deploy your frontend alongside agents or separately
- App Configuration: All configuration options including
render