Choosing a runtime

The stack has three places code can run, and the choice is usually obvious once the workload is named honestly. Start from the first question that applies.

The short version

WorkloadRuntime
Public content known at build timeAstro site
A service the interface talks toGo backend (the default)
A service where one language across the loop matters mostTypeScript backend (Fastify)

Astro: is it content?

If every visitor sees the same thing and the content changes when someone commits, it is a site. Marketing pages, documentation, writing, a public face for the product. Sites build to static files, deploy as nginx containers, and cost nearly nothing to run or to keep secure.

The boundary is sharp on purpose: the moment a page wants per-user state, auth, or live data, it stops being a site. Resist the temptation to sprinkle API calls into a site; that need is the signal a backend service and an interface screen exist for.

Go: is it a service?

The Go backend is the default for anything the interface talks to, and the word default is doing real work: choose it unless a specific pressure pushes to TypeScript. It became the default in September 2026, once two services had walked the path and the reference implementation itself had restarted on it. What it buys:

  • One binary, one container. Embedded migrations, the web export served from the same process if you want it, a 33 MB image, no node_modules in production, instant start.
  • The compiler as the checklist, all the way down. The generated handler interface makes a missing RPC a build failure; sqlc makes a wrong query a generate failure; protovalidate keeps input rules in the contract so handlers have nothing left to check.
  • The workload shapes Go was always for. Long-running daemons, fan-out to many devices or APIs, streaming aggregation, small hardware. These used to be the reasons to reach past the default; now they are the default’s home ground.
  • A thin backend over other systems. The reference implementation is a backend-for-frontend with near-zero own state over a photo library, an identity provider and a task board. Go’s stdlib HTTP client, its concurrency, and its footprint fit that shape exactly.

The scaffold ships this backend without being asked (--backend typescript is the flag, not the other way round) because a default that has to be requested is not one: the templates follow the module shape the reference implementation runs, so a new service starts where a proven one stands rather than where a tutorial ends.

The costs, stated plainly: a second toolchain in a repo whose interface is TypeScript, a second set of idioms to hold, and a dev database that is a container rather than an in-process library. The contract keeps both small: a generated client cannot tell the languages apart.

TypeScript: when one language wins

Choose the TypeScript backend when a specific pressure makes one language across the whole loop worth more than the Go defaults above:

  • The same person edits the store, the handler and the screen all day, and switching languages per layer is the real cost.
  • PGlite matters. A real Postgres embedded in-process, clone, install, run, no container. No other runtime matches this today.
  • An existing TypeScript backend joins the stack. Keep the language, adopt the contract, the layer discipline and the tooling.
  • Iteration speed on product CRUD beats deploy simplicity for this service, and the team knows it does.

It is a validated path in production, not a fallback: the blueprint in Backend: TypeScript is complete and the bootstrap skill scaffolds it with --backend typescript. Without the flag a new repo gets the Go backend, since October 2026.

The rule that makes the choice cheap

One proto service package, one backend. The interface never knows which language serves a call; it holds a generated client per service, and the transport is identical. This means:

  • Backend choice is per-service, made at the boundary of a bounded context (the app service, an ingest service, a reports service), never per-RPC.
  • A service can be rewritten in the other language RPC-by-RPC behind its unchanged contract, with the old and new implementations run side by side during the swap.
  • Adding a Go service to a TypeScript repo (or the reverse) leaves the contract layer alone: a new service package under proto/<slug>/, a plugin block in buf.gen.yaml, the service’s own directory. What else moves is below, under When one service becomes two.

What the stack leaves to the app

Not every shape is a stack decision. How a resource leaves the default view is the clearest case: a hard delete, a soft delete the reads filter on, an archive with a way back, or something else, each is right for some resource and wrong for others, so the stack asks the question (what happens to the resource, and to the rows that point at it?) and asks that the answer be a dedicated RPC with its rule in its comment. The Note example answers with ArchiveNote; your resources answer for themselves. The contract has the question in full.

Signals the choice was wrong

  • An Astro site is fetching from a backend at runtime: promote that page to the interface, or accept it is an app.
  • A Fastify service spends its life in Promise.all batches babysitting CPU-bound or fan-out work, or its container restarts for memory: that service is asking to be Go.
  • A Go service’s every change is a matching interface change by the same person, and the language switch is what slows them: that is the TypeScript pressure, and it is worth naming rather than enduring.
  • Anything is being built “in both languages to compare”: the contract already guarantees the swap is possible later; build it once, in the default.

When one service becomes two

Everything above assumes what the scaffold builds: one repo, one backend, one contract, one docs site. That assumption is load-bearing in more places than it looks, and it is cheaper to know which ones before a second service arrives than during.

The seam that holds. The proto service is the unit, and it was chosen for this: a second backend gets its own service package under proto/<slug>/, shares resources/v1 and common/v1, and the interface holds a second generated client over the same transport. The contract layer does not change shape. Each backend still keys its exhaustiveness check off one service type, which is the property that makes the split safe rather than merely possible.

