Every few months a new framework promises to fix everything that annoyed us about the last one. We read about all of them, we try most of them in the lab, and we almost never use them on client work. That isn't caution for its own sake. It's because our clients have to live with the code long after we've gone.
A product we build might be maintained by the client's own team for five years. In that time the people change, the priorities change and the budget gets tighter. The stack has to survive all of it.
Our default stack
For most products we start from the same short list, and we have to argue ourselves out of it rather than into it:
- TypeScript everywhere, front to back
- React and Next.js for the web, React Native when the app needs to be native
- Node.js services with PostgreSQL underneath
- AWS or GCP, set up with Terraform from the first week
- Plain CSS variables and a small component library instead of a heavy UI kit
None of this is exciting, and that's the point. Each piece has a large community, good documentation, years of production use and a hiring pool our clients can actually recruit from.
The list changes slowly. Something joins it only after it has proved itself on internal tools and in at least one low-risk corner of a client project. Something leaves it when we notice we're working around it more than with it.
What we mean by boring
Boring doesn't mean old. It means the failure modes are known. When a Postgres query is slow, there are a hundred well-written answers explaining why. When a two-month-old database is slow, there's a GitHub issue with three thumbs up.
Before a tool goes into a client project, we look for three things:
- It has run in production at companies like the client's for at least a couple of years
- A developer who has never seen it can be productive within a week
- If it disappeared tomorrow, we could replace it without a rewrite
The best stack for a client is the one their next hire already knows.
Where we do take risks
We're not against new tools; we just keep them where they can't hurt anyone. New ideas go into the lab first, then into our internal tools, then into a small, replaceable corner of a client project. Only after that do they become a default.
AI work is the honest exception. That field moves too fast for a five-year track record, so we put every model call behind a thin interface we control. Swapping a provider becomes a config change instead of a rewrite:
// every model call goes through one small interfaceexport interface Model { complete(prompt: string, opts?: Options): Promise<string>}// swapping providers is a config change, not a rewriteexport const model: Model = process.env.LLM_PROVIDER === "local" ? localModel() : hostedModel(process.env.LLM_KEY!)How we decide
When someone on the crew wants to bring in something new, they write a one-page note: what problem it solves, what it replaces, what happens if it's abandoned and how a new developer would learn it. If the note is hard to write, the answer is usually no.
That note goes to the client too. They're paying for the decision, so they should be able to read the reasoning behind it, in plain words.
Most notes end the same way: the problem is real, but the tools we already have can solve it with a little more effort. Now and then a note makes such a good case that the new tool joins the list, and from then on it gets the same scrutiny as everything else.
The short version
Choose tools with known failure modes, keep experiments where they can't hurt, and write down why. The products we're proudest of aren't the ones with the newest stack. They're the ones still running smoothly long after the handover, looked after by people we've never met.
