SSR + api

Server-rendered pages fed by a sibling api — an `ssr` loader calls `query('notes.list', {})` over the api's POST /rpc so real rows are in the first paint and the <title>, then useSubscription upgrades the same data to live. Pairs with api-backend.

The SSR-from-your-backend shape: a page that server-renders real api data into the first paint. The index page is renderMode: 'ssr', and its loader runs on the server on every request, calling query('notes.list', {}) over the api's POST /rpc surface — so the rows AND the document <title> are correct in the HTML before it's sent (crawlable, no client round-trip). The component then upgrades the same data to live with useSubscription. This is the piece frontend-ssr leaves out — that template shows the ssr / isr mechanics but computes its data locally; this one wires the loader to a real sibling api. Template id: frontend-ssr-api.

Scaffold — pairs with an api

This template consumes an api exposing notes.list. Scaffold both:

voltro create-project acme --api=api-backend --web=frontend-ssr-api
voltro add-app web --template=frontend-ssr-api --to acme

What ships

apps/acme/web/                          # dir named by the app, not the template
├── app.config.ts                       # type:web, theme:'system', apis: { app }
├── package.json                        # + @acme/api (workspace:*)
├── tsconfig.json
├── README.md
└── src/
    ├── globals.css
    ├── globals.d.ts                     # `declare module '*.css'`
    └── pages/
        ├── layout.tsx
        └── index.tsx                    # renderMode:'ssr' — loader query() + useSubscription

The only addition over a standalone web template is the api dependency: package.json declares @acme/api and app.config.ts names it under apis.

Wiring the api — apis in app.config.ts

// app.config.ts
export default {
  type: 'web' as const,
  name: 'AcmeWeb',
  port: 5173,
  // The NAME ('app') is the lookup key the loader's `query` + the hooks use.
  // The `package` resolves the sibling api in this project; its port is
  // auto-discovered from its own app.config.ts, and codegen pulls its typed
  // rpc surface so `query` + `useSubscription` are end-to-end typed.
  apis: { app: { package: '@acme/api' } },
}

The SSR loader — query() over POST /rpc

query is present ONLY server-side (ssr / isr). It invokes the api's rpc directly over POST /rpc, forwarding the request's session cookie so the same Subject + tenant resolve as the WebSocket path. A streaming query is drained to its FIRST snapshot — here, the current notes for this tenant. meta reads the result, so the <title> reflects real data in the server-rendered HTML:

// src/pages/index.tsx
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
import { useSubscription } from '@voltro/client'

interface Note { readonly id: string; readonly title: string; readonly body: string; readonly done: boolean }
interface HomeData { readonly notes: ReadonlyArray<Note> }

export const renderMode = 'ssr' as const

export const loader: LoaderFn<HomeData> = async ({ query }) => ({
  // Pass the row type so the result is typed without importing the api.
  notes: query ? await query<ReadonlyArray<Note>>('notes.list', {}) : [],
})

export const meta = ({ loaderData }: { loaderData: HomeData }): PageMeta => ({
  title: `${loaderData.notes.length} notes — Acme`,
  description: 'Server-rendered from the api on every request.',
})

query?.(...) is guarded because query is undefined for client-side loader invocations — only rely on it under renderMode: 'ssr' | 'isr'.

SSR → live, one data source

The component renders the SSR'd snapshot, then useSubscription upgrades it to live once the WebSocket connects. During SSR useSubscription returns undefined, so the first paint is the loader's data; it swaps to live on the client — no flash, one data source:

export default function Home() {
  const { notes: initial } = useLoaderData<HomeData>()
  const { data: live } = useSubscription<ReadonlyArray<Note>>('app', 'notes.list')
  const notes = live ?? initial
  return <ul>{notes.map((n) => <li key={n.id}>{n.title}{n.done ? ' ✓' : ''}</li>)}</ul>
}

Build + serve

voltro build .     # codegen pulls the api's rpc types + builds an SSR bundle
voltro start .     # the loader hits the api per request; the api must be running

voltro start renders the page server-side on each request, so the api has to be up (start it alongside the web). Unlike a static page, there's no pre-rendered HTML — the point is fresh, per-request data.

SSR vs the other render modes

The page's data… Use Template
Must hit the api per request (per-subject, always fresh) renderMode: 'ssr' frontend-ssr-api (this)
Changes occasionally + can be cached renderMode: 'isr' + revalidate same loader, cached HTML
Is fully client-reactive (no SSR needed) useSubscription in a default page frontend-app
Is computed locally / needs no backend ssr with a local loader frontend-ssr

When to use frontend-ssr-api vs. the other web templates

You want… Pick
Server-rendered pages whose first paint + <title> come from real api data frontend-ssr-api
The reactive end-to-end loop with client-side rendering frontend-app
SSR / ISR mechanics without a backend frontend-ssr
An auth-gated app shell frontend-dashboard

Pairs well with

  • api-backend — the documented partner; it exposes the notes.list query this loader calls. Any api with a streaming query works — point the loader's tag at it.

Anti-patterns

  • Relying on query outside ssr / isr. It's undefined for client-side loader runs. Guard with query?.(...), and fetch live data in the component with useSubscription — not in a client-side loader.
  • Using the loader result as the live source. The loader runs once per request; for updates after first paint, subscribe in the component. The pattern is SSR'd snapshot → useSubscription upgrade, with live ?? initial.
  • Making the page static. A static page's loader runs at build time with no per-request render and no query — it can't read per-request api data. If the HTML must reflect current rows, it's ssr (or isr for cached-with-revalidation).
  • Importing the api's VALUE exports into the page. A type-only import type { Note } from '@acme/api/database' is browser-safe; importing values pulls server-only modules into the browser bundle. This template keeps a local Note interface to stay self-contained.