arrow_backBack to Blog homeHome
Full Stack Systems Architecture Node.js · React

Architecting Scalable Full Stack Systems From Scratch

System-level thinking for engineers who want to build things that last.

Not a tutorial. A mental model for how experienced engineers think when designing a full-stack system — from the first whiteboard sketch to production constraints.

calendar_todayFebruary 2026 schedule15 min read personSaptarshi Sadhu

Most developers learn full-stack development bottom-up: they pick a framework, follow a tutorial, and end up with something that works on localhost. That's fine for learning. But when you're asked to build something that will serve 100k users, survive a traffic spike at 3am, and be maintained by a team for 3 years — a different kind of thinking kicks in.

This article is about that thinking. It's not about which framework to use. It's about the decisions that determine whether your system can grow — and why those decisions are hard to undo once made.

"Architecture is the decisions you wish you could easily change later, but can't."
— paraphrased from Martin Fowler

schemaStart With the System Shape

Before writing a single line of code, draw the system. Not a UML diagram — a box-and-arrow sketch of data flows. This forces you to answer three questions early:

  1. Who are the actors? (users, admins, external services, background jobs)
  2. What data moves where? (request/response, events, file uploads, webhooks)
  3. Where are the trust boundaries? (what's public, what's authenticated, what's internal-only)

A typical web application resolves into a standard 3-layer shape. Every layer has a well-defined contract with its neighbours — and the key discipline is never violating those boundaries.

web
Presentation Layer
React · Next.js · Mobile · Public API consumers
dns
Application Layer
Express / NestJS · Business logic · Auth · Queues
storage
Data Layer
PostgreSQL · Redis · S3 · Search index
info
The core discipline The presentation layer must never talk directly to the data layer. All data access flows through the application layer. This sounds obvious; it's routinely violated in production codebases.

web_assetFrontend: The Rendering & State Contract

The frontend's job is to render state and communicate user intent. That's it. When frontend code starts containing business logic, authorization checks, or data transformation — the system becomes fragile because logic is now split across two unreliable surfaces (the client can be manipulated).

State Architecture

Before reaching for Redux or Zustand, answer: where does this state live, and who owns it?

  • Server state — data that lives in the database (users, posts, orders). Use React Query or SWR. Do not copy server state into a global store.
  • UI state — modal open/closed, selected tab, form draft. Local component state is almost always the right choice.
  • Shared client state — authenticated user session, theme, cart. A lightweight store (Zustand, Jotai) or React Context is adequate.

Folder Structure That Scales

Project Structure · React / Next.js
src/
├── app/           # Next.js 13+ app router — pages only
├── components/
│   ├── ui/        # Primitive: Button, Input, Modal
│   └── features/  # Domain: PostCard, UserAvatar, AuthForm
├── hooks/         # useAuth, usePosts, useDebounce
├── lib/           # API client, date utils, constants
├── stores/        # Zustand slices — only truly shared state
└── types/         # Shared TypeScript interfaces

The rule: feature components own their data fetching. A PostCard shouldn't receive raw API data as props — it should know how to call usePost(id) itself. This makes components portable and keeps prop drilling shallow.

dnsBackend: API Layer Design

REST Resource Modeling

A well-designed REST API is self-documenting. URLs are nouns, HTTP methods are verbs, status codes are outcomes. The most common mistakes are using verbs in URLs (/getUser), returning 200 OK for errors, or stuffing unrelated resources under one endpoint.

REST Design · Conventions
# Resources are plural nouns
GET    /api/v1/posts          → list posts (paginated)
GET    /api/v1/posts/:id      → single post
POST   /api/v1/posts          → create post
PATCH  /api/v1/posts/:id      → partial update
DELETE /api/v1/posts/:id      → delete

# Nested resources for tight ownership
GET    /api/v1/posts/:id/comments   → post's comments

# Actions that don't map to CRUD — use sub-resources
POST   /api/v1/posts/:id/publish
POST   /api/v1/auth/refresh-token

Layered Backend Architecture

The anti-pattern is fat route handlers: a 200-line function that validates input, queries the database, formats the response, sends an email, and logs to analytics — all in one block. When that breaks (and it will), debugging is a nightmare.

Instead, split each request into distinct responsibilities:

Node.js · Layered Architecture
src/
├── routes/          # HTTP verbs + URL → controller
│   └── posts.ts
├── controllers/     # Parse request, call service, return response
│   └── PostController.ts
├── services/        # Business logic — no HTTP, no DB calls
│   └── PostService.ts
├── repositories/    # All DB queries live here
│   └── PostRepository.ts
├── middleware/      # auth, rate-limit, error handling
├── validators/      # Zod / Joi schemas for request bodies
└── types/           # Shared DTOs and domain types
check_circle
The key rule Services must be framework-agnostic. If PostService.createPost() imports anything from Express, it's leaking concerns. Services should be testable with a plain function call.

lockAuthentication: JWT vs Sessions

Both work. The choice depends on your deployment model, not on which is "better". Understanding the tradeoffs means you won't paint yourself into a corner.

tokenJWT (Stateless)
  • ✓ No server-side session store
  • ✓ Works across microservices
  • ✓ Easy horizontal scaling
  • ✗ Can't revoke before expiry
  • ✗ Payload is readable (base64)
cookieSessions (Stateful)
  • ✓ Instant invalidation on logout
  • ✓ Smaller cookie payload
  • ✓ Server controls session lifetime
  • ✗ Requires session store (Redis)
  • ✗ Sticky sessions or shared store

Practical JWT Pattern

If using JWTs, always use a short-lived access token (15 min) paired with a long-lived refresh token stored in an HttpOnly cookie. Never store JWTs in localStorage — it's accessible to XSS. The refresh token flow:

Auth Flow · JWT + Refresh Token
# Login
POST /auth/login
→ access_token (15m, in response body)
→ refresh_token (7d, HttpOnly cookie, Secure, SameSite=Strict)

# Authenticated request
Authorization: Bearer {access_token}

# Token expired → silent refresh
POST /auth/refresh
Cookie: refresh_token=...
→ new access_token

# Logout → server invalidates refresh token in DB
POST /auth/logout → 204
warning
Common mistake Signing JWTs with a static secret in your .env file that's the same across environments. Use asymmetric keys (RS256): the private key signs, the public key verifies. Microservices can verify tokens without the private key.

storageDatabase Design: SQL vs NoSQL Is a False Choice

The question isn't "should I use SQL or NoSQL?" — it's "what are the access patterns of this data, and which storage engine is optimised for them?" Most production systems use both.

  • PostgreSQL — relational data with complex joins (users, orders, finances). ACID guarantees matter here.
  • Redis — ephemeral, hot-path data: session store, rate limit counters, leaderboards, pub/sub.
  • MongoDB — document data with variable schema (CMS content, event logs, config blobs).
  • S3-compatible storage — large binary objects. Never store files in a relational DB.

Schema Design Principles

Normalize for write integrity, denormalize for read performance. These are opposing forces — and the decision of when to trade one for the other is a key architectural judgment call.

Always design for the access patterns you know about now, while leaving room for the indexes you'll need later. Adding an index is cheap; changing a column type across a table with 50M rows is a multi-hour migration.

SQL · Scalable Schema Pattern
-- Soft deletes over hard deletes (preserve audit trail)
ALTER TABLE posts ADD COLUMN deleted_at TIMESTAMPTZ;

-- Index on commonly filtered columns
CREATE INDEX idx_posts_user_created
  ON posts (user_id, created_at DESC)
  WHERE deleted_at IS NULL;

-- UUID as primary key for distributed safety
id UUID PRIMARY KEY DEFAULT gen_random_uuid();

-- Audit columns on every table
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW();

trending_upScalability: What It Actually Means

"Scalable" is one of the most overloaded words in software. In practice, it means: how does the system behave under 10× load? There are two levers — scale up (bigger machine) and scale out (more machines). Building for scale-out from the start has a cost: it rules out certain patterns.

Stateless Services

A service is horizontally scalable only if it holds no local state between requests. No in-memory caches that aren't shared, no file system writes that aren't replicated. Every piece of state that needs to survive a restart must live in an external store (DB, Redis, S3).

The N+1 Query Problem Is a Scalability Killer

When fetching a list of 50 posts and then making a separate DB query for each post's author, you've made 51 queries. At scale, this is catastrophic. Solutions: eager loading with JOINs, DataLoader batching (for GraphQL), or a read-through cache on the repository layer.

Background Jobs for Everything Slow

If an API request triggers something that takes more than ~200ms (send email, resize image, call a slow third-party API, run ML inference), move it to a background queue. The user gets an immediate 202 Accepted; the work happens asynchronously. This is one of the highest-leverage architectural decisions you can make early.

info
Queue stack that works BullMQ (Node.js) + Redis is production-proven and operationally simple. For heavier workloads: RabbitMQ or Kafka. The interface to your queue should be a well-typed service — callers should never construct raw job payloads inline.

constructionReal-World Constraints Architecture Ignores

Textbook architecture discusses systems as if they're built by one person who has unlimited time. In practice:

  • Team size determines module boundaries. A 3-person team can be loose about service contracts; a 20-person team cannot. Enforce interfaces early — they become team contracts.
  • Deploy early, deploy often. An architecture that requires a 2-week integration testing phase before deploy is not scalable in the engineering sense. CI/CD is an architectural decision, not a DevOps one.
  • Observability is not optional. Structured logging, distributed tracing (OpenTelemetry), and uptime metrics should be in from day one. Debugging a production issue with no observability is a multi-day ordeal.
  • Migration strategy matters more than initial schema. Your first schema is almost always wrong. Budget for migrations. Use a migration tool (Flyway, Prisma Migrate) from the first commit.
"The biggest source of technical debt is not bad code — it's good code in the wrong place."

flagThe Mindset Shift

Junior engineers ask "how do I build this feature?" Senior engineers ask "where does this feature live, what does it touch, and what breaks if it changes?" Architecture is the practice of answering the second question before writing any code for the first.

The patterns here — layered services, stateless backends, typed contracts, external state, background queues — aren't rules. They're accumulated answers to the question: what decisions are hardest to change later? Make those well, and the rest is surprisingly flexible.

Enjoyed this article? ☕
If this helped you or saved you some time, consider supporting my work.
Support my work

emoji_objects Key Takeaways

Saptarshi Sadhu
Saptarshi Sadhu
System-focused developer at the intersection of AI, backend engineering, and scalable infrastructure. Builds things that have to work in the real world.
← Previous Urban Air Quality Prediction Next → CI/CD, Docker & Zero-Downtime Deployments