Static sites and prerendering
Pages that are the same for every visitor can be rendered once, when you build, and served as plain files. You don't need a server at runtime: any static host will do. This website works this way. Every page is a prerendered HTML file on Cloudflare Pages, and only the pages with a live component load any JavaScript.
@gyral/ssr/static does the rendering. It doesn't have a renderer of its own: it sends a real
Request for each path to the same app you would use to render per
request, and writes what comes back to disk. So a static page and a
server-rendered page are the same HTML, hydration seeds included.
A static build
The app renders pages with renderPage, as on a server:
// src/counter.ts
import { define, html } from '@gyral/core';
export type Msg = { readonly _tag: 'Increment' };
export const Counter = define<{ readonly count: number }, Msg>()('my-counter', {
init: () => ({ count: 0 }),
intent: { Increment: () => ({ _tag: 'Increment' }) },
update: { Increment: (s) => ({ count: s.count + 1 }) },
view: (s, i) => html`
<output aria-live="polite">${s.count}</output>
<button type="button" data-intent=${i.Increment}>Increment</button>
`,
});// server/create-app.ts
import { Hono } from 'hono';
import { html } from '@gyral/core';
import { renderPage } from '@gyral/ssr';
import '../src/counter.js';
export interface ClientAssets {
readonly clientEntry: string;
readonly modulepreload: readonly string[];
readonly stylesheets: readonly string[];
}
export const createApp = ({ clientEntry, modulepreload, stylesheets }: ClientAssets): Hono => {
const app = new Hono();
// A page with a live component loads the client entry, and preloads what it needs...
app.get('/', () =>
renderPage({
title: 'Home',
body: html`<main>
<h1>Home</h1>
<my-counter></my-counter>
</main>`,
scripts: [clientEntry],
modulepreload,
stylesheets,
}),
);
// ...and a page without one ships no JavaScript at all, only the stylesheet.
app.get('/about/', () =>
renderPage({ title: 'About', body: html`<main><h1>About us</h1></main>`, stylesheets }),
);
return app;
};After vite build, a script renders every path into the same folder:
// scripts/prerender.ts
import { clientAssetsFromManifest, prerender } from '@gyral/ssr/static';
import { createApp } from '../server/create-app.js';
const client = await clientAssetsFromManifest('dist/.vite/manifest.json', 'src/entry-client.ts');
const pages = await prerender({
app: createApp({
clientEntry: client.entry,
modulepreload: client.modulepreload,
stylesheets: client.css,
}),
paths: ['/', '/about/'],
outDir: 'dist',
origin: 'https://example.com',
});
console.log(`prerendered ${String(pages.length)} pages`);// vite.config.ts
import { defineConfig } from 'vite';
import { gyralVitePreset } from '@gyral/core/vite';
export default defineConfig({
...gyralVitePreset(),
build: {
outDir: 'dist',
emptyOutDir: true,
manifest: true, // clientAssetsFromManifest reads it
rollupOptions: { input: 'src/entry-client.ts' },
},
});prerender({ app, paths, outDir, origin })writes/about/todist/about/index.html. A path that doesn't answer200fails the build, so an error page can't ship by accident.originis the URL the requests are made with. Set it to your real site, so absolute URLs your pages build from the request (canonical links, Open Graph tags) come out right.clientAssetsFromManifest(manifestPath, entry)reads Vite's manifest and returns the hashed URL of your client entry (entry, for example/assets/entry-client-Ab12.js) and the chunks to preload with it (modulepreload): the entry itself, its static imports and Gyral's hydration chunk.renderPage({ modulepreload })writes a<link rel="modulepreload">for each, so the browser fetches them alongside the entry instead of a round trip later. A third argument lists modules a page imports lazily, by source path (['src/routes/product.ts']); they are preloaded too, each with its static imports, and their CSS joinscss.csslists the content-hashed CSS files Vite built from the stylesheets your client entry imports (import './app.css';), each after the files of the modules it imports, so the cascade is the one you had in development. Link them withrenderPage({ stylesheets })instead of inlining your CSS into every page withstyles: the browser caches one file across pages, andstyle-src 'self'allows it with no hash.- The client entry (
src/entry-client.ts) imports your components (import './counter.js';), and each one hydrates on its own. To have each page load only the components it rendered, with no entry to write, see Server rendering. @gyral/ssr/staticreads and writes files, so it runs in Node at build time. Your pages don't need Node: the output is plain files.
Deploy dist/ to any static host. Deploying has the settings
for the common ones.
What ships no JavaScript
A page is only HTML and CSS until you give it scripts. Every component on it is still
rendered: each shadow root is written as Declarative Shadow DOM, which the browser turns into a
real shadow root while it parses, styles included. Links and forms work. Nothing hydrates,
because nothing loaded the code to do it.
That makes "should this page have JavaScript?" a per-page decision. On this website the docs pages have none, and the home page loads one small entry for its counter. To delay even that, see lazy hydration and Code-splitting.
Headers and CSP
A static host can't compute headers per request, but most read a headers file from the output:
Cloudflare Pages and Netlify read _headers. Where a server renders per request,
renderPage({ csp }) builds the policy as the page renders; here, build it ahead of time with
contentSecurityPolicy() from @gyral/ssr in the same build step and write it there. Its
style-src lists the hash of each component's Declarative Shadow DOM <style>, so it needs no
'unsafe-inline':
// scripts/headers.ts
import { writeFile } from 'node:fs/promises';
import { contentSecurityPolicy } from '@gyral/ssr';
import '../src/counter.js'; // registers the components whose styles get hashed
const csp = await contentSecurityPolicy({
directives: { 'default-src': "'self'", 'script-src': "'self'", 'object-src': "'none'" },
});
await writeFile('dist/_headers', `/*\n Content-Security-Policy: ${csp}\n`);The hashes change when a component's CSS changes, so write the file in every build, never by
hand. Linked stylesheets need no hash: style-src 'self' allows them. style="…" attributes
aren't covered: the policy blocks them in the HTML, and on a page that never hydrates nothing
applies them later (see Styling). Give the
element a class instead. This site works this way; its code blocks are coloured by classes
rather than the inline styles a highlighter writes by default.
Mixing static and per-request pages
When some pages must be rendered per request, keep the Vite manifest layout
Gyral's production server expects (dist/client/ and dist/static/)
and let productionServer serve both: prerendered files from disk, everything else through
your app. Rendering modes shows how to keep the list of
static paths next to your routes.
Limits
- Paths with parameters (
/products/:id) are prerendered only if you list them; there's no crawling. - There's no incremental regeneration: rebuild to change a static page.
- Gyral doesn't write a sitemap for you. Generate one from the same path list.