Every post in this track so far has started from a system that already has a problem: too many reads, two guests after one room, a petabyte of video. This one starts from nothing. You have been hired as the first engineer at a company with an idea, a little funding and no code, and someone asks the question that sounds like a hiring-panel warm-up and is actually the most consequential design decision the company will make for two years:
What would you build it with — frontend and backend separate or combined — and where would you host it? And why not the alternatives?
It is a system design question, and it should be answered like one: scope, estimate, then design. The difference is which resource is scarce. Everywhere else in this track the scarce resource has been the machine — CPU, connections, bandwidth, a lock. Here it is the engineer. There is one of you. The risk that kills a company at this stage is not that the system falls over under load; it is that the product takes nine months to ship and turns out to be the wrong product. Every choice below is judged against that.
The answer this post defends is short enough to say in one breath: one language, one deployable application, one database, and managed everything else. The rest of the post is why, where each piece earns or loses its place, and — just as important — the exact symptoms that tell you it is time to add the parts the earlier posts in this track describe.
Step 1 — Scope the real requirements
An interviewer who asks this question is not looking for a list of fashionable tools. They want to see whether you ask what the company actually needs before choosing anything. These are the questions worth asking, and the answers you will usually get:
ASK TYPICAL ANSWER AT DAY ONE
what is the product? a web app; a mobile app "later"
who are the first users? hundreds, maybe a few thousand
how fast must we ship? a usable version in 6-10 weeks
who else is engineering? you, then 2-4 hires this year
regulated data? (health, payments, kids) usually no; ask anyway
AI / data-heavy core? increasingly often, yes
what does the team already know well? the most important answer here
NON-FUNCTIONAL, HONESTLY STATED
availability a few minutes down a month is survivable; data loss is not
scale small, unknown, and probably wrong if guessed
cost low, but engineer-hours cost far more than servers
change rate the schema changes weekly; the product pivots at least onceTwo lines on that list decide more than all the others. “What does the team already know well” outranks every technical argument in this post: a team fluent in Rails or Django or Spring will ship faster in it than in anything recommended here, and shipping speed is the requirement. “The schema changes weekly” is the other one, because it rules out anything that makes changing your data model expensive — which is the strongest argument against several of the popular wrong answers further down.
Step 2 — Estimate, so the scale argument ends early
The estimation post exists for exactly this moment. Somebody will say “but will it scale?” and the arithmetic is the answer, not a promise:
ASSUME an optimistic first year: 10,000 daily active users
50 requests per user per day (page loads, API calls, polling)
10,000 x 50 = 500,000 requests/day
500,000 / 86,400 s ~ 6 requests/second average
peak at 5x average ~ 30 requests/second
STORAGE 10,000 users x 5,000 rows each x 1 KB ~ 50 GB in the first year
(generous; most products are well under this)
VERDICT one application server handles this with room to spare
one Postgres instance holds this without partitioning anything
nothing in the database-scaling post is needed this yearThirty requests a second is stage two of the build-up in System Design Basics — one application, one database, maybe a second instance for safety. Saying that out loud, with the arithmetic, is a stronger answer than a diagram with a message bus and three service meshes, because it shows you know which problems you do not have.
Ten times higher is still a load balancer and a few stateless app servers. Traffic that big, if it arrives, arrives with the revenue to pay for a rebuild.
Step 3 — Sort the decisions by how hard they are to undo
Not every stack decision deserves the same amount of thought. The useful question is not “what is best” but “what does it cost to change my mind?” Spend the thinking where reversal is expensive and move quickly everywhere else:
EXPENSIVE TO REVERSE (think hard, decide once)
primary language rewrite the whole codebase to change it
primary database migrate every row, rewrite every query
data model fundamentals tenant isolation, ids, money as integers
MODERATE (decide carefully, migration is weeks not months)
monolith vs services merging services back is painful
auth provider every user's credentials live there
frontend framework a page-by-page rewrite
CHEAP TO REVERSE (pick something good and move on)
hosting provider a container or a build command is portable
email / SMS vendor one module wraps it
CSS framework, ORM local to the code that uses them
observability tooling swap the SDK, keep the habitsThis table is the most useful thing to say in an interview, because it reframes the whole question. Hosting, which people argue about the most, is near the bottom: moving a containerised application or a framework build from one host to another is a routine week of work. The language and the database are at the top, and they are what most of the rest of this post is about.
Step 4 — The stack
Here is the recommendation for a typical web product, with the reason each line is there:
| Layer | Choice | Why this, at this stage |
|---|---|---|
| Language | TypeScript, end to end | One language for the UI, the server and the scripts. Every hire can work anywhere, and the types describing an API response are the same types the page consumes. |
| Application | Next.js — frontend and backend in one | Pages, server-side rendering and API routes ship as a single deploy. One repository, one pipeline, one thing to roll back. |
| Database | Postgres, managed | Relational integrity, transactions, constraints, JSONB for the parts of the schema that have not settled, full-text search, and a job queue — one engine doing five jobs. |
| Data access | Drizzle or Prisma | Typed queries and versioned migrations. The migrations matter more than the query builder. |
| Authentication | A hosted provider (Clerk, Auth0) or Auth.js | Password reset, email verification, OAuth, MFA and session revocation are a month of work you should not do first. |
| Payments | Stripe | Buy it. The webhook-as-source-of-truth pattern in the Airbnb post applies unchanged. |
| Background work | A Postgres-backed queue (pg-boss), or a hosted one (Inngest) | No broker to run until volume demands one. |
| A transactional provider (Postmark, Resend, SES) | Deliverability is a full-time job somebody else already does. | |
| Observability | Sentry plus the host's logs | Errors with stack traces and a searchable log are enough until there is real traffic. |
Notice what is not there: no Redis, no Elasticsearch, no Kafka, no separate API service, no container orchestrator. Each of those has a post in this track explaining exactly what it is for — and the honest summary of all of them is that none is needed until a symptom appears that it cures. The last section of this post lists those symptoms.
Why TypeScript rather than the alternatives
The argument for TypeScript is not that it is the best language. It is that it is the only language that runs natively in the browser and is a first-class server language, so choosing it for the backend removes a boundary instead of adding one. With a Python or Go backend behind a React frontend you have two languages, two package ecosystems, two sets of linting rules, and an API contract that has to be kept in sync by hand or by code generation. With one language the contract is a type, and breaking it is a compile error.
When the answer is Python instead
If the product's core is AI or data — model calls with retrieval, document processing, evaluation pipelines, anything that leans on the scientific Python stack — the libraries live in Python, and fighting that costs more than a second language does. Then the recommendation becomes a React or Next.js frontend plus a FastAPI backend, still one database, still managed hosting. That is precisely the shape of the StayHub application this track quotes from, which is useful: it means the trade can be shown rather than described, further down.
The same logic says: if the team already knows Rails, Django, Laravel or Spring Boot deeply, use it. Those frameworks are batteries-included monoliths with two decades of answers to every boring problem. The principle is “one boring, productive stack the team knows”; TypeScript is just the best default when nobody has a stronger preference.
Step 5 — Combined, but with a seam
The “frontend and backend separate or combined?” question has a clear answer at this stage: combined. A separate single-page app and a separate API means two repositories or a monorepo to manage, two deploy pipelines, CORS configuration, a versioning story for the API, and duplicated validation — all cost, paid daily, for a benefit you only collect once a second client exists.
But combined does not mean tangled. The mistake that makes a monolith hard to split later is putting business logic inside route handlers and page components. Keep it in a plain module that knows nothing about HTTP or React, and the web layer becomes a thin adapter over it:
app/ Next.js: pages, layouts, route handlers (thin)
(marketing)/page.tsx
dashboard/page.tsx
api/
orders/route.ts parses the request, calls domain, shapes the response
webhooks/stripe/route.ts
domain/ the actual product. No HTTP, no React, no framework imports
orders/
create-order.ts
order-rules.ts
billing/
db/
schema.ts tables, in one place
migrations/
jobs/ background handlers, called by the queue
lib/ config, logging, the email and payment wrappersA sketch of what “thin” means in practice — illustrative, not lifted from a running application:
// domain/orders/create-order.ts — knows nothing about HTTP
export async function createOrder(db: Db, input: NewOrder): Promise<Order> {
assertValidItems(input.items);
return db.transaction(async (tx) => {
const order = await tx.insert(orders).values(toRow(input)).returning();
await enqueue(tx, "order.created", { orderId: order.id });
return order;
});
}
// app/api/orders/route.ts — parse, call, shape. Nothing else.
export async function POST(req: Request) {
const user = await requireUser(req);
const input = NewOrderSchema.parse(await req.json());
const order = await createOrder(db, { ...input, userId: user.id });
return Response.json(toOrderResponse(order), { status: 201 });
}The test for whether the seam is real: could a mobile app's API, a CLI or a background job call
createOrder without importing anything from app/? If yes, then the day a
mobile client arrives, extracting a standalone API is a matter of moving the thin layer —
days, not a rewrite. That is the whole reason to defer the split: the option stays open and you
do not pay for it until you exercise it.
Notice the enqueue(tx, ...) inside the transaction. That is the transactional
outbox from the message queues post,
and it is worth having from the first week: the order and the “send a confirmation”
job commit together or not at all, with no broker involved.
Step 6 — One database doing five jobs
Postgres is the least controversial line in the stack and the one doing the most work. At this stage it can credibly be:
- The system of record — foreign keys, transactions and constraints. The concurrency post showed a constraint making a double booking impossible rather than unlikely; that guarantee is free here and very expensive to rebuild anywhere else.
- A document store — a
jsonbcolumn for the settings blob, the integration payload, the part of the model you have not understood yet. Promote fields to real columns as they settle. - A search engine — built-in full-text search with a GIN index covers “search my own records” for a long time.
- A vector store — the
pgvectorextension, if the product does retrieval, keeps embeddings next to the rows they describe and inside the same transactions and backups. - A job queue — a table, polled with row locks.
The last one sounds like a hack and is not. StayHub's outbox is exactly that, and the claim step is a single query:
stmt = (
select(OutboxMessage)
.where(
OutboxMessage.status == OutboxStatus.PENDING,
OutboxMessage.available_at <= datetime.now(UTC),
)
.order_by(OutboxMessage.available_at)
.limit(limit)
.with_for_update(skip_locked=True)
)
return list(db.execute(stmt).scalars().all())FOR UPDATE SKIP LOCKED lets any number of workers pull from the same table without
ever taking the same row twice, and the queue gets your database's durability, backups and
transactional semantics for nothing. Libraries such as pg-boss package exactly this pattern in
TypeScript. It stops being the right answer at sustained high throughput or when many independent
consumers need the same event stream — which is when the queue-versus-log discussion in the
message queues post becomes relevant, and not before.
Every additional datastore is another thing to provision, secure, back up, monitor and keep consistent with the others. The consistency post spent most of its length on the dual-write problem that appears the moment a second datastore holds a copy of the first one's data. One database is not a compromise at this stage; it is the design that does not have that problem.
Step 7 — Where it runs
The hosting answer follows from the shape. A single Next.js application and a managed Postgres want a platform that turns a git push into a deploy and requires no infrastructure work at all:
git push
│
▼
┌──────────────────────────────────┐ preview URL per pull request
│ PLATFORM (Vercel / Render / │ ──> production on merge to main
│ Railway / Fly) │ rollback = redeploy previous build
│ │
│ CDN ── static assets, pages │
│ app ── server rendering, API │
│ cron / worker ── jobs │
└───────────────┬──────────────────┘
│ pooled connections
▼
┌──────────────────────────────────┐
│ MANAGED POSTGRES │ automated backups, point-in-time
│ (Neon / Supabase / RDS) │ restore, branch per preview env
└──────────────────────────────────┘
+ Stripe, auth provider, email provider, Sentry — all SaaS, all one SDK eachVercel plus Neon is the lowest-effort version of this for a Next.js app: deploys on push, a preview environment per pull request, and database branches that give each preview its own copy of the schema. Render, Railway or Fly are the better fit if the application is a long-running server — a FastAPI backend, a WebSocket service, a worker that runs continuously — because they run ordinary containers and processes rather than functions. Either way the monthly bill at this stage is in free tiers or tens of dollars, which is noise against a single engineer's salary.
Three things to check before committing to any platform, because these are the ones that bite later:
- Connection limits. Serverless functions open database connections per instance, and a traffic spike can open hundreds. Use the provider's pooled connection string from the start, not the direct one.
- Backups you have restored. “Automated backups” is a claim until you have restored one into a scratch database and run the app against it. Do it in the first month.
- Region. Put the application and the database in the same region. An app in one region querying a database in another pays a cross-region round trip on every query, and a page that runs twenty queries pays it twenty times.
And when AWS is the right answer
AWS, Google Cloud and Azure are not wrong; they are the usual destination. The time to move is when a concrete need appears: a compliance regime that needs controls you can document, a bill at a volume where reserved capacity matters, a service only the cloud provides, or a security review that asks for private networking. Because hosting sits in the cheap-to-reverse row of the Step 3 table, waiting costs little. A container that ran on Render runs on ECS; a database dump from Neon restores into RDS.
Starting there costs weeks: VPC, subnets, IAM, a load balancer, a container service, secrets and infrastructure-as-code, all before the first customer, none of it visible to one.
Step 8 — Build the seams for what you will add later
Deferring a component is only safe if adding it later is cheap. Two habits make that true, and StayHub shows both.
All configuration in one typed place. When Redis or a search cluster arrives,
it should arrive as one new setting, not as process.env lookups scattered through the
code. StayHub's settings module is where every external dependency is declared:
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="STAYHUB_",
extra="ignore",
)
...
database_url: str = "postgresql+psycopg://stayhub:stayhub@localhost:5433/stayhub"
...
elasticsearch_url: str = "http://localhost:9200"
...
redis_url: str = "redis://localhost:6380/0"A typo in a variable name becomes a startup failure instead of a None discovered
three layers down at request time. The TypeScript equivalent is a schema-validated
env.ts that every module imports instead of reading the environment itself.
Optional dependencies that are genuinely optional. When a cache is added, it should be added as an optimisation that the application survives without. StayHub's cache client treats a missing library and a missing server exactly like a cache miss:
try:
import redis
except ImportError:
logger.info("redis-py is not installed — the listing cache is disabled")
return NoneThat pattern — covered properly in the caching post — is what makes “add Redis later” a safe sentence. The cache arrives behind an interface that already tolerates its absence, so adding it cannot take the site down, and removing it again is a configuration change.
What the year-two system looks like — StayHub, honestly
It would be dishonest to recommend one database and one application while quoting code from an application that has neither, so here is StayHub's actual shape:
STAYHUB TODAY DAY ONE, THE SAME PRODUCT
guest frontend React + Vite (SPA) one Next.js app, or one React SPA
admin frontend React + Vite (SPA) with the admin as a route group
API FastAPI the same FastAPI, or API routes
system of record Postgres Postgres
search Elasticsearch Postgres full-text + filters
cache, limiter Redis nothing; add when a read is hot
GraphQL Hasura nothing; REST is enough
async work Postgres outbox Postgres outbox (keep this)
payments Stripe Stripe
datastores: 4 datastores: 1Every line on the left was added to demonstrate a mechanism this track teaches, and each one corresponds to a real pressure a growing product meets: search over a denormalised index once filters and ranking outgrow SQL, a cache once one listing page is read thousands of times between edits, a rate limiter once someone points a script at the login endpoint. They are the right components for the problems they solve. They are the wrong components for a company that does not yet have those problems.
The one line that is identical in both columns is worth noticing: the outbox. Reliable asynchronous work is needed from the first email you send, and building it on the database you already have costs nothing.
Why the other choices lose at this stage
None of these is a bad technology. Each is a good answer to a problem a founding engineer does not have yet, paid for with the one resource that is actually scarce.
Microservices
Splitting a product into services before it has users turns every function call into a network call that can time out, fail, retry and arrive twice; turns every cross-service change into a coordinated deploy; and replaces a database transaction with a saga. The reasons to split — independent scaling axes, teams blocking each other's releases, genuinely different runtimes — are all organisational or load-driven, and a team of one has none of them. And boundaries drawn before the domain is understood are drawn in the wrong places.
Kubernetes, or raw cloud infrastructure on day one
Kubernetes solves the problem of running many services across many machines with many teams. With one application it is a large, fast-moving system you must now operate, upgrade and secure in exchange for capabilities a platform gives you for free. Raw AWS has the same cost in a different form: the networking, identity and pipeline work described in Step 7, done before the first customer, by the only person who could have been building the product.
A NoSQL database as the primary store
MongoDB and DynamoDB are excellent at what they are for. But nearly every product's data turns
out to be relational — users belong to organisations, organisations have subscriptions,
subscriptions have invoices, permissions cross all of them — and you discover that on the
first report someone asks for. DynamoDB is the sharper case: its single-table design requires
knowing your access patterns up front, and in the first year the access patterns change every
week. Postgres's jsonb gives you schemaless storage where you want it without giving
up joins, constraints and ad-hoc queries everywhere else.
A separate SPA and API from the start
Two codebases, two deploys, CORS, API versioning, and validation written twice, for a benefit — an API usable by other clients — that only pays once those clients exist. The seam from Step 5 preserves the option at almost no cost. When the mobile app arrives, split; until then, the split is overhead.
Go or Rust for a CRUD product
Superb runtimes, slower to iterate in for what a young product is mostly made of: forms, validation, permissions, endpoints over rows. Their throughput is not the bottleneck at thirty requests a second. Reach for them later, for the one component where performance is the product.
Serverless everything
Functions for the API, queues between functions, a step-function workflow for the checkout — each piece is cheap and scales to zero, and the system as a whole is hard to run locally, hard to trace, and spread across dozens of resources. Serverless hosting of one application, as in Step 7, is fine; decomposing the application into cloud primitives is microservices again with more vendor lock-in.
GraphQL
GraphQL earns its complexity when many clients with different data needs share one backend, or when many teams contribute to one API surface. With one client written by the same person who writes the server, it adds a schema layer, a caching problem and an authorisation surface for no gain. StayHub runs Hasura alongside its REST API precisely to teach it; a founding engineer would not start there.
When to graduate — the symptoms that justify each part
The discipline that makes the minimal stack safe is being specific about what would change your mind. Every component left out above comes back the moment one of these symptoms appears, and not before:
| Symptom | What you add | Where this track covers it |
|---|---|---|
| The same expensive read dominates database load | A cache (Redis), cache-aside | Caching |
| Search needs ranking, typo tolerance, facets across millions of rows | A search index fed by the outbox | Consistency |
| Reads far outnumber writes and the primary is busy | Read replicas, then partitioning | Database scaling |
| Job volume or fan-out outgrows a polled table | A real broker or log | Message queues |
| Abuse, scraping or one customer starving the others | Rate limiting | Rate limiting |
| A mobile app or public API needs the backend | Extract the API from the seam | Step 5 above |
| Teams block each other's releases; one part scales differently | Split that part into a service | Basics |
| Compliance, cost at volume, or private networking | Move to a major cloud provider | Step 7 above |
Each addition is triggered by an observation about the running system, never by a forecast. Write the trigger down when you defer a component — “add a cache when the listing query exceeds a fifth of database time” — and the metrics settle it, not taste.
Things that break, even with the boring stack
- No staging data, no restore drill. The first serious incident at a young company is usually data, not load: a bad migration, a deleted table. Point-in-time restore only helps if somebody has practised it.
- Migrations run by hand. If production schema changes are typed into a console, environments drift within a month. Migrations run in the deploy pipeline, every time.
- Multi-tenancy bolted on later. If the product is B2B, put an organisation id on every tenant-owned table on day one. Retrofitting tenant isolation onto a live schema is one of the most expensive migrations there is.
What an interviewer will push on
- “Won't a monolith fail to scale?” — a stateless monolith scales horizontally behind a load balancer like any service. What it cannot do is scale one part independently of another, and that only matters once one part's load is different in kind, which the estimate says is not this year.
- “Why not microservices so teams can work independently?” — there is one team. Modules give independent ownership of code without the network in between; services come when release coordination is the bottleneck.
- “Isn't Vercel or Render lock-in?” — for hosting, very little: the artefact is a framework build or a container. The lock-in to avoid is in the data layer, which is why the database is plain Postgres.
- “What would make you change this plan?” — the graduation table. Naming the triggers before you are asked is the strongest signal in the whole answer.
The thread running through this post is the same one that ran through the case studies, turned around. There, the lesson was to add each part at the point it stops being optional. Here, at the very start, the lesson is the converse: every part you add before it is needed is complexity paid for in the one currency a new company cannot spare. Choose a boring, productive stack, keep the seams clean, write down what would change your mind, and spend everything else on the product.
Next, and last: the interview questions — everything in this track, condensed into answers short enough to say out loud.