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 — no design-system dependency to fight. 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>
├── package.json
├── tsconfig.json
└── src/
├── globals.css
└── pages/
├── layout.tsx # imports globals.css, renders {children}
└── index.tsx # the landing page (hero · features · CTA)The page
src/pages/index.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/index.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'
const SignupForm = () => { /* … */ }
export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })// src/pages/index.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.
Styling
globals.css is yours. Add @import "tailwindcss" plus the mandatory @source "./**/*.{tsx,ts,jsx,js}" glob if you want Tailwind, or @import "@voltro/ui-shadcn/tokens.css" (plus the kit @source) to pull in the design tokens and compose shadcn-style components on top.
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.