What the second service pays. The scaffold names things in the singular because the first service has no reason to carry a scheme for a second — the same rule that keeps a type out of common/v1 until it has two consumers. So the cost lands on the service that arrives second:

  • packages/backend needs a name, which forces a naming scheme on both of them. The root package.json passthroughs (dev:backend, docker:backend:*) follow it.
  • The interface exports one client over one baseUrl. A second service is a second transport and a second URL in the environment.
  • TypeScript output stays one protocol-buffers package for the whole repo, so every client generates the entire contract surface rather than the part it calls. That is fine at two services and is the first thing to revisit if it reaches several.
  • The docs site picks up the new service without being asked: it filters on the repo’s proto package prefix, so a second service lands on the same page. That is either exactly right or the earliest signal the team has outgrown one site.

What the stack has no answer for yet. Stated as open rather than filled in, because none of it has been run:

  • One database, or one per service. The compose file runs a single Postgres, and in a mixed repo two different migration tools would point at it. Two services owning tables in one schema is a coupling the contract cannot protect. maybloom-stack-bootstrap-go asks about this at interview time and prefers separate schemas or separate databases, but that is a default, not a documented outcome.
  • How Go packages a second service. The layout in Overview is one module per repo with a cmd/ per binary. It is a reasonable default and an untested one: a module per service buys independent dependency graphs and costs a shared gen/ tree and a go.work file. Answer this by building it, not by reasoning about it.
  • When docs and sites should split by team. A per-team documentation site is already cheap — another site package reading a different package prefix — but nothing here knows when that is an improvement rather than four sites nobody reads. Ownership splitting horizontally, across services rather than across layers, is the condition to watch for.

The honest position: the two-service shape is blueprinted, not walked. Treat this section the way Open paths below treats languages — the rules hold, and the first project to go through it writes down what it actually cost.

Open paths

The runtimes above are the validated paths — documented because they have been built and run for real. They are not the boundary of the stack. The core travels further: a language with an official Connect implementation joins the contract as a first-class citizen, and a Connect server also speaks gRPC and gRPC-Web, so a language with only gRPC support reaches it too. A SwiftUI client, a Kotlin service, a Python worker — each consumes the same protos and looks, to the rest of the system, exactly like a validated implementation.

ImplementationUpstream statusWhat it would be here
Go, TypeScript/JavaScriptstable, in productionthe validated paths above
Swiftstablea SwiftUI client
Kotlinbetaan Android client, or a JVM service
Pythonbetaa worker, or an ML-adjacent service
Dartofficial, arrived through RFC 004a Flutter client — below
Rustofficial, arrived through RFC 007, pre-1.0a systems backend — below

Those are upstream’s own words as of August 2026, and the two at the bottom are the newest. Check the status before leaning on one: “beta” and “pre-1.0” are claims with dates on them.

Choosing an open path means carrying the core and writing the blueprint as you go: the contract rules from Contracts, the layer vocabulary (a thin transport seam, business logic behind it, pure translation between wire shapes and stored shapes), and generated edges that are never committed. The skills support this explicitly — on an implementation these docs don’t blueprint, they fall back to a language-neutral core reference and take the project’s existing code as the exemplar to imitate.

A path stops being open the day it has been walked far enough to document; that is how the stack grows. The Go backend went through that passage in 2026: blueprinted from study, then walked by two services whose findings rewrote the blueprint. The next path will enter the same way.

Flutter clients, in Dart

connectrpc/connect-dart is an official implementation and the pub package is connectrpc. Codegen takes the same two-plugin shape Go does — one plugin for the messages, one for the clients:

# buf.gen.yaml
plugins:
  - remote: buf.build/protocolbuffers/dart   # messages: *.pb.dart
    out: <flutter-package>/lib/gen
  - remote: buf.build/connectrpc/dart        # clients: *.connect.client.dart
    out: <flutter-package>/lib/gen

Three transports are available: Connect over HTTP/1.1 (the default), gRPC (which needs the http2 package), and gRPC-Web. On the web the client is fetch-based through dart:js_interop and carries unary and server-streaming RPCs only — a limitation that would bind only if a project adopted client streaming, which the stack has no case for.

What is not settled is where the package lives. Flutter resolves through pub, not through the pnpm workspace, so a Flutter client is the first real instance of the mixed-language packaging question raised in When one service becomes two. Nothing here knows yet whether it belongs under packages/ as a non-node sibling, at the repo root beside go/, or somewhere else.

Rust servers

connectrpc/connect-rust is an official implementation built on Tower, so it drops into Axum or Hyper. It passes the full Connect conformance suite across all three protocols and runs in production, but it is pre-1.0 and says its API may still shift. The crates are connectrpc (the runtime), protoc-gen-connect-rust (codegen), connectrpc-build (build.rs integration), and connectrpc-health / connectrpc-reflection for the standard services. It wants Rust 1.88 or newer.

One upstream default has to be overridden to stay on the stack. connect-rust recommends running buf generate and checking the output in; this stack’s second core rule is that generated code is never committed. Use connectrpc-build from a build.rs instead. It regenerates at build time, which puts Rust on the same footing as every other language here: the generated edge is a build product, and a stale checkout cannot compile against a contract that has moved.

Where the crate lives is the open question the Go module already has — one workspace with a crate per service, or a crate per service standing alone. It has the same answer: build it before deciding.