Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/deployments/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"title": "Deployments",
"description": "Deploy a VitNode application to production with cloud or self-hosted infrastructure",
"icon": "HardDriveUpload",
"pages": ["self-hosted", "cloud"]
"pages": ["self-hosted", "cloud", "prerendering"]
}
127 changes: 127 additions & 0 deletions apps/web/content/docs/dev/deployments/prerendering.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
title: Prerender Static Pages
description: Render marketing pages once during the build and serve them as static HTML files, in every language your app speaks.
icon: FileCode
---

import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

Prerendering renders a page once, during `vitnode build`, and saves it as an HTML file the server sends as-is. Use it for pages that look the same for every visitor - a home page, a pricing page, a list of solutions. Your server stops rendering them on every request, and visitors get the page as fast as a static file can travel.

Pages that change per visitor - settings, anything behind a sign-in, a feed of new posts - stay server-rendered. You do not need to change them.

## Prerender your pages

<Steps>
<Step>

### List the pages

Create a module with the paths to prerender. Write each path once, without a locale prefix:

```ts title="src/prerender.ts"
export const PRERENDERED_PATHS = ['/', '/pricing', '/solutions']
```

A route with a parameter, such as `/solutions/$slug`, is never found on its own. List every value you want as a file: `/solutions/help-center`.

</Step>
<Step>

### Hand the list to Nitro

Pass the paths through `prerenderRoutes` from `@vitnode/core/framework/vite` and give the result to the `prerender.routes` option of `nitro()`:

```ts title="vite.config.ts"
import { prerenderRoutes, vitnode } from '@vitnode/core/framework/vite' // [!code highlight]
import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite'

import { PRERENDERED_PATHS } from './src/prerender.ts' // [!code highlight]
import { vitNodeConfig } from './src/vitnode.config.ts' // [!code highlight]

export default defineConfig({
plugins: [
vitnode({ appRoot: import.meta.dirname }),
nitro({
// [!code highlight:6]
prerender: {
routes: prerenderRoutes({
i18n: vitNodeConfig.i18n,
paths: PRERENDERED_PATHS,
}),
},
}),
// tailwindcss(), tanstackStart(), viteReact() as before
],
})
```

`prerenderRoutes` writes every path once per enabled locale, using the same URL rules as the router. With English as the default and Polish as a second locale, `/pricing` becomes `/pricing` and `/pl/pricing`, and a translated path from `i18n.routePaths` is used where one exists.

Vite loads this file before anything else is built, so keep the `.ts` extension on local imports and keep the path module free of components.

</Step>
<Step>

### Build

<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build the app">

```bash tab="bun"
bun run build
```

```bash tab="pnpm"
pnpm build
```

```bash tab="npm"
npm run build
```

</Tabs>

Nitro starts a temporary server, requests every route, and writes the HTML into `.output/public` before it packages your production server.

</Step>
</Steps>

## Check the result

Each listed route has its own file, one folder per path:

```text
.output/public/index.html
.output/public/pricing/index.html
.output/public/pl/pricing/index.html
```

Start the app with `vitnode start` and compare the headers of a prerendered page and a server-rendered one:

```bash
curl -sI http://localhost:3000/pricing
curl -sI http://localhost:3000/login
```

The prerendered page answers with an `etag` and a `last-modified` date. The server-rendered page answers with `cache-control: private, no-store` instead, because VitNode renders it for one visitor.

## What a prerendered page leaves out

A prerendered file is shared by every visitor and built without your API, so VitNode leaves out anything that comes from the database or belongs to one visitor. Your build does not need a database.

- **The user menu loads in the browser.** The main layout skips reading the session while prerendering. The header ships with a placeholder where the avatar goes, and the browser fills it in once the page loads - the same thing a server-rendered page shows for a split second.
- **The header navigation loads in the browser.** The links you edit in **AdminCP → System → Navigation** are not part of the file. The header shows a placeholder in their place, the browser asks the API for the links once the page loads, and a change shows up right away, without a rebuild.
- **The language choice is stored by the browser.** A visit to `/pl/...` normally sets the locale cookie in the server's response. A static file has no such response, so VitNode sets the same cookie from the browser when the page loads.

## Pages you should not prerender

