The Web Stack I’d Use Today
Trying to choose a web stack right now is kind of ridiculous. There’s an endless supply of new frameworks, runtimes, databases, and deployment tools, and every one of them is supposed to be the thing that finally makes web development simple.
I’ve spent hundreds of hours trying this stuff in actual projects, so this is my current answer. Not “these exact tools will definitely win forever,” but if I were building a web app today, this is what I’d want the stack to make possible:
- No work should ever have to be repeated twice. Remote caches should be global.
- Writing a program in one language shouldn’t mean that it can only be used by programs written in that language.
- Writing a program as a REST API shouldn’t mean that I can’t also use it as a CLI, a UI-driven mobile app, an agent’s tool, or a TypeScript module that retains its types.
- Global replication should be something I don’t have to think about, up to a limit.
- The stack should optimize for iteration speed.
A lot of this isn’t accomplished by any specific technology. It’s mostly convention.
So this isn’t really about building one giant framework. It’s more about establishing some conventions that avoid decisions which tend to leave you locked in or limited in the future.
The technologies I keep ending up with are Cloudflare Workers and bindings, server functions and RPC, protobuf and codegen, and infrastructure written in the same language as the app using it.
The interesting part isn’t any one of those things. It’s what happens when they’re used together.
A function can stay a normal function. If another deployed service needs it, it can get a small Worker adapter. If the frontend needs it, it can get a server-function adapter. If another language needs it, it can use a generated client. If a person or agent needs it, the same contract can become a CLI or an MCP tool.
The code doesn’t have to be rewritten every time somebody wants to use it in a different way.
That’s the basic idea. The rest of this series is how I think the pieces fit together.
The Ideal Default Is the Edge, Not the Origin
Perhaps the one part that isn’t convention-shaped and could actually be seen as limiting is using Cloudflare Workers as a strict requirement in every app.
Although it sounds limiting, it’s only limiting in the sense that using a CDN through CloudFront is limiting.
The way most CDNs are used today is that all requests flow through a proxy, and when a response is returned by the server, the CDN lazily starts caching stuff. On future requests, the CDN returns the cached response, so that request never even hits your server and the user gets a much better response time.
Your code doesn’t even know the CDN exists.
The way I think Workers should be used is sort of like the CDN example, but a little less transparent because now the proxy can run your code.
For example, it would be pretty painful if you had ten apps and had to implement auth all over again in each one. Instead, I’d build the shared parts of auth once in a Worker and use it in a way that allows the apps themselves to not really worry about auth.
Now, assuming that was already in place, what you’d find is that overall, the more you can handle in the Worker, the more performant your app is.
The reason is obvious: the Worker is globally replicated across a large network, so it’s usually very close to the user. Naturally, you start preferring Web APIs and edge-compatible libraries instead of assuming everything runs in one Node process somewhere.
So the conclusion that follows is that the ideal default is the edge, not the origin.
This doesn’t mean literally everything belongs at the edge. Most databases, including Postgres setups with read replicas, still have a single writer or some other place where writes have to converge. Depending on where that writer is, there will always be some people who have to pay the tax of their request traveling across the globe.
Some code also needs more CPU, a filesystem, native libraries, or a long-running process. That probably belongs in a container or regional service.
The point is just that I want to start at the edge and leave when the request proves that it needs to. Starting everything at the origin and then trying to win the latency back later seems backwards.
So once people started writing more code for Workers, things that seemed different started to look pretty similar. Here’s a very small auth Worker:
interface Env {
PASSWORD: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
switch (url.pathname) {
case '/':
return new Response('Anyone can access the homepage.')
case '/admin': {
const authorization = request.headers.get('Authorization')
if (!authorization) {
return new Response('You need to log in.', { status: 401 })
}
// Validate the credentials here.
return new Response('You have private access!', {
headers: { 'Cache-Control': 'no-store' },
})
}
default:
return new Response('Not found.', { status: 404 })
}
},
} satisfies ExportedHandler<Env>
So a Worker is just a module which exports a fetch method that gets called when it receives a request.
This makes them very malleable, especially if the thing calling it is also a Worker. In that scenario, it’s like two microservices that are already inside the same cloud environment. Why even make a public request at that point?
And this same thought is probably what prompted a lot of the interesting things Cloudflare has.
If the caller is also something you own and is in the same account, having to get a URL, add it as an environment variable, put the secret somewhere, etc. etc. just seems like unnecessary effort.
But you still want all of that if the caller is your self-hosted Hetzner server, or if you want to call it from your local machine, or if somebody outside your account needs to use it.
So that’s why bindings exist.
Whereas typical JavaScript applications have process.env, which mostly contains environment variables, Workers have env, which can contain references to almost any other infrastructure you have hosted on Cloudflare.
That means your database can just be a value on env.DB, and your other Workers can be attached as well, which significantly reduces the amount of setup and configuration you have to do.
Instead of getting a string that tells you where the thing lives, you get the thing.
Microservices Sucked, Workers Are Chill
Once you have a couple of Workers and your infrastructure is available as bindings, microservices start to look good again.
But this time, a lot of the annoying parts are gone.
You get what feels like a reference to the actual service, and you no longer have to create a public URL, serialize everything for a REST API, and then undo it all just to call a function.
It’s still RPC and still a service boundary, so it can fail in ways a local function can’t. But compared with the amount of setup a normal microservice needs, it feels much closer to calling ordinary code.
This is the pattern I want to take advantage of.
If I decide some code should be its own module, that typically consists of reorganizing the directory structure a bit and maybe defining the public versus private interface.
For example:
// BEFORE: everything is in one file
function add(a: number, b: number) {
return a + b
}
function foo(props: { a: number; b: number }) {
return add(props.a, props.b)
}
Then I move it into a normal module:
// services/calc.ts
export function add(a: number, b: number) {
return a + b
}
// foo.ts
import { add } from './services/calc'
export function foo(props: { a: number; b: number }) {
return add(props.a, props.b)
}
Now assume another deployed Worker needs to call it. I can put a tiny service adapter around the same function:
// services/calc.worker.ts
import { WorkerEntrypoint } from 'cloudflare:workers'
import { add } from './calc'
export class CalcService extends WorkerEntrypoint {
add(a: number, b: number) {
return add(a, b)
}
}
The caller gets that Worker as a binding:
const result = await env.CALC.add(2, 3)
See how little effort that was?
It only took a few lines of code to turn a function into a service that can be deployed and called independently. I didn’t lose the ability to use it like normal code by doing this, and I didn’t have to introduce a public API just so another Worker could call it.
I get the best of both worlds basically for free. Obviously it isn’t literally free—it’s still a service boundary—but the part I normally have to build around it is mostly gone.
And if I later want it to be accessible over the web, I can add a small fetch adapter without changing the function again.
These are the reasons I’m willing to let Workers violate a principle I’d apply in almost any other context: avoid lock-in and prefer future-proof solutions.
The benefit is sort of worth it, and the switching cost isn’t especially high because the Worker part is its own file that can be deleted or replaced. The actual function is still normal code.
This also means I don’t have to make the “monolith or microservices?” decision at the beginning of the project.
A function can become a module. The module can become an independently deployed service. The service can get a public API when there’s actually a public caller.
The code can change shape without getting rewritten.
A REST API Shouldn’t Only Be a REST API
So now the code can also become a service with very little effort, which lets me iterate faster, but the frontend still has to do serialization and care about HTTP.
This is one reason Next.js has done so well. As the first framework a lot of people used with a practical implementation of React Server Components, it gave developers a DX that is hard to come back from once you’ve worked with it:
import { db } from './server/db'
async function Feed() {
const posts = await db.getPosts({
order: ['created_at', 'DESC'],
})
return (
<div>
<h1>Your Feed</h1>
{posts.map((post) => (
<Post
key={post.id}
id={post.id}
title={post.title}
content={post.content}
/>
))}
</div>
)
}
This looks like how we always used to do web development before React was a thing, except this happens on individual components instead of only on pages.
There’s actually a lot of complexity being abstracted away that becomes more visible if you inspect the network requests on the page. The response can contain bits of rendered UI that eventually get spliced into the page.
The issue is that an endpoint returning a partial page is mainly useful for that one use case.
If I could write my code like this but return normal structured data instead, the function would be more reusable. And that’s where server functions come in.
With a server function, you can turn server-side code into something the client can call almost as if it were local:
// shared/users.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { db } from '../server/db'
export const getUsers = createServerFn().handler(async () => {
return db.users.findAll()
})
// UserList.tsx
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { getUsers } from './shared/users.functions'
export function UserList() {
const callGetUsers = useServerFn(getUsers)
const { data: users = [] } = useQuery({
queryKey: ['users'],
queryFn: () => callGetUsers(),
})
return users.map((user) => (
<UserCard key={user.id} user={user} />
))
}
Unlike an RSC response, this returns a more typical data shape that is reusable, cache-friendly, and potentially useful to parts of the app other than the component you’re currently building.
Underneath the hood, server functions use RPC calls over plain HTTP. This showcases the flexibility of RPC and is part of the reason REST is becoming more of a compatibility feature than the default way I want to author application code.
RPC maps more cleanly to functions and therefore improves the DX.
TanStack Start’s server functions themselves are intended for the app that defines them. If I want the same underlying function to be public, I can also put it behind something like an oRPC contract, which can expose an OpenAPI-compatible REST API.
That means it can also become a CLI, since there are plenty of OpenAPI-to-CLI generators.
And if it’s a CLI, that means an agent can use it too 🙂
So the same code can be:
- A normal TypeScript module.
- A server function called by the frontend.
- A private RPC method called by another Worker.
- A REST API called by something outside the app.
- A CLI used by a person, CI job, or agent.
- An MCP tool if I want to expose a smaller agent-specific interface.
I don’t want to maintain six versions of the function. I want one function with a few small adapters around it.
Writing a Program in One Language Shouldn’t Wall It Off From Every Other Language
So now I have a framework for building very fast, composable apps that can be modularized or productized almost for free.
The same code can be distributed as a CLI, REST API, MCP server, or normal module without sacrificing the developer experience. In fact, it probably improves the DX, since this architecture is most appreciated exactly when you decide you want to use some function in another app.
But what about other languages? How could an app written in Rust or Go benefit?
Language choice is probably one of the most important decisions when creating an app, since it determines so many things down the line, including which libraries you can use, which devices can run it, and which other programs can import it.
The two most important problems I want to deal with are:
- Code in some runtimes can’t run at the edge unless you spend a bunch on replication or put it behind something that can.
- Calling across languages introduces complexity because both sides need to agree on the same data shape.
To elaborate on the second point, if I’m calling Rust from JavaScript, I need to know the shape the Rust function wants, send it as JSON, and then deserialize it on the Rust side.
Furthermore, I need to keep both definitions in sync, and any change on one side can cause drift on the other.
I want to solve this by using protocol buffers by default.
Combined with codegen, protobuf gives me typed clients on both sides. It has always been a hassle to remember to create every service and make sure they all have feature parity, but with the advent of LLMs, I’m pretty sure a lot of this can be completely automated.
The protocol should still be the source of truth. The LLM can generate the boring adapters and conformance tests, but it shouldn’t get to invent what the contract means.
Regarding running other languages at the edge to give users the best experience, this is once again possible thanks to Cloudflare, and a lot of it is pretty recent.
Workers support JavaScript, TypeScript, Python, Rust, and WebAssembly-based workloads. And for anything that needs a full Linux environment, more resources, or an existing Docker image, Cloudflare Containers are now generally available.
A Worker can get a container through a binding just like it gets other infrastructure:
import { Container, getContainer } from '@cloudflare/containers'
export class MyContainer extends Container {
defaultPort = 4000
sleepAfter = '10m'
}
export default {
async fetch(request, env) {
const { sessionId } = await request.json()
const container = getContainer(env.MY_CONTAINER, sessionId)
return container.fetch(request)
},
}
So the Worker stays at the edge and acts as the global entry point, while the Rust, Go, or whatever-else workload can run behind it.
The caller doesn’t need to know where the container lives, and the container doesn’t need to become a completely separate public integration.
By making protobuf a default instead of an add-on, and building useful edge modules around code in other runtimes, apps can become maximally performant and flexible without forcing everything into one language.
That sounds much closer to the way this should work.
Infra-as-Code
Assuming I had all of the above built and tested it by creating a new app, what I’d find is that I still have to rewrite workflows and CI/CD, deal with environment variables and connection strings during local development, and either develop against live resources or set up a local Postgres instance in order to work.
The last piece of the stack is the infrastructure-as-code tooling which solves this part.
If you’ve ever used Terraform, you’re already aware of why it’s useful.
Instead of having to learn the massive and confusing UI that is the AWS console, you write a couple of .tf files that encode things like which instance you want and in which regions. Then you run terraform apply and boom, you have all your infrastructure running that would’ve required hours of clicking buttons on AWS’s website.
Throughout the years, there has still always been this one issue with Terraform though:
If I’m provisioning a database in the same process where I’m using it, why do I still have to deal with the connection string?
To elaborate: if I launch a database through Terraform, the details I need to connect to it are now in a state file somewhere, encrypted. I still have to somehow provide them to my app.
Maybe I create an SSM parameter containing the secret, then create the proper ACLs, then update the code in my app to read it from process.env, etc. etc.
And let’s say I want to use it for tests in CI. Now I have to add the secret to GitHub Actions too.
The infrastructure tool already knows what the database is. Why am I turning that knowledge into a string and then manually rebuilding the connection on the other side?
Here’s roughly how this looks in Alchemy:
import * as Alchemy from 'alchemy'
import * as Drizzle from 'alchemy/Drizzle'
import * as Neon from 'alchemy/Neon'
import * as Effect from 'effect/Effect'
export const NeonDb = Effect.gen(function* () {
const { stage } = yield* Alchemy.Stack
const schema = yield* Drizzle.Schema('app-schema', {
schema: './src/schema.ts',
out: './migrations',
})
const project = stage.startsWith('pr-')
? yield* Neon.Project.ref('app-db', { stage: 'staging' })
: yield* Neon.Project('app-db', {
region: 'aws-us-east-1',
})
const branch = yield* Neon.Branch('app-branch', {
project,
migrationsDir: schema.out,
})
return { project, branch, schema }
})
This snippet is doing much more than just providing a database.
It’s giving me a typed ORM and migrations, creating a preview database for pull requests, and handling staging and development, all while letting the infrastructure participate in the same TypeScript program as the app.
I don’t have to copy a connection string out of one tool and then reconstruct the resource in another.
Even better, there no longer needs to be a sensitive connection string sitting in some .env file on my machine. The deployment tooling can handle the credentials and bind the resulting resource to the runtime using it.
What I want is for an app’s infrastructure to provide something like this:
import * as Effect from 'effect/Effect'
import { App } from './infra'
export const Runtime = Effect.gen(function* () {
const db = yield* App.DB
const cache = yield* App.Cache
const auth = yield* App.Auth
return { db, cache, auth }
})
The exact API is just a sketch.
The point is that the app asks for a database, cache, or auth resource, and the infrastructure code handles how it’s provisioned, how preview environments work, and how it gets connected to the runtime.
This, in my opinion, is the iterative capability at pretty much its maximum limit given the technology available today.
And this doesn’t mean every app needs to be written in TypeScript.
Bun has treated TypeScript as a first-class runtime from the beginning, and it can produce small standalone executables. So a tiny TypeScript launcher can resolve the infrastructure and then run a command written in Rust, Go, Python, or whatever else:
#!/usr/bin/env bun
import { $ } from 'bun'
import { resolveDatabase } from './infra'
const db = await resolveDatabase()
const command = process.argv.slice(2)
await $`DATABASE_URL=${db.origin} ${command}`
Now I can do:
./env.ts cargo run .
The Rust app gets the same infrastructure workflow without becoming a TypeScript app.
So now the pieces all line up:
- Workers remove unnecessary trips to the origin.
- Bindings remove the URLs, secrets, and configuration needed for services I already own to call each other.
- Server functions remove the HTTP plumbing between the frontend and backend.
- RPC and OpenAPI let the same function become a REST API, CLI, or agent tool.
- Protobuf keeps different languages in sync.
- Infrastructure written with the app removes the connection-string dance.
None of this completely eliminates the actual boundaries. It just stops making me repeat work the system already knows how to do.
That’s what I think building software should look like.
