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 acme

What 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 island

No 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/ locally

Because 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 in app.config.ts) only when a page genuinely needs live, request-time data — at which point flip that page to renderMode: '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 wants interactive: 'full' — every island would be its own React root, which is slower than one full-tree hydration. See the render modes guide.
  • Forgetting getStaticPaths on 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's renderMode: 'static' needs getStaticPaths to enumerate its paths from the content source.
  • Returning the wrong shape from meta. meta here is a function of { loaderData } — read the post's fields off loaderData, 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 a requestIdleCallback re-import. If a page genuinely needs interactivity, mark it 'islands' (wrap the moving part in island()) 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 is renderMode: 'ssr' (or 'isr' for cached-with-revalidation), not 'static'.