Static Rendering — Agentuity Documentation

Static Rendering

Pre-render your frontend to static HTML for faster page loads and better SEO

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:

typescriptagentuity.config.ts
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:

tsxsrc/web/entry-server.tsx
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 directly

Every 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:

ModeBehavior
'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
typescriptagentuity.config.ts
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.

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.

tsxsrc/web/entry-server.tsx
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).

tsxsrc/web/entry-server.tsx
// 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:

tsxsrc/web/entry-server.tsx
// 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:

  1. Client build runs normally: Vite compiles your frontend into .agentuity/client/
  2. SSR build compiles entry-server.tsx using Vite's SSR mode, resolving import.meta.glob, MDX imports, and other build-time features
  3. Route discovery: routes are extracted automatically from the exported routeTree (skipping parameterized routes like $id). If getStaticPaths() is also exported, those paths are merged in.
  4. Rendering: render(url) is called for each path
  5. HTML injection: each rendered string replaces <!--app-html--> in the client's index.html template
  6. File output: pre-rendered pages are written to .agentuity/client/[route]/index.html
  7. 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:

htmlsrc/web/index.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>

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:

tsxsrc/web/frontend.tsx
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 />);
}

SPA vs Static

SPA (default)Static
First paintBlank until JS loads and rendersInstant HTML
SEORequires JavaScript executionFull HTML in page source
Build timeFastSlower (renders each route)
Dynamic contentEverything is dynamicStatic shell + client hydration
Best forApps, dashboards, authenticated UIsDocs, marketing sites, blogs

Tips and Gotchas

  • import.meta.glob works: 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. Export getStaticPaths() 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 base index.html without pre-rendered content. Client-side routing still works
  • Each path gets its own file: /blog/hello becomes .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:

typescriptagentuity.config.ts
import type { AgentuityConfig } from '@agentuity/cli';
 
export default {
	render: 'static',
} satisfies AgentuityConfig;
htmlsrc/web/index.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>
tsxsrc/web/entry-server.tsx
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'];
}
tsxsrc/web/frontend.tsx
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