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

```typescript title="agentuity.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:

```tsx title="src/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:**

```bash
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:

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

```typescript title="agentuity.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.

### `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.

```tsx title="src/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).

> [!TIP]
> **Auto-Discovery Covers Most Apps**
> 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.

```tsx title="src/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:

```tsx title="src/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:

```html title="src/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>
```

> [!WARNING]
> **Placeholder Required**
> 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:

```tsx title="src/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 />);
}
```

> [!NOTE]
> **Hydration Mismatches**
> 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.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:

```typescript title="agentuity.config.ts"
import type { AgentuityConfig } from '@agentuity/cli';

export default {
	render: 'static',
} satisfies AgentuityConfig;
```

```html title="src/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>
```

```tsx title="src/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'];
}
```

```tsx title="src/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

- [Build Configuration](/reference/cli/build-configuration): Customize the Vite build with plugins and constants
- [Deployment Scenarios](/frontend/deployment-scenarios): Deploy your frontend alongside agents or separately
- [App Configuration](/get-started/app-configuration): All configuration options including `render`