<Callout type="warn" title="Per-domain locales cannot be prerendered">
With `i18n.domains`, one path serves a different language on every domain, and
one file cannot do that. `prerenderRoutes` throws a `PrerenderDomainsError`
instead of writing the wrong language. Keep those pages server-rendered.
</Callout>

Only list pages that look the same for everyone. A page behind a sign-in would be prerendered as a signed-out visitor sees it, and every signed-in visitor would get that copy.

A page whose own loader reads from your API - a list of the latest blog posts, for example - needs that API and its database during the build, and its content stays as it was at build time. Keep such pages server-rendered too.
2 changes: 1 addition & 1 deletion apps/web/src/docs/examples/date-picker-range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DateRangePicker } from '@vitnode/core/components/ui/date-range-picker'
import { Label } from '@vitnode/core/components/ui/label'
import React from 'react'

type Preset = 'last-30-days' | 'last-7-days' | 'this-month'
type Preset = 'last-7-days' | 'last-30-days' | 'this-month'

const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate())
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/site/prerender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SOLUTION_SLUGS, solutionPath } from './solutions/catalog.ts'

export const PRERENDERED_PATHS = [
'/',
'/plugins',
'/solutions',
...SOLUTION_SLUGS.map(solutionPath),
]
2 changes: 2 additions & 0 deletions apps/web/src/site/solutions/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const SOLUTION_CATALOG = {

export type SolutionSlug = keyof typeof SOLUTION_CATALOG

export const SOLUTION_SLUGS = Object.keys(SOLUTION_CATALOG) as SolutionSlug[]

const isSolutionSlug = (slug: string): slug is SolutionSlug =>
Object.hasOwn(SOLUTION_CATALOG, slug)

Expand Down
14 changes: 12 additions & 2 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import tailwindcss from '@tailwindcss/vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { vitnode } from '@vitnode/core/framework/vite'
import { prerenderRoutes, vitnode } from '@vitnode/core/framework/vite'
import { fumadocsMdx } from 'fumadocs-mdx/vite'
import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite'

import { PRERENDERED_PATHS } from './src/site/prerender.ts'
import { vitNodeConfig } from './src/vitnode.config.ts'

const config = defineConfig({
resolve: {
alias: [{ find: /^@\//, replacement: `${import.meta.dirname}/src/` }],
Expand All @@ -17,7 +20,14 @@ const config = defineConfig({
vitnode({ appRoot: import.meta.dirname }),
fumadocsMdx({ index: false }),
devtools(),
nitro(),
nitro({
prerender: {
routes: prerenderRoutes({
i18n: vitNodeConfig.i18n,
paths: PRERENDERED_PATHS,
}),
},
}),
tailwindcss(),
tanstackStart(),
viteReact(),
Expand Down
2 changes: 2 additions & 0 deletions packages/vitnode/src/framework/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export { vitNodeEnv } from "./env";
export { vitNodeOptimizeDeps } from "./optimize-deps";
export type { VitNodePluginRoutesOptions } from "./plugin-routes";
export { vitNodePluginRoutes } from "./plugin-routes";
export type { PrerenderRoutesOptions } from "./prerender-routes";
export { PrerenderDomainsError, prerenderRoutes } from "./prerender-routes";
export type { VitNodeSsrExternalsOptions } from "./ssr-externals";
export { vitNodeSsrExternals } from "./ssr-externals";
export type { VitNodeTailwindSourcesOptions } from "./tailwind-sources";
Expand Down
78 changes: 78 additions & 0 deletions packages/vitnode/src/framework/vite/prerender-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";

import { PrerenderDomainsError, prerenderRoutes } from "./prerender-routes";

const locales = [
{ code: "en", name: "English" },
{ code: "pl", name: "Polski" },
];

describe("prerenderRoutes", () => {
it("writes every path once per locale, the default locale unprefixed", () => {
expect(
prerenderRoutes({
i18n: { defaultLocale: "en", locales },
paths: ["/", "/plugins"],
}),
).toEqual(["/", "/plugins", "/pl", "/pl/plugins"]);
});

it("uses a locale's translated route path", () => {
expect(
prerenderRoutes({
i18n: {
defaultLocale: "en",
locales,
routePaths: { pl: { "/discover": "/odkrywaj" } },
},
paths: ["/discover"],
}),
).toEqual(["/discover", "/pl/odkrywaj"]);
});

it("prefixes the default locale too when every locale is prefixed", () => {
expect(
prerenderRoutes({
i18n: { defaultLocale: "en", localePrefix: "always", locales },
paths: ["/plugins"],
}),
).toEqual(["/en/plugins", "/pl/plugins"]);
});

it("skips a disabled locale", () => {
expect(
prerenderRoutes({
i18n: {
defaultLocale: "en",
locales: [locales[0], { ...locales[1], enabled: false }],
},
paths: ["/plugins"],
}),
).toEqual(["/plugins"]);
});

it("lists a repeated path only once", () => {
expect(
prerenderRoutes({
i18n: { defaultLocale: "en", locales: [locales[0]] },
paths: ["/plugins", "/plugins"],
}),
).toEqual(["/plugins"]);
});

it("refuses per-domain locales, which one file cannot answer", () => {
expect(() =>
prerenderRoutes({
i18n: {
defaultLocale: "en",
domains: [
{ defaultLocale: "en", origin: "https://example.com" },
{ defaultLocale: "pl", origin: "https://example.pl" },
],
locales,
},
paths: ["/"],
}),
).toThrow(PrerenderDomainsError);
});
});
34 changes: 34 additions & 0 deletions packages/vitnode/src/framework/vite/prerender-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { LocaleConfig, VitNodeI18nConfig } from "@/lib/i18n/types";

import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing";

export interface PrerenderRoutesOptions {
i18n: Pick<
VitNodeI18nConfig,
"defaultLocale" | "domains" | "localePrefix" | "routePaths"
> & { locales: LocaleConfig[] };
paths: readonly string[];
}

export class PrerenderDomainsError extends Error {
constructor() {
super(
"Prerendering writes one file per path, so it cannot answer per-domain locales. Remove `i18n.domains` or keep these pages server-rendered.",
);
this.name = "PrerenderDomainsError";
}
}

export const prerenderRoutes = ({
i18n,
paths,
}: PrerenderRoutesOptions): string[] => {
const localeRouting = localeRoutingFromConfig(i18n);
if (localeRouting.domains.length > 0) throw new PrerenderDomainsError();

const localized = localeRouting.locales.flatMap(locale =>
paths.map(path => localeRouting.localizePathname(path, locale)),
);

return [...new Set(localized)];
};
42 changes: 42 additions & 0 deletions packages/vitnode/src/lib/i18n/remembered-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";

import { localeRoutingFromConfig } from "./locale-routing";
import { urlLocaleToRemember } from "./remembered-locale";

const localeRouting = localeRoutingFromConfig({
defaultLocale: "en",
locales: [
{ code: "en", name: "English" },
{ code: "pl", name: "Polski" },
],
});

const remember = (pathname: string, cookieHeader?: string) =>
urlLocaleToRemember({
cookieHeader,
host: "vitnode.test",
localeRouting,
pathname,
});

describe("urlLocaleToRemember", () => {
it("remembers a locale the URL asks for", () => {
expect(remember("/pl/plugins")).toBe("pl");
});

it("leaves a cookie that already says so alone", () => {
expect(remember("/pl/plugins", "vitnode_locale=pl")).toBeUndefined();
});

it("replaces a cookie that says something else", () => {
expect(remember("/pl", "vitnode_locale=en")).toBe("pl");
});

it("does not treat an unprefixed URL as a choice", () => {
expect(remember("/plugins", "vitnode_locale=pl")).toBeUndefined();
});

it("ignores paths that never carry a locale", () => {
expect(remember("/admin")).toBeUndefined();
});
});
26 changes: 26 additions & 0 deletions packages/vitnode/src/lib/i18n/remembered-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { LocaleRouting } from "./locale-routing";

import { readLocaleCookie } from "./locale-cookie";

export interface UrlLocaleToRememberInput {
cookieHeader: null | string | undefined;
host: string | undefined;
localeRouting: LocaleRouting;
pathname: string;
}

export const urlLocaleToRemember = ({
cookieHeader,
host,
localeRouting,
pathname,
}: UrlLocaleToRememberInput): string | undefined => {
if (localeRouting.shouldIgnoreLocalePath(pathname)) return undefined;

const urlLocale = localeRouting.extractLocaleFromPath(pathname, { host });
if (!urlLocale || urlLocale === readLocaleCookie(cookieHeader)) {
return undefined;
}

return urlLocale;
};
Loading
Loading