Static blog
A static SSG blog (renderMode: 'static') — dynamic [slug] routes pre-rendered at build via getStaticPaths from a content source, per-post meta from loaderData, and ONE island for selective hydration. No backend.
A static, pre-rendered blog — every page is built to flat HTML by voltro build, so the browser downloads HTML and (almost) nothing else. It's the canonical CMS→static shape: a dynamic [slug] route is enumerated at build time by getStaticPaths reading a content source, each post's <title>/<description> comes from its loader data, and the one bit that moves — a reading-progress bar — is a single island() hydrated inside otherwise-inert HTML. No api, no rpc client, no SSR. Template id: frontend-static-blog.
Scaffold
voltro create-project acme --web=frontend-static-blog
voltro add-app blog --template=frontend-static-blog --to acmeWhat ships
apps/acme/web/ # dir named by the app, not the template
├── app.config.ts # type:web, theme:'system', defineEnv (public-only)
├── package.json
├── tsconfig.json
├── README.md
└── src/
├── globals.css
├── content/
│ └── posts.ts # the content source (the "CMS") — single source of truth
├── components/
│ └── ReadingProgress.island.tsx # the ONE island — island() + hydrate: 'load'
└── pages/
├── layout.tsx # imports globals.css, site header
├── index.tsx # the post LIST — interactive: 'none' (zero JS)
└── blog/
└── [slug].tsx # per-post page — getStaticPaths + loader + meta + the islandNo rpc client, no loader fetching a backend, no apis: entry. The app depends only on @voltro/web / @voltro/client / @voltro/env / @voltro/cli + React 19 — the same minimal web wiring as frontend-spa, but every page is renderMode: 'static' instead of 'spa'.
The content source — one swappable array (the "CMS")
src/content/posts.ts is a plain in-repo array: the simplest stand-in for a CMS. It is imported by the index page (the list), the [slug] page (the detail), AND getStaticPaths — so it is the single source of truth for "which posts exist". In a real app you replace the body of getStaticPaths + the page loader to read from a CMS API, a database query, or a folder of markdown; nothing else changes.
// src/content/posts.ts
export interface Post {
/** URL segment — `/blog/<slug>`. Must be unique + DNS-safe. */
readonly slug: string
readonly title: string
/** ISO date, shown + used for ordering. */
readonly date: string
/** One-line summary — feeds the list + the `<meta name="description">`. */
readonly excerpt: string
readonly readingMinutes: number
/** Body paragraphs, separated by a blank line. */
readonly body: string
}
export const posts: ReadonlyArray<Post> = [
{
slug: 'hello-static',
title: 'Why this blog ships zero JavaScript',
date: '2025-01-15',
excerpt: 'Every page is pre-rendered at build time, so the browser downloads HTML and nothing else.',
readingMinutes: 3,
body: `…`,
},
// …more posts…
]SSG dynamic routes — getStaticPaths + loader + per-post meta
A dynamic route like src/pages/blog/[slug].tsx matches infinitely many URLs. To pre-render it statically the build needs to know WHICH slugs exist — that is what getStaticPaths answers, by mapping over the content source. For each enumerated params the build runs the loader (server-side, at build time) and renders one HTML file. The meta export is a FUNCTION of the loader data, so each post gets its own correct <title> / <description> baked into the HTML — exactly what you want for SEO and social cards.
// src/pages/blog/[slug].tsx
import type { ReactNode } from 'react'
import { useLoaderData, notFound, type LoaderFn, type PageMeta } from '@voltro/web'
import { posts, type Post } from '../../content/posts'
import ReadingProgress from '../../components/ReadingProgress.island'
export const renderMode = 'static' as const
export const interactive = 'islands' as const
// Which concrete paths to pre-render. Every returned `params` becomes one
// built HTML file; un-enumerated slugs are simply not built (→ 404).
export const getStaticPaths = async (): Promise<Array<{ params: { slug: string } }>> =>
posts.map((post) => ({ params: { slug: post.slug } }))
// Runs at build time for each enumerated slug. Returning `notFound()` skips
// the artifact at build / 404s at runtime — defensive, though getStaticPaths
// only ever feeds us slugs that exist.
export const loader: LoaderFn<Post> = async ({ params }) => {
const post = posts.find((p) => p.slug === params.slug)
if (!post) return notFound(`post ${params.slug}`)
return post
}
// `meta` as a function of the loader data → correct per-post <title> in the
// pre-rendered HTML (great for SEO + social cards).
export const meta = ({ loaderData }: { loaderData: Post }): PageMeta => ({
title: `${loaderData.title} — Acme`,
description: loaderData.excerpt,
})
export default function BlogPost(): ReactNode {
const post = useLoaderData<Post>()
return (
<main>
<ReadingProgress />
<p><a href="/">← All posts</a></p>
<article>
<h1>{post.title}</h1>
<p className="muted">{post.date} · {post.readingMinutes} min read</p>
{post.body.split('\n\n').map((para, i) => (
<p key={i}>{para}</p>
))}
</article>
</main>
)
}Point getStaticPaths + loader at a CMS/DB/filesystem and the build will pre-render exactly those pages — the "static generation from a content source" pattern, with no extra config. A pure 'static' page 404s for slugs you didn't enumerate, which is exactly right for a fixed content set; if you ALSO set renderMode: 'ssr' | 'isr', voltro start renders the un-enumerated ones on demand.
The ONE island — selective hydration
The post list ships zero JavaScript, but the post page wants a thin reading-progress bar that tracks scrolling. Hydrating the whole article to get that one widget would be wasteful. Instead the page declares interactive: 'islands' and the bar is wrapped in island(): the framework renders the article once as inert HTML and attaches React ONLY to the island marker — the surrounding HTML never runs a React lifecycle.
// src/components/ReadingProgress.island.tsx
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { island } from '@voltro/web'
function ReadingProgress(): ReactNode {
const [pct, setPct] = useState(0)
useEffect(() => {
const onScroll = (): void => {
const el = document.documentElement
const max = el.scrollHeight - el.clientHeight
setPct(max > 0 ? Math.min(100, (el.scrollTop / max) * 100) : 0)
}
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
}
}, [])
return <div className="reading-progress" style={{ width: `${pct}%` }} aria-hidden="true" />
}
export default island(ReadingProgress, { name: 'ReadingProgress', hydrate: 'load' })hydrate: 'load' wakes the island immediately — it must track scrolling from the very first frame. Other strategies defer the cost: 'idle', 'visible' (on scroll-into-view, the best default below the fold), 'interaction' (on first pointer/key), 'never' (inert HTML forever).
The list page — interactive: 'none' (zero JS)
The index at / is pure content: a list of posts with no interactivity. It declares interactive: 'none', which makes the framework strip EVERY <script> it would otherwise emit — the HTML ships with no JS bundle at all. It reads the same content source the [slug] page + getStaticPaths use:
// src/pages/index.tsx
import type { ReactNode } from 'react'
import type { PageMeta } from '@voltro/web'
import { posts } from '../content/posts'
export const renderMode = 'static' as const
export const interactive = 'none' as const
export const meta: PageMeta = {
title: 'Acme — Blog',
description: 'A static, pre-rendered blog built with Voltro.',
}
// Newest first — sort a copy so the source array stays stable.
const byDateDesc = [...posts].sort((a, b) => b.date.localeCompare(a.date))
export default function Index(): ReactNode {
return (
<main>
<h1>Blog</h1>
<p className="muted">{posts.length} posts · pre-rendered at build time</p>
<ul className="post-list">
{byDateDesc.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
<p>
<span className="muted">{post.date} · {post.readingMinutes} min</span>
{' — '}
{post.excerpt}
</p>
</li>
))}
</ul>
</main>
)
}The layout uses a plain <a> (not <Link>) deliberately: the list is interactive: 'none' and the posts are 'islands', so there is no router runtime to intercept clicks — a real browser navigation is the correct, robust behaviour for a static content site.
Config — theme: 'system' + a public env var
app.config.ts is the minimal web shape: a single public env var declared with defineEnv, and theme: 'system' so the framework's inline head script sets the :root.dark class from the OS preference (or the voltro:theme cookie) BEFORE first paint — no light→dark flash. A static site has no server, so a secret here makes no sense; everything is public (browser-bundled, VOLTRO_PUBLIC_*-prefixed):
// app.config.ts
import { defineEnv, envVar } from '@voltro/env'
export const env = defineEnv({
VOLTRO_PUBLIC_SITE_NAME: envVar.string({ access: 'public', default: 'Acme' }),
})
export default {
type: 'web' as const,
name: 'AcmeBlog',
port: 5173,
theme: 'system' as const,
env,
}Build + serve
voltro build . # pre-render → dist/ (one HTML file per post)
voltro start . # serve the built dist/ locallyBecause the output is pure static files, you can push dist/ to any CDN instead of running a server. Add a post by appending to src/content/posts.ts; getStaticPaths reads the same array, so a new entry is pre-rendered on the next build — no route config.
When to use frontend-static-blog vs. the other web templates
| You want… | Pick |
|---|---|
| A content site (blog/docs/marketing) pre-rendered to flat HTML with a few interactive widgets | frontend-static-blog |
| A heavily interactive standalone tool whose state lives in the browser | frontend-spa |
| A blank React shell to bring your own brand | frontend-blank |
| A marketing landing surface | frontend-landing |
| A documentation site | frontend-docs |
Pairs well with
- Nothing on the backend — the point of this template is a self-contained static site. Add an api later (and an
apis:entry inapp.config.ts) only when a page genuinely needs live, request-time data — at which point flip that page torenderMode: 'ssr' | 'isr'. - Any api template if the blog grows a comments section or admin surface — the web wiring is identical to the other web templates.
Anti-patterns
- Reaching for
interactive: 'islands'on a page that's interactive everywhere. Islands only help when most of the page is static and a few widgets move (this template: a static article + one progress bar). A dashboard where everything is interactive wantsinteractive: 'full'— every island would be its own React root, which is slower than one full-tree hydration. See the render modes guide. - Forgetting
getStaticPathson a dynamic[slug]route. Without it the build can't know which concrete URLs to emit, so NO post page is pre-rendered. Every dynamic segment that'srenderMode: 'static'needsgetStaticPathsto enumerate its paths from the content source. - Returning the wrong shape from
meta.metahere is a function of{ loaderData }— read the post's fields offloaderData, don't hardcode a single title for every post, or every page gets the same<title>and you lose the per-post SEO win. - Shipping JS on the
interactive: 'none'list page. The framework strips the bundle on purpose — don't paper over it with a Suspense boundary or arequestIdleCallbackre-import. If a page genuinely needs interactivity, mark it'islands'(wrap the moving part inisland()) or'full'. - Using
<a>to a CMS/DB query at request time on a'static'page. A'static'page's loader runs at BUILD time only — there is no per-request render. If the data must be fresh per visit, the page isrenderMode: 'ssr'(or'isr'for cached-with-revalidation), not'static'.