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

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:

htmlsrc/web/index.html
<div id="root"><!--app-html--></div>

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.

How Static Rendering Is Enabled

The build pipeline enables static rendering by file convention:

  • If src/web/index.html exists, the CLI builds the client bundle.
  • If src/web/entry-server.tsx also exists, the CLI runs a Vite SSR build, imports entry-server.js, discovers paths, and injects rendered HTML into the client template.
  • If entry-server.tsx is 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.

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) 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 />);
}

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. Routes are rendered sequentially

Full Example

A complete minimal setup with the three frontend files:

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) throw new Error('Root element not found');
 
if (container.innerHTML.trim()) {
	hydrateRoot(container, <App />);
} else {
	createRoot(container).render(<App />);
}

Next Steps