Contact form
Static page + a serverless email contact form — an island form POSTs to a bundled *.serverless.ts that sends mail via Resend. Page → CDN, function → scales to zero.
The headline "static frontend, serverless backend" combo: a pre-rendered page (renderMode: 'static') whose only hydrated part is an island() contact form, which POSTs JSON to a standalone functions/sendMessage.serverless.ts. That function is NOT part of a long-running api — the CLI bundles + ships it on its own, it sends mail via Resend over HttpClient, and it scales to zero. The page can go to any CDN; the function deploys SEPARATELY with voltro serverless. That split — page on the edge, backend on demand — is the whole point of the template. Template id: frontend-contact.
Scaffold
voltro create-project acme --web=frontend-contactWhat ships
apps/acme/web/ # dir named by the app, not the template
├── app.config.ts # type:web, theme:'system', defineEnv
├── package.json # scripts: dev, build, fn:dev, fn:deploy
├── tsconfig.json
├── README.md
├── functions/
│ └── sendMessage.serverless.ts # the serverless backend — defineServerless, sends mail
└── src/
├── globals.css
├── config.ts # CONTACT_ENDPOINT — where the form POSTs
├── components/
│ └── ContactForm.island.tsx # the only hydrated part (an island)
└── pages/
├── layout.tsx # imports globals.css, renders {children}
└── index.tsx # static page — renderMode:'static', interactive:'islands'The serverless function lives next to the web app but is a separate deploy artifact — it depends on @voltro/serverless (+ @effect/platform for HttpClient). The page itself depends only on @voltro/web / @voltro/client / @voltro/env / @voltro/cli + React 19.
The static page + the island form
src/pages/index.tsx is renderMode: 'static' (pre-rendered, servable from a CDN) and interactive: 'islands' — so only the <ContactForm> island hydrates; the headline + copy ship as inert HTML with no React lifecycle:
// src/pages/index.tsx
import type { ReactNode } from 'react'
import type { PageMeta } from '@voltro/web'
import ContactForm from '../components/ContactForm.island'
export const renderMode = 'static' as const
export const interactive = 'islands' as const
export const meta: PageMeta = {
title: '{{capProjectName}} — Get in touch',
description: 'Static page, serverless contact form.',
}
export default function Index(): ReactNode {
return (
<main>
<section className="hero">
<h1>{{capProjectName}}</h1>
<p className="lead">
This page is pre-rendered and ships from a CDN. The form below talks to
a serverless function that scales to zero — no always-on server in sight.
</p>
</section>
<section className="contact">
<h2>Send us a message</h2>
<ContactForm />
</section>
</main>
)
}The form is an island with hydrate: 'visible' — it's below the fold, so hydration defers until it scrolls into view. On submit it POSTs JSON to CONTACT_ENDPOINT (the serverless function), which lives on a different origin; the call works because the function's dev runner AND the edge hosts send CORS headers:
// src/components/ContactForm.island.tsx
import type { FormEvent, ReactNode } from 'react'
import { useState } from 'react'
import { island } from '@voltro/web'
import { CONTACT_ENDPOINT } from '../config'
type Status =
| { readonly kind: 'idle' }
| { readonly kind: 'sending' }
| { readonly kind: 'sent' }
| { readonly kind: 'error'; readonly message: string }
function ContactForm(): ReactNode {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [status, setStatus] = useState<Status>({ kind: 'idle' })
const onSubmit = async (e: FormEvent): Promise<void> => {
e.preventDefault()
setStatus({ kind: 'sending' })
try {
const res = await fetch(CONTACT_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string; detail?: string }
if (!res.ok) {
setStatus({ kind: 'error', message: body.detail ?? body.error ?? `HTTP ${res.status}` })
return
}
setStatus({ kind: 'sent' })
setName(''); setEmail(''); setMessage('')
} catch (err) {
setStatus({ kind: 'error', message: err instanceof Error ? err.message : 'network error' })
}
}
// ...form JSX (name / email / message inputs + a submit button)...
}
export default island(ContactForm, { name: 'ContactForm', hydrate: 'visible' })CONTACT_ENDPOINT is a plain constant in src/config.ts (not publicEnv) so the template typechecks BEFORE voltro dev generates the env types. Its default is the local serverless dev port, so the form works the moment you run fn:dev in another terminal:
// src/config.ts
export const CONTACT_ENDPOINT = 'http://localhost:8910'The serverless function — defineServerless
functions/sendMessage.serverless.ts is a standalone *.serverless.ts. It declares a Schema-validated input (a bad payload → a typed 400 before your handler runs), reads its secret from ctx.env, and sends the email by POSTing to Resend's HTTP API with the framework-provided HttpClient. No node:* import — so the SAME file runs on a Cloudflare Worker:
// functions/sendMessage.serverless.ts
import { Effect, Schema } from 'effect'
import { HttpClient, HttpClientRequest } from '@effect/platform'
import { defineServerless, ServerlessHttpError } from '@voltro/serverless'
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
export default defineServerless({
name: 'send-message',
method: 'POST',
input: Schema.Struct({
name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(120)),
email: Schema.String.pipe(Schema.pattern(EMAIL_RE)),
message: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(5000)),
}),
output: Schema.Struct({ ok: Schema.Boolean }),
runtime: { memoryMb: 128, timeoutSeconds: 10 },
handler: ({ name, email, message }, ctx) =>
Effect.gen(function* () {
const apiKey = ctx.env.RESEND_API_KEY
const to = ctx.env.CONTACT_TO ?? 'you@example.com'
const from = ctx.env.CONTACT_FROM ?? 'Contact form <onboarding@resend.dev>'
// No key wired yet → fail with a clear 503 the form can render. The
// function never pretends to send: honest in dev, honest in prod.
if (!apiKey) {
return yield* Effect.fail(
new ServerlessHttpError({
status: 503,
message: 'email-not-configured',
detail: 'Set RESEND_API_KEY (+ optionally CONTACT_TO / CONTACT_FROM) in the function env.',
}),
)
}
const http = yield* HttpClient.HttpClient
const request = HttpClientRequest.post('https://api.resend.com/emails').pipe(
HttpClientRequest.setHeader('authorization', `Bearer ${apiKey}`),
HttpClientRequest.bodyUnsafeJson({
from,
to: [to],
reply_to: email,
subject: `New message from ${name}`,
text: `${message}\n\n— ${name} <${email}>`,
}),
)
const response = yield* http.execute(request)
if (response.status >= 400) {
const detail = yield* response.text
return yield* Effect.fail(new ServerlessHttpError({ status: 502, message: 'email-send-failed', detail }))
}
return { ok: true }
}),
})ServerlessHttpError({ status, message, detail }) is how you control the HTTP status the form sees — 503 when no key is configured, 502 when Resend rejects the send. The handler Effect declares HttpClient as its only dependency; the runner provides it.
Run both halves locally (two terminals)
The page and the function run as two separate dev servers:
pnpm install
# Terminal 1 — the static site (HMR dev server)
pnpm --filter @acme/web dev # voltro dev .
# Terminal 2 — the serverless function on http://localhost:8910
pnpm --filter @acme/web fn:dev # voltro serverless dev functions/sendMessage.serverless.tsThe form POSTs to http://localhost:8910 (the CONTACT_ENDPOINT default). The cross-origin POST works because voltro serverless dev sends CORS headers — the same headers Cloudflare / Scaleway add at the edge, so dev matches prod.
Without RESEND_API_KEY the function returns a clean 503 email-not-configured and the form renders it — it never pretends to send. To send for real, pass the secret to the function's env:
RESEND_API_KEY=re_... CONTACT_TO=you@example.com \
pnpm --filter @acme/web fn:devDeploy — the split
The page and the function ship to different places — that's the headline:
# 1. The static page → a CDN
pnpm --filter @acme/web build
voltro static deploy --host cloudflare-pages --project-name web
# 2. The function → a function host (scales to zero), deployed SEPARATELY
voltro serverless deploy --target node # the package's fn:deploy script
# ...or --target cloudflare, or --target scalewayThen point the form at the deployed function: set CONTACT_ENDPOINT in src/config.ts to the URL voltro serverless deploy printed, and rebuild the page. The function host bills per invocation and idles to zero between requests — no always-on server for an inbox form that fires a few times a day.
When to use frontend-contact vs. the other web templates
| You want… | Pick |
|---|---|
| A static page + ONE serverless backend endpoint (a form, a small action) | frontend-contact |
| A pure static surface with no backend at all | frontend-blank |
| A heavily-interactive standalone tool whose state lives in the browser | frontend-spa |
| A marketing landing surface | frontend-landing |
| A documentation site | frontend-docs |
Pairs well with
- Serverless functions — the deploy + runtime model for the
*.serverless.tshalf (node / Cloudflare / Scaleway, scales to zero). - Static sites — where the pre-rendered page goes (any CDN).
- Edge functions — when you need MORE than one function, or richer routing than a single contact endpoint.
- Render modes — the
static+islandscombination the page leans on.
Anti-patterns
- Turning the function into a long-running api. A
*.serverless.tsis a standalone, scale-to-zero artifact — bundled + deployed on its own withvoltro serverless, not discovered byvoltro devas part of an api. If you find yourself wanting queries, subscriptions, or a database next to it, you want an api template, not this. - Reaching for
node:*in the function. The handler is edge-safe by design (it runs on a Cloudflare Worker). Use the framework-providedHttpClientfor outbound HTTP; anode:-prefixed import breaks the Cloudflare/Scaleway targets. - Making the function pretend to send when no key is wired. The template fails LOUD with a
503 email-not-configuredthat the form renders. Don't swap that for a silent{ ok: true }— a contact form that silently drops messages is worse than one that says it's not configured. - Letting the form go
interactive: 'full'or'none'. The page is mostly static copy with one interactive widget — that's exactlyinteractive: 'islands'+ anisland()form.'full'hydrates the whole tree for no reason;'none'would strip the JS the form needs. - Skipping the input Schema on the function. The
defineServerlessinputis what turns a malformed POST into a typed400before your handler runs. Don't hand-parseJSON.parse(body)and trust it.