Skip to content

Commit e7a599e

Browse files
JoviDeCroockclaude
andcommitted
Add getStaticPaths API for dynamic SSG route generation
Replace prerender() with getStaticPaths() so route modules return param objects instead of full paths, decoupling them from URL patterns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent edeafc9 commit e7a599e

13 files changed

Lines changed: 84 additions & 25 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ export function ErrorBoundary({ error }: ErrorBoundaryProps) {
127127
return <p>Something went wrong: {error.message}</p>;
128128
}
129129

130-
// Build: enumerate paths for SSG/ISG prerendering (optional)
131-
export async function prerender(): Promise<string[]> {
132-
return ["/dashboard"];
130+
// Build: enumerate params for SSG/ISG prerendering (optional)
131+
export function getStaticPaths(): RouteParams[] {
132+
return [{ id: "1" }, { id: "2" }];
133133
}
134134
```
135135

docs/RENDERING_MODES.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,26 @@ route — it's served as a static file.
2828

2929
### Dynamic SSG paths
3030

31-
For routes with dynamic segments, export a `prerender` function:
31+
For routes with dynamic segments, export a `getStaticPaths` function that
32+
returns the params for each page to generate:
3233

3334
```typescript
3435
// src/routes/blog-post.tsx
35-
export async function prerender(): Promise<string[]> {
36-
const posts = await getAllPosts();
37-
return posts.map((p) => `/blog/${p.slug}`);
36+
import type { LoaderArgs, RouteParams } from "viact";
37+
38+
export function getStaticPaths(): RouteParams[] {
39+
const posts = getAllPosts();
40+
return posts.map((p) => ({ slug: p.slug }));
3841
}
3942

4043
export async function loader({ params }: LoaderArgs) {
4144
return { post: await getPost(params.slug) };
4245
}
4346
```
4447

45-
The build calls `prerender()` to enumerate all paths, then runs the loader
46-
and renderer for each. Output: `dist/client/blog/hello-world/index.html`, etc.
48+
The build calls `getStaticPaths()` to enumerate params, constructs full paths
49+
from the route pattern, then runs the loader and renderer for each.
50+
Output: `dist/client/blog/hello-world/index.html`, etc.
4751

4852
Prerendering runs concurrently (default: 6 parallel renders).
4953

e2e/node-build.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ test("viact build emits a deployable Node server entry", async () => {
6363
"Viact starts with an explicit app manifest.",
6464
);
6565

66+
// Dynamic SSG routes should be prerendered as static HTML files
67+
for (const id of ["1", "2", "3"]) {
68+
const htmlPath = resolve(exampleDir, `dist/client/products/${id}/index.html`);
69+
expect(existsSync(htmlPath)).toBe(true);
70+
71+
const productResponse = await fetch(`http://127.0.0.1:${port}/products/${id}`);
72+
expect(productResponse.status).toBe(200);
73+
const productHtml = await productResponse.text();
74+
expect(productHtml).toContain("Price:");
75+
}
76+
6677
const apiResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
6778
expect(apiResponse.status).toBe(200);
6879
await expect(apiResponse.json()).resolves.toEqual({ status: "ok" });

examples/basic/src/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export const app = defineApp({
1111
routes: [
1212
group({ shell: "public" }, [
1313
route("/", "./routes/home.tsx", { id: "home", render: "ssg" }),
14+
route("/products/:productId", "./routes/product.tsx", {
15+
id: "product",
16+
render: "ssg",
17+
}),
1418
route("/pricing", "./routes/pricing.tsx", {
1519
id: "pricing",
1620
render: "isg",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { LoaderArgs, RouteComponentProps, RouteParams } from "viact";
2+
3+
const PRODUCTS = [
4+
{ id: "1", name: "Widget", price: "$9.99" },
5+
{ id: "2", name: "Gadget", price: "$19.99" },
6+
{ id: "3", name: "Doohickey", price: "$4.99" },
7+
];
8+
9+
export function getStaticPaths(): RouteParams[] {
10+
return PRODUCTS.map((p) => ({ productId: p.id }));
11+
}
12+
13+
export function loader({ params }: LoaderArgs) {
14+
const product = PRODUCTS.find((p) => p.id === params.productId);
15+
if (!product) throw new Error("Product not found");
16+
return product;
17+
}
18+
19+
export function Component({ data }: RouteComponentProps<typeof loader>) {
20+
return (
21+
<div>
22+
<h1>{data.name}</h1>
23+
<p>Price: {data.price}</p>
24+
</div>
25+
);
26+
}

examples/docs/src/routes/docs/recipes-i18n.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,11 @@ export function LanguageSwitcher({ currentLocale }: { currentLocale: string }) {
224224

225225
## Tips
226226

227-
- For **SSG** pages, use `prerender()` to generate a page per locale:
227+
- For **SSG** pages, use `getStaticPaths()` to generate a page per locale:
228228

229229
```ts
230-
export async function prerender() {
231-
return ["/en/about", "/fr/about"];
230+
export function getStaticPaths(): RouteParams[] {
231+
return [{ locale: "en" }, { locale: "fr" }];
232232
}
233233
```
234234

examples/docs/src/routes/docs/rendering.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ HTML is generated at build time. The loader runs once during the build, and the
3131

3232
### Dynamic SSG paths
3333

34-
For routes with dynamic segments, export a `prerender` function to enumerate all paths:
34+
For routes with dynamic segments, export a `getStaticPaths` function that returns the params for each page:
3535

3636
```ts [src/routes/blog-post.tsx]
37-
export async function prerender(): Promise<string[]> {
38-
const posts = await getAllPosts();
39-
return posts.map(p => `/blog/${p.slug}`);
37+
export function getStaticPaths(): RouteParams[] {
38+
const posts = getAllPosts();
39+
return posts.map(p => ({ slug: p.slug }));
4040
}
4141

4242
export async function loader({ params }: LoaderArgs) {
@@ -48,7 +48,7 @@ export function Component({ data }) {
4848
}
4949
```
5050

51-
The build calls `prerender()` to enumerate all paths, then runs the loader and renderer for each. Prerendering runs concurrently (default: 6 parallel renders).
51+
The build calls `getStaticPaths()` to enumerate params, constructs full paths from the route pattern, then runs the loader and renderer for each. Prerendering runs concurrently (default: 6 parallel renders).
5252

5353
---
5454

packages/framework/src/app.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,17 @@ function normalizeRoutePath(path: string): string {
262262
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
263263
}
264264

265+
export function buildPathFromSegments(segments: RouteSegment[], params: RouteParams): string {
266+
const parts = segments.map((segment) => {
267+
if (segment.type === "static") return segment.value;
268+
if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
269+
// catchall
270+
return params["*"] ?? "";
271+
});
272+
273+
return normalizeRoutePath("/" + parts.join("/"));
274+
}
275+
265276
// ---------------------------------------------------------------------------
266277
// API Routes — file-based auto-discovery
267278
// ---------------------------------------------------------------------------

packages/framework/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
buildPathFromSegments,
23
defineApp,
34
group,
45
matchApiRoute,

packages/framework/src/runtime.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,17 +1183,19 @@ async function collectSSGPaths(
11831183
return [route.path];
11841184
}
11851185

1186-
// Dynamic route — must export prerender() to enumerate paths
1186+
// Dynamic route — must export getStaticPaths() to enumerate params
11871187
const routeModule = await resolveRegistryModule<RouteModule>(registry?.routeModules, route.file);
11881188

1189-
if (!routeModule?.prerender) {
1189+
if (!routeModule?.getStaticPaths) {
11901190
console.warn(
1191-
` Warning: SSG route "${route.path}" has dynamic segments but no prerender() export, skipping.`,
1191+
` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`,
11921192
);
11931193
return [];
11941194
}
11951195

1196-
return routeModule.prerender();
1196+
const { buildPathFromSegments } = await import("./app.ts");
1197+
const paramSets = await routeModule.getStaticPaths();
1198+
return paramSets.map((params) => buildPathFromSegments(route.segments, params));
11971199
}
11981200

11991201
async function readResponseBody(response: Response): Promise<unknown> {

0 commit comments

Comments
 (0)