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 acmeWhat 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 mirrorThe 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 testthe image plugin is not wired (it is a dev/build transform), so a?imagespecifier resolves to a plain URL string. The shippedpage.test.tsxvi.mocks the import with the asset object the pipeline produces — copy that pattern rather than hand-writingwidth/heighton 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
apiand 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 anisland()(then set'islands') — don't flip the whole page to'full'. - Leaving
renderModeunset. The default is'static'already, but the explicit pair ('static'+'none') is what makes this page ship zero JS — keep it.