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
Static rendering runs during agentuity build or agentuity deploy when your project has src/web/index.html and src/web/entry-server.tsx.
1. Add a placeholder inside your root element:
<div id="root"><!--app-html--></div>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.
How Static Rendering Is Enabled
The build pipeline enables static rendering by file convention:
- If
src/web/index.htmlexists, the CLI builds the client bundle. - If
src/web/entry-server.tsxalso exists, the CLI runs a Vite SSR build, importsentry-server.js, discovers paths, and injects rendered HTML into the client template. - If
entry-server.tsxis missing, the frontend is built as a standard single-page app.
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-->, the build completes but the rendered HTML is not injected into the page. Put the placeholder inside the element your client app hydrates.
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) throw new Error('Root element not found');
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. Routes are rendered sequentially
Full Example
A complete minimal setup with the three frontend files:
<!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) throw new Error('Root element not found');
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: Wire routes, CORS, and app-level settings