Landing

A marketing landing page — hero, features, CTA. Static-rendered with zero JS on the wire by default.

A marketing landing page. The page exports renderMode = 'static' + interactive = 'none', so voltro build pre-renders it to HTML and voltro start serves the file directly — zero framework JS on the wire. Template id: frontend-landing.

It ships plain JSX (a hero, a features list, a CTA) you replace with your own copy, bilingual out of the box (URL-prefix i18n: / + /de), plus the two asset pipelines a marketing page actually needs: a local hero image through ?image + <Image> and a self-hosted woff2 declared under fonts:. When you need a contact form or sign-up flow, switch interactive: 'islands' on the page and mark the interactive component with a *.island.tsx suffix so only that bundle ships.

Scaffold

voltro create-project acme --web=frontend-landing
# or onto an existing project
voltro add-app marketing --template=frontend-landing --to acme

What ships

apps/acme/web/                          # dir named by the app, not the template
├── app.config.ts                       # type:web, port:<allocated>, locales, fonts:
├── package.json
├── tsconfig.json
└── src/
    ├── globals.css
    ├── globals.d.ts                    # ambient '*.css' + '*?image'
    ├── assets/hero.jpg                 # imported with ?image (build-time pipeline)
    ├── fonts/Geist-Variable.woff2      # self-hosted, declared in app.config.ts
    ├── fonts/LICENSE-Geist.txt         # the face's licence, shipped beside it
    ├── lib/locale.ts                   # URL-prefix i18n helpers
    ├── locales/{en,de}.ts              # the two catalogs
    └── pages/
        ├── layout.tsx                  # imports globals.css, renders {children}
        ├── page.tsx                    # the landing page (hero · features · CTA)
        └── [locale]/page.tsx           # the /de mirror

The page

src/pages/page.tsx is plain JSX with the two static-render exports:

export const renderMode  = 'static' as const   // pre-render at build time
export const interactive = 'none'   as const   // strip ALL framework JS — plain HTML + CSS

export default function Index() {
  return (
    <main>
      <section>
        <h1>{/* your brand */}</h1>
        <p>Replace this hero copy with your value prop.</p>
        <a href="#features">Learn more →</a>
      </section>
      <section id="features">
        <h2>Features</h2>
        <ul>{/* feature bullets */}</ul>
      </section>
      <section>
        <h2>Ready to start?</h2>
        <p>Edit <code>src/pages/page.tsx</code> to make it yours.</p>
      </section>
    </main>
  )
}

interactive: 'none' means the framework strips every <script type="module"> from the rendered HTML — the page ships as content + CSS only. Best perf for a pure-content marketing page.

Adding an interactive piece

A contact form, a newsletter signup, a theme toggle — anything that needs JS — goes in an island so only that bundle hydrates:

// src/components/SignupForm.island.tsx
import { island } from '@voltro/web/islands'
const SignupForm = () => { /* … */ }
export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })
// src/pages/page.tsx
export const interactive = 'islands' as const   // was 'none'
import SignupForm from '../components/SignupForm.island'
// … render <SignupForm /> somewhere in the page …

The surrounding HTML stays static; only the island hydrates.

The hero image — ?image + <Image>

src/assets/hero.jpg is imported with the ?image suffix, the explicit opt-in to the build-time image pipeline: the import resolves to an optimized-asset object instead of vite's plain hashed URL, and <Image> renders it as a <picture> with one <source> per modern format.

import { Image } from '@voltro/web'
import hero from '../assets/hero.jpg?image'

<Image src={hero} alt="Abstract gradient artwork" priority sizes="(max-width: 900px) 100vw, 900px" />

width, height and the blur placeholder are not props — they come off the asset, which is what reserves the box (CLS ≈ 0) without hand-written numbers. priority marks it the LCP image (eager + high fetch priority). Nothing here needs JS, so it survives interactive: 'none'.

The *?image ambient type is declared once in src/globals.d.ts. A dynamic src (a URL from a loader or CMS frontmatter) cannot be seen at build time — use the loader seam (<Image src={url} loader={cdn} />) instead.

Under voltro test the image plugin is not wired (it is a dev/build transform), so a ?image specifier resolves to a plain URL string. The shipped page.test.tsx vi.mocks the import with the asset object the pipeline produces — copy that pattern rather than hand-writing width/height on the page.

The font — self-hosted, no CDN

app.config.ts declares one family under fonts:, pointing at the woff2 committed in src/fonts/. The build content-hashes it and serves it from your origin, emits @font-face, computes a size-adjusted fallback face from the file's real metrics so the swap moves no text, and puts a <link rel="preload"> in the shell.

Reference it from CSS through the --font-geist variable the shell defines (globals.css points the kit's --font-sans at it), or from TSX with localFont('Geist').

Swapping in your own face: drop the woff2 and its licence file into src/fonts/, then change family + path. The framework ships no font downloader on purpose — licence terms differ per family. The bundled Geist is SIL OFL 1.1 (src/fonts/LICENSE-Geist.txt).

Styling

globals.css imports @voltro/ui-shadcn/tokens.css (the design tokens) plus the mandatory @source "./**/*.{tsx,ts,jsx,js}" glob. Drop the kit import and use @import "tailwindcss" directly if you would rather start from nothing — but keep the @source line either way, and keep the --font-sans mapping if you keep the font declaration.

What it doesn't ship

  • A sign-in form / auth. Wire it yourself, or pair the page with an api and the framework's session helpers.
  • Live data / subscriptions. Marketing pages are static. Pair with an api template if you need a live stat.

Pairs well with

  • frontend-docs — link "Docs" from the landing's nav.
  • Any api template — pair the marketing site with a backend in the same project.

Anti-patterns

  • Hydrating the whole page when only one widget is interactive. Keep interactive: 'none' and wrap the one interactive bit in an island() (then set 'islands') — don't flip the whole page to 'full'.
  • Leaving renderMode unset. The default is 'static' already, but the explicit pair ('static' + 'none') is what makes this page ship zero JS — keep it.