Dashboard
An authenticated dashboard shell — a silent (marketing) route group for public pages, a /dashboard area gated by a nested layout loader that RedirectErrors to /login when there's no session, and scoped error/loading/not-found special files. SSR auth gate, no backend.
The app shell every SaaS frontend needs: a public marketing area and an authenticated dashboard, with the auth gate done right. Public pages live in a silent route group ((marketing)/ — organises files + scopes a layout without adding a URL segment). The /dashboard area is gated by a nested layout loader that runs once for every page beneath it, server-side, and throws RedirectError('/login') when there's no session — so an unauthenticated visitor is bounced before any dashboard HTML renders. The authed pages are renderMode: 'ssr' precisely so the gate runs per request. The gate is a cookie-only stand-in, so the template boots with no backend. Template id: frontend-dashboard.
Scaffold
voltro create-project acme --web=frontend-dashboard
voltro add-app app --template=frontend-dashboard --to acmeWhat ships
apps/acme/web/ # dir named by the app, not the template
├── app.config.ts # type:web, theme:'system', defineEnv (public-only)
├── package.json
├── tsconfig.json
├── README.md
└── src/
├── globals.css
├── globals.d.ts # `declare module '*.css'`
├── config.ts # APP_NAME brand constant
├── lib/
│ └── auth.ts # the cookie-only demo gate (readCookie + isSignedIn)
└── pages/
├── layout.tsx # thin root layout (loads the stylesheet)
├── (marketing)/ # SILENT route group → URLs have NO group segment
│ ├── layout.tsx # marketing header/footer chrome
│ ├── index.tsx # / — public landing (static)
│ └── login.tsx # /login — sets the demo session cookie
└── dashboard/ # real /dashboard prefix
├── layout.tsx # THE AUTH GATE — loader + RedirectError + shared data
├── error.tsx # scoped error boundary
├── loading.tsx # scoped opt-in skeleton
├── not-found.tsx # scoped 404
├── index.tsx # /dashboard — overview (ssr)
└── settings.tsx # /dashboard/settings — settings (ssr)The app depends only on @voltro/web / @voltro/client / @voltro/env / @voltro/cli + React 19 — no api, no rpc client.
Two route shapes — silent group vs real prefix
src/pages/(marketing)/ is a route group: the parentheses organise files and scope a layout, but the directory name never appears in a URL. So the pages inside are / and /login — not /(marketing)/.... Use a group when you want a shared layout (here: the marketing header) for a set of pages without inventing a URL segment for it.
src/pages/dashboard/ is an ordinary directory, so it DOES contribute a URL segment — /dashboard, /dashboard/settings. The contrast is the point: a group for cross-cutting layout scoping, a real dir when you want the prefix.
The auth gate — a nested layout loader
A layout.tsx can export a loader, just like a page. The framework runs it once for every page beneath that directory, in parallel with the page loader, server-side, before render. That makes the dashboard layout's loader the one right place to validate the session and preload shared data:
// src/pages/dashboard/layout.tsx
import { RedirectError, useLoaderData, type LoaderFn } from '@voltro/web'
import { isSignedIn } from '../../lib/auth'
interface DashboardData {
readonly user: { readonly name: string; readonly plan: string }
}
export const loader: LoaderFn<DashboardData> = async ({ headers }) => {
if (!isSignedIn(headers)) {
// No session → bounce to login, remembering where they were headed.
throw new RedirectError('/login?from=/dashboard')
}
// Shared data, loaded ONCE for every page beneath this layout, read via
// useLoaderData<DashboardData>() right here in the layout.
return { user: { name: 'Demo User', plan: 'Pro' } }
}
export default function DashboardLayout({ children }) {
const { user } = useLoaderData<DashboardData>()
// … sidebar + topbar showing `user`, then {children} …
}RedirectError is loader control-flow, NOT an error-boundary error: on SSR it emits a 303 + Location; on a client navigation it does navigate(location, { replace: true }) so Back doesn't bounce onto the redirecting page. (Its sibling NotFoundError renders the scoped not-found.tsx instead.)
Why the dashboard pages are renderMode: 'ssr'
The gate must run per request — it reads the request's cookie and may redirect. A static page is pre-rendered once at build time with no per-visitor render, so it can't redirect per-visitor; in fact voltro build rejects a RedirectError thrown during static pre-render. So the authed pages declare renderMode: 'ssr':
// src/pages/dashboard/index.tsx
export const renderMode = 'ssr' as const
export const meta = { title: 'Overview' }
export default function Overview() { /* … */ }The public marketing pages stay static (pre-rendered, CDN-friendly) — only the gated area pays for SSR.
The demo gate — cookie-only, zero backend
src/lib/auth.ts is a deliberate stand-in so the template boots with nothing wired. It reads a cookie from both places the gate runs — the request Cookie header on SSR, and document.cookie on a client navigation (where the loader's headers is undefined):
// src/lib/auth.ts
export const SESSION_COOKIE = 'demo_session'
export const isSignedIn = (headers: Record<string, string | undefined> | undefined): boolean => {
const cookieHeader = headers?.['cookie'] ?? (typeof document === 'undefined' ? '' : document.cookie)
return readCookie(cookieHeader, SESSION_COOKIE) !== undefined
}/login writes that cookie (a real app POSTs credentials to an api that returns a signed, HttpOnly cookie). Swap isSignedIn for your real check — verify a signed session, or call your api — and the rest of the shell is unchanged.
Scoped special files
Three files under dashboard/ apply to every page in that subtree:
error.tsx— an error boundary. A render error (or a loader rejection that ISN'T aRedirectError/NotFoundError) bubbles to the nearesterror.tsxinstead of the router default.resetre-runs the failed segment.loading.tsx— an OPT-IN skeleton. By default navigation is deferred (the previous page stays until the loader settles); addingloading.tsxopts this subtree into swapping in immediately and showing a skeleton — which suits a data-backed dashboard.not-found.tsx— a scoped 404. On a client navigation to an unmatched/dashboard/...URL the router renders this; a direct request to such a URL returns a404.
Build + serve
voltro build . # static marketing pages + an SSR bundle for /dashboard
voltro start . # serve: / + /login are static; /dashboard renders per requestvoltro start returns 303 → /login?from=/dashboard for /dashboard without a session cookie, and 200 once the cookie is set — the gate in action.
Wire a real backend
Add an api and an apis: entry in app.config.ts, then read live data in the dashboard pages with useSubscription. The gate stays the same — only its session check moves from the demo cookie to your real session. For the gate's own session verification, see the Sessions guide.
When to use frontend-dashboard vs. the other web templates
| You want… | Pick |
|---|---|
| An authenticated app shell — public pages + a gated dashboard | frontend-dashboard |
| The reactive end-to-end loop (frontend wired to an api) | frontend-app |
| Server-rendered / cached pages without an auth gate | frontend-ssr |
| A blank React shell to bring your own brand | frontend-blank |
Pairs well with
- Any api template — drop one in, add an
apis:entry, and the dashboard pages become live.api-authis the natural partner: replace the demo cookie with its real session, andisSignedInbecomes a real verification.
Anti-patterns
- Making the gated pages
static. Astaticpage renders once at build time — the loader can't redirect per visitor, and the build rejects aRedirectError. Gated pages arerenderMode: 'ssr'(or'isr'if the content is cacheable and not per-subject). - Putting the gate in each page's loader. The whole point of the layout loader is that it runs once for every page beneath it. Duplicating the session check in each page is the bug the nested layout loader exists to avoid.
- Reading only
headersin the gate.headersis populated server-side; on a client navigation it's undefined. Fall back todocument.cookie(or your client-side session source) so the gate works on SPA navigation too — otherwise a client nav into the dashboard always bounces to/login. - Trusting an unsigned cookie in production. The demo cookie is a stand-in. A real gate verifies a signed session (or calls the api) — a bare
present/absentcookie check is forgeable. See the Sessions guide. - Throwing a plain
Errorfor redirects. A plainthrow new Error()hits theerror.tsxboundary. Control-flow redirects useRedirectError/redirect(); 404s useNotFoundError/notFound().