LangENKO

Prerender

Static-by-default rendering — how Mandu turns your routes into HTML at build time, and the tiny lever that opts out.

since v0.22
On this page

Prerender

Prerender is the render mode Mandu assumes you want. Routes become HTML files at build time and the server does the boring work in advance, which is usually the work you wanted anyway.

This page documents what actually happens when the prerender engine runs, what the knobs do, and where the artifacts land.

The default path

Every file matching app/**/page.tsx is a candidate. If its URL pattern has no dynamic parameters, it is prerendered unconditionally — no opt-in, no config.

// app/about/page.tsx
export default function About() {
  return <article>Static. Done.</article>;
}

The engine walks the routes manifest, collects patterns where route.pattern.includes(":") is false, and renders each one by issuing a Request to the fetch handler (source: bundler/prerender.ts:69-73).

Output for /about lands at .mandu/static/about/index.html. The root route / lands at .mandu/static/index.html. The suffix is always index.html because clean URLs are non-negotiable (source: bundler/prerender.ts:166-171).

Dynamic routes and generateStaticParams

Dynamic routes — those with [param], [...param], or [[...param]] — produce a pattern that contains a colon (for example /blog/:slug). The engine looks for a named export:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await loadPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

export default function Post({ params }: { params: { slug: string } }) {
  return <article>{params.slug}</article>;
}

For each param object returned, the engine substitutes the pattern via resolvePattern() — catch-all segments preserve slashes and encode each path piece individually (source: bundler/prerender.ts:145-159).

If generateStaticParams() returns anything other than an array, the engine logs a warning and skips that route. If the export is missing, the route is simply not prerendered; it will render dynamically at request time instead.

Crawl mode — discover without listing

You can tell the engine to follow internal links in the HTML it just rendered and prerender those too:

bun run build --crawl

Mechanically: after writing an HTML file, the engine pulls every href attribute whose value starts with /, drops query strings and fragments, filters out static-asset extensions (.js, .css, .png, .woff2, and friends), and adds the remaining paths to the render queue (source: bundler/prerender.ts:174-195).

Crawl mode is idempotent — already-rendered paths skip re-render thanks to the renderedPaths set. It is most useful for catalog-style sites where an index page enumerates detail pages and you would rather not maintain two lists.

Adding explicit routes

If a path is neither listed in the manifest nor reachable by crawling (for example, a page you generate via generateStaticParams on a dynamic route you also reference elsewhere), pass it in:

// mandu.config.ts
export default {
  prerender: {
    routes: ["/sitemap.xml", "/well-known/ai-plugin.json"],
  },
};

These are merged into pathsToRender before any manifest-driven discovery (source: bundler/prerender.ts:66).

Output layout

After a successful build:

.mandu/static/
├── index.html                  ← /
├── about/index.html            ← /about
├── blog/
│   ├── index.html              ← /blog
│   └── hello-world/index.html  ← /blog/hello-world (from generateStaticParams)
└── docs/
    └── architect/
        └── prerender/index.html

Each page result carries { path, size, duration } so the build reporter can show you which page cost what. Errors are collected per path and surfaced at the end — a failed render does not abort the build, but a non-zero count is treated as a build failure by the orchestrator (source: bundler/prerender.ts:99-135).

Opting out: dynamic rendering

The one-line escape hatch:

// app/dashboard/page.tsx
export const dynamic = "force-dynamic";

Routes tagged force-dynamic are excluded from the prerender pass and served at request time by the runtime server. Use it when the page's HTML truly depends on the request (authenticated sessions, per-user data, request headers). Do not use it to avoid learning generateStaticParams.

Anti-patterns

Do not do this.

  1. Calling fetch inside the render path to reach your own API. The prerender engine runs the same fetch handler; hitting /api/posts from inside a page handler creates a cycle. Read the data source directly.
  2. Returning a promise from generateStaticParams that resolves to an object (not an array). The engine warns and skips the route — your page silently becomes request-time rendered, and you will not notice until a CDN miss at 3am.
  3. Relying on Date.now() or process.env.SOMETHING inside a prerendered page without a rebuild trigger. The HTML freezes at build time. If the value needs to be fresh, the route should be dynamic.

🤖 Agent Prompt

🤖 Agent Prompt — Prerender
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/architect/prerender to my project.

Summary of the page:
Prerender is the default render mode. Every page route without a dynamic segment is rendered to HTML at build time into .mandu/static/**/index.html. Dynamic routes participate via generateStaticParams(). Crawl mode follows internal links to discover unlisted paths.

Required invariants — must hold after your changes:
- A page route with no dynamic segment is always prerendered
- A dynamic page route is prerendered only for params returned by generateStaticParams()
- Output lives under .mandu/static/<pathname>/index.html (clean URLs)
- generateStaticParams() must return an array; non-array returns are warned and skipped

Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
   in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.

For Agents

AI hint

Prerender is the default render mode. Every page route without a dynamic segment is rendered to HTML at build time into .mandu/static/**/index.html. Dynamic routes participate via generateStaticParams(). Crawl mode follows internal links to discover unlisted paths.

Invariants
  • A page route with no dynamic segment is always prerendered
  • A dynamic page route is prerendered only for params returned by generateStaticParams()
  • Output lives under .mandu/static/<pathname>/index.html (clean URLs)
  • generateStaticParams() must return an array; non-array returns are warned and skipped
Guard scope
prerender