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-contact

What 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.ts

The 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:dev

Deploy — 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 scaleway

Then 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.ts half (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 + islands combination the page leans on.

Anti-patterns

  • Turning the function into a long-running api. A *.serverless.ts is a standalone, scale-to-zero artifact — bundled + deployed on its own with voltro serverless, not discovered by voltro dev as 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-provided HttpClient for outbound HTTP; a node:-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-configured that 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 exactly interactive: 'islands' + an island() 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 defineServerless input is what turns a malformed POST into a typed 400 before your handler runs. Don't hand-parse JSON.parse(body) and trust it.