Skip to content

Stack

What an SVRBC site is built with. Hosting covers where it runs, and Sites the conventions every site follows whatever it’s built with. Decided 2026-10-07; existing sites move when someone next works on them.

Tier When Front end Back end Hosted on
1. Static (the default) Every page is the same for everyone until the next release Astro, static output none S3 and CloudFront; GitLab Pages if it’s for developers
2. Static, with an API Some data changes between releases, or something must hold a secret (a form, a payment, seats left) Astro, static output one Rust function on Lambda, at /api/* AWS: S3, CloudFront, Lambda
3. Server-rendered A page’s HTML depends on who asks (signed-in views, per-user pages) Astro, rendering on the server the site’s own endpoints and actions AWS: S3, CloudFront, Lambda

A site moves up a tier only for a reason its AGENTS.md gives. Most SVRBC sites are tier 1: a page changes when someone changes it, so it can be built once, at release, rather than on every request.

SVRBC’s code is now mostly written by AI agents. That changes what a framework is for: saving a person typing matters less than how little the framework does implicitly and how much the build checks.

  • Explicit beats convenient. Nuxt’s auto-imports, file conventions and server/client split are where an agent writes plausible code that is wrong. An Astro file says what it imports; the code at its top runs at build time, and the template below it is the output. astro check and the build catch most of the rest.
  • No JavaScript unless a part needs it. Astro ships plain HTML and CSS, and loads a script only for an island that asks for one. Nuxt hydrates the whole page, interactive or not. Short of writing the HTML by hand, nothing serves faster.
  • One framework at every tier. A site that grows from tier 1 to tier 3 changes how some of its pages are rendered, not what it’s written in, and agents work in one set of conventions across every SVRBC site.
  • No server to run. A static site has none of the Lambda traps: no writable /tmp to arrange, no server routes to keep apart from static paths, no function to deploy. Astro’s content collections do at build time what Nuxt Content does on the server.
  • Rust for APIs because the compiler is the strictest reviewer an agent has: a handler either type-checks against the real shapes of its data or doesn’t build. It also starts fastest on Lambda (tens of milliseconds cold) and needs the least memory, which is what Lambda bills.
  • output: 'static' (Astro’s default), with no adapter, at tiers 1 and 2. Only tier 3 adds one.
  • Content is Markdown in the repository, in content collections, read at build time. A change to it is a commit, and goes live with a release. Data that changes between releases comes from the site’s API, fetched by an island.
  • Native HTML before a component library. Agents write components directly, so a library mostly saves typing nobody does. <dialog>, <details>, the popover attribute and native form validation cover most of what one was for, accessibly. Pull in a library only for a widget that is hard to get right (a combobox, a date picker), inside an island.
  • Styling with Tailwind CSS, through its Vite plugin. Name colors for what they're for (bg-default, text-muted, text-primary), as tokens in the site's stylesheet that follow the visitor's light or dark setting, rather than writing palette colors through the markup.
  • Icons with unplugin-icons, compiled as Svelte components, so one import works in .astro files and islands alike: import Ticket from '~icons/lucide/ticket', then <Ticket class="size-5" />. Install only the sets used (@iconify-json/lucide, @iconify-json/simple-icons); only the icons imported reach the page. astro check reads a Svelte 5 component's type as a function of its first argument and rejects every prop, so declare the module once (doreancon.org's src/env.d.ts).
  • Fonts self-hosted with Fontsource, imported in the stylesheet, so no page asks Google for anything. Import the weights the design uses; a variable font draws heavier than the static weights a design was made with.
  • A component that takes a class merges it with its own using tailwind-merge, so the one passed in wins (justify-start over the component's justify-center). Only in .astro files, where it runs at build time: in an island, write the classes already merged, and keep it out of the browser.
  • A documentation site uses Starlight, Astro’s documentation theme.
  • astro check runs in the build step, before astro build, so a type error fails the pipeline rather than the page.

An island is a component that runs in the browser. Write islands in Svelte 5, which compiles to small bundles with no runtime framework to ship. Agents still slip into Svelte 4’s syntax (export let, $:), so a project with islands says in its AGENTS.md to use runes ($props, $state, $derived). Load an island as late as works: client:visible or client:idle before client:load.

Not everything that runs in the browser is an island. A few lines that set an attribute or a class (a menu’s toggle, a badge kept current) are a <script> in the component; an island is for something that holds state.

  • State several islands share goes in a .svelte.ts module, a class with $state fields that each island imports. Islands on one page share one copy of a module, so one object serves them all (doreancon.org’s hymn player drives the controls under every hymn, the notation and a dock).
  • $state.raw for a large object replaced whole rather than changed in place: plain $state makes a deep proxy of it.
  • Scoped CSS sees only the class names it can find in the markup: toggle them with class:name={…}, not by building the class string.
  • Svelte trims the whitespace at the edges of a block ({#if}), so a space that has to be there is written {' '}.
  • A line break between text and a tag disappears. Astro treats the whitespace the way JSX does: past at the end of a line and <BrandMark /> starting the next render as past#doreancon. Keep the text and the tag on one line, or write {' '}. svrbc.org’s test/astro-whitespace.test.ts finds both shapes of it (text, then a line starting with a tag; an inline element closed at the end of a line, then text) in every .astro file; copy it.
  • {count && <p>…</p>} renders 0 when count is 0. Write {!!count && …} or a ternary.
  • A content collection drops what its schema doesn’t name, without a word. A field added to the YAML but not the schema never reaches the page, and a site moved from Nuxt Content (which kept such fields) loses them. Test that every content file comes through its schema whole: parse it, and compare (doreancon.org’s test/contentSchemas.test.ts). Keeping the schemas in a plain module (src/schemas.ts) that content.config.ts imports lets a test reach them.
  • astro dev runs in the background (Astro 7): astro dev status, logs and stop. A second one started on the same port quietly takes the next. A package imported only by client scripts goes in vite.optimizeDeps.include, or pages fail to hydrate with “504 Outdated Optimize Dep” while Vite re-bundles it mid-load.

A page that prints (a bulletin, a booklet, a poster) has no server to set its print layout, so:

  • Build every layout into the page and switch between them with a class or an attribute, rather than re-rendering in the browser.
  • Choose one with the URL: a script in <head> reads ?print=<mode> and sets an attribute on <html> before first paint, which the print stylesheet keys on. A tool that makes PDFs (headless Chromium) opens the page with it.

A tier 2 site has one Rust function, svrbc-<domain>-api (naming), serving everything under /api/*:

  • An axum app, run by AWS’s lambda_http. The same router runs as an ordinary local server during development, so there is no emulator.
  • Built for provided.al2023 on arm64: a static binary named bootstrap. Graviton costs less per millisecond than x86.
  • Behind the site’s own CloudFront distribution, as a cache behavior for /api/* that isn’t cached, with S3 for everything else. One origin means no CORS. The function URL takes the same secret header as a tier 3 site’s.
  • The Rust types are the API’s definition. Requests deserialize (serde) into typed structs, so malformed ones are refused before a handler runs, and the front end’s TypeScript types are generated from the same structs (ts-rs) in the build, rather than written twice.
  • State in DynamoDB, on demand, named like the function (svrbc-<domain>-<what>). It costs nothing idle. Lambda’s filesystem is temporary, which rules out SQLite.
  • Secrets in SSM Parameter Store, read by the function at start, never in the repository or OpenTofu state (AWS).

Buy rather than build what a service does whole: payments with Stripe Checkout or Payment Links, mail from a form with SES. Put off building sign-in until something needs it; that is usually the step to tier 3.

This is for APIs a site calls. Tools and bots keep their own rules: the single-file Python of tools/, and the merge bot.

svrbc.org’s api/ is the worked example: Cargo builds the crate inside one Bazel step, with one lock file and nothing to keep in step with Cargo.lock (no rules_rust).

  • The toolchain is a pinned tool, a few lines of Nix (svrbc.org’s nix/rust.nix): nixpkgs’ pkgsCross.aarch64-multiplatform-musl.buildPackages.{rustc,cargo}, that target’s stdenv.cc as the linker, and the host’s stdenv.cc, which rustc links build scripts and proc macros with. All of it is in cache.nixos.org at the shared nixpkgs pin, so nothing compiles to get it; no rustup, zig or cargo lambda.
  • Link statically: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-C target-feature=+crt-static". Without it nixpkgs’ musl target links dynamically, against an interpreter in /nix/store that Lambda doesn’t have, and the function fails to start.
  • Build in a directory of the step’s own, from dereferenced copies of the sources and with a fresh CARGO_HOME, --locked, and --remap-path-prefix for that directory (its random name would otherwise be in the binary). Zip it deterministically, so an unchanged binary is an unchanged zip and the release skips the function. Tag the step requires-network: Cargo downloads the locked crates. Pass -j 4: Cargo’s default, every core, runs small machines out of memory.
  • cargo test is an sh_test. nix_tool() expands only inside a genrule’s command, so the test is given realise.sh and nix_drv("rust") as arguments and fetches the toolchain itself.
  • The types reach the front end through ts-rs, into a committed api/bindings/; a test fails when they’re stale, and the site’s build copies them in.
  • Secrets are read by name (ssm:GetParameters): the workload boundary allows no GetParametersByPath. A value the function keeps for itself (svrbc.org’s YouTube OAuth token) can live beside them, under /svrbc/<site>-api/state/, which the boundary lets a function write; DynamoDB is for more than a value or two (the applier can’t yet manage it). svrbc.org signs its SSM calls itself (SigV4, a few dozen lines) rather than linking the AWS SDK.

A cold build takes three to seven minutes; the release finds it cached. Measured 2026-10-07: a 5.4 MB static binary, a 2.7 MB zip. Built and tested; not yet run on Lambda.

When a page’s HTML depends on who asks, that page renders on the server, and the rest of the site stays static. It’s still Astro:

  • Per page, not per site. export const prerender = false on the pages that need a request; everything else is built at release as before.
  • @astrojs/node, in standalone mode, run on Lambda by AWS’s Lambda Web Adapter, a layer that lets an ordinary web server answer Lambda’s requests. Astro has no official Lambda adapter, and the community ones aren’t worth depending on; the Node adapter is Astro’s own, and the Web Adapter is AWS’s.
  • Hosted like any server-rendered site (Hosting): the function behind CloudFront, its static files (dist/client) in S3. The function-URL traps apply (the visitor’s Host, the secret header); the ones about Nuxt and Nitro don’t.
  • Its own endpoints and actions serve what its pages need, with the session that signed the visitor in. Anything more than that, or shared with another site, is still a Rust function.

A pilot: no SVRBC site runs Astro on Lambda yet, and the first tier 3 site settles the details and records them in Hosting.

Nuxt is no longer a tier. doreancon.org, the last site on it, is moving to Astro (below).

Serving several hostnames doesn’t make a site tier 3 by itself. If its tenants are known when it’s built, build each one statically, in one build:

  • One directory per host. A page under src/pages/[host]/ lists, in its getStaticPaths, the hosts it applies to, so each host gets only its own pages: dist/www/, dist/2025/, dist/travel/. Files every host shares (_astro/, images/) stay at the top, once.
  • A CloudFront function routes. It picks the directory from the hostname (/hymns on 2025.example.org is /2025/hymns/index.html), passes shared files through, and answers the redirects (the bare domain, a host that moved, old paths). The redirects and the list of hosts come from the build, as JSON an endpoint writes, so the function is the site’s code, published by each release, not infrastructure.
  • The same file routes the dev server. An Astro integration runs it as Vite middleware, and a small Node server runs it over dist/ for previews and PDF renders: 2025.localhost:4321 is the 2025 site. Browsers resolve *.localhost on their own.
  • Links between hosts are written out in full when a page is built: on the real domain in a build, on *.localhost:4321 under astro dev.
  • The function rewrites before the cache looks, so a page’s cache key is its file’s path, the Host header needn’t be in it, and a release invalidates exactly the files it changed.
  • CloudFront Functions take 10 KB of code. Routes and a few dozen redirects fit; past that, keep the redirects in a CloudFront KeyValueStore the release writes.

doreancon.org is the example: router/router.js, src/tenants.ts, src/pages/router.json.ts and the dev server’s side in its astro.config.mjs.

Site Tier Built with
developers.svrbc.org 1 Starlight
doreancon.org 1 Astro, static, one directory per host (above) (until that release: Nuxt, SSR)
svrbc.org 2 Astro, static, and a Rust API for form mail, Scripture text and the live stream’s status, staged at next.svrbc.org from its next branch (on master and at svrbc.org: Gridsome on Netlify, until master takes it)

svrbc-audit warns about a site built with something other than Astro, an Astro site that renders on a server without the Node adapter or without a reason in its AGENTS.md, and an API that isn’t Rust.

  • Astro on Lambda through the Lambda Web Adapter: the first tier 3 site pilots it.
  • How Bazel builds a Rust function: Cargo in one step, with a Nix-pinned cross toolchain (Building the function).
  • A Rust function on Lambda, end to end: svrbc.org’s first release.
  • Whether doreancon.org’s tenants can be static builds, which would make it tier 1. Yes: every tenant is known when the site is built (Several hosts, one static build).