Let’s approach React, TypeScript and Node as one complete application stack rather than a collection of disconnected frontend and backend topics.
When I mentor a .NET developer moving into modern JavaScript engineering, I start from what they already understand: typed contracts, APIs, SQL, application boundaries, authentication, testing and production operations. React and Node use different tools, but the engineering questions remain familiar.
We will work through TypeScript, modern JavaScript, React, hooks, Context, Redux, routing, testing, Node.js, Express, PostgreSQL, Prisma, authentication, Docker, delivery and observability. The aim is to understand how the pieces cooperate in a real business application.
The mental model is this:
TypeScript gives JavaScript discipline.
React builds the browser application.
Redux/Context manage shared state.
React Router controls navigation.
Node runs JavaScript on the server.
Express structures HTTP APIs.
PostgreSQL stores durable data.
Prisma maps TypeScript code to database access.
JWT/auth middleware protects the system.
Docker packages the app.
CI/CD ships it.
Observability tells you what is happening in production.
That is the full-stack chain.
For this article, let’s use a real application example: Loan Management Supermarket.
Imagine a platform where brokers submit loan applications, admins review them, users log in, loan products are listed, applications are created, comments are added, documents are uploaded, and dashboards show pipeline status.
That is not a toy app. That is the kind of application where TypeScript, React, Node, PostgreSQL and Prisma begin to make proper sense.
1. TypeScript: JavaScript with engineering discipline
JavaScript is flexible. That is both its power and its danger.
In JavaScript, this is allowed:
let amount = 100000;
amount = "one hundred thousand";
The language does not stop you.
In a tiny script, maybe that is fine. In a loan management platform, that is dangerous. Amounts, statuses, user roles, application IDs and permissions must be clear.
TypeScript adds static typing before your code reaches runtime.
type LoanStatus =
| "Draft"
| "Submitted"
| "UnderReview"
| "Approved"
| "Rejected";
interface LoanApplication {
id: string;
applicantName: string;
requestedAmount: number;
status: LoanStatus;
submittedAt: string;
}
Now the compiler protects you.
This is wrong:
const loan: LoanApplication = {
id: "LN-1001",
applicantName: "Sarah Khan",
requestedAmount: "250000", // error: string is not number
status: "Pending", // error: not a valid LoanStatus
submittedAt: "2026-07-27"
};
I treat TypeScript as a contract system, not merely extra typing.
It documents intent. It catches mistakes early. It improves refactoring. It helps the IDE. It prevents accidental object shape problems.
The whole stack benefits from static typing, interfaces, classes, generics, utility types, unions, intersections and strict configuration through tsconfig.json.
A good full-stack TypeScript developer should understand this difference:
let value: any = getApiResponse();
value.doesNotExist(); // compiler does not care
Versus:
let value: unknown = getApiResponse();
if (typeof value === "object" && value !== null) {
// Now we can safely narrow it
}
any says: “Compiler, go away.”
unknown says: “Compiler, I do not know yet, force me to prove it.”
My rule of thumb:
In serious TypeScript, avoid any unless you are at a boundary and you have a clear reason.
2. ES6+: the JavaScript you must know before React
React is not magic. React is modern JavaScript plus a rendering model.
Before React, you must understand destructuring, spread syntax, modules, promises, async/await, array functions and closures.
Example:
const loan = {
id: "LN-1001",
applicantName: "Sarah Khan",
requestedAmount: 250000,
status: "Submitted"
};
// Destructuring
const { applicantName, requestedAmount } = loan;
// Spread syntax: create a new object, don't mutate the old one
const updatedLoan = {
...loan,
status: "UnderReview"
};
This matters in React because state should be treated immutably.
Bad:
loan.status = "Approved"; // direct mutation
Better:
const approvedLoan = {
...loan,
status: "Approved"
};
Array functions are also everywhere:
const submittedLoans = loans.filter(
loan => loan.status === "Submitted"
);
const loanCards = loans.map(loan => ({
id: loan.id,
title: `${loan.applicantName} - ${loan.requestedAmount}`,
status: loan.status
}));
const totalRequested = loans.reduce(
(total, loan) => total + loan.requestedAmount,
0
);
A senior developer does not just know the syntax. He knows why it matters.
map transforms.
filter removes.
reduce aggregates.
some answers “does at least one match?”
every answers “do all match?”
Async/await is another core idea:
async function loadLoan(id: string): Promise<LoanApplication> {
const response = await fetch(`/api/v1/loans/${id}`);
if (!response.ok) {
throw new Error("Failed to load loan");
}
return response.json();
}
The browser sends the request. JavaScript does not block the entire application while waiting. The promise resolves later.
That model appears everywhere: API calls, route loaders, form submissions, token refresh, test mocks and backend database operations.
My rule of thumb:
React becomes much easier when ES6+ JavaScript is not shaky.
3. SPA principles: what React is actually building
A traditional server-rendered app works like this:
User clicks link
↓
Browser asks server for new page
↓
Server builds HTML
↓
Browser replaces page
A single-page application works differently:
Browser loads app shell once
↓
React controls UI
↓
Route changes happen client-side
↓
Data is fetched from APIs
↓
Components re-render when state changes
That is why React is often paired with APIs.
The backend might be ASP.NET Core or Node with Express. Either way, React remains the client application and should communicate through explicit API contracts.
A React SPA usually contains:
src/
api/
components/
features/
routes/
store/
types/
For our loan app:
src/
api/
loansApi.ts
authApi.ts
client.ts
features/
auth/
loans/
dashboard/
components/
Button.tsx
FormField.tsx
StatusBadge.tsx
routes/
AppRoutes.tsx
ProtectedRoute.tsx
store/
store.ts
types/
loan.ts
auth.ts
I prefer to organise substantial applications around business features rather than scattering one feature across generic file-type folders.
Loans belong together. Auth belongs together. Dashboard belongs together.
That keeps the code understandable as the application grows.
4. React components: the UI as small reusable functions
A React component is a function that returns UI.
type LoanStatusBadgeProps = {
status: LoanStatus;
};
export function LoanStatusBadge({ status }: LoanStatusBadgeProps) {
return <span className={`badge badge-${status}`}>{status}</span>;
}
This is small, but important.
The component receives props. Props are input. It returns JSX. JSX describes UI.
A larger component:
type LoanCardProps = {
loan: LoanApplication;
onSelect: (loanId: string) => void;
};
export function LoanCard({ loan, onSelect }: LoanCardProps) {
return (
<article className="loan-card">
<h3>{loan.applicantName}</h3>
<p>
Requested amount: ${loan.requestedAmount.toLocaleString()}
</p>
<LoanStatusBadge status={loan.status} />
<button onClick={() => onSelect(loan.id)}>
View details
</button>
</article>
);
}
Look at what this component does well.
It does not fetch data. It does not know routing. It does not know authentication. It does not know Redux. It receives data and exposes an event.
That is a clean component.
Bad component design is when one component does everything:
function LoanPage() {
// fetch data
// manage auth
// manage form
// format currency
// handle routing
// show table
// open modal
// update global state
// handle permissions
}
That becomes hard to test and hard to change.
My rule of thumb:
A component should either orchestrate a feature or render a reusable piece of UI. Be careful when it tries to do both.
5. Hooks: state, effects and reusable behaviour
React hooks let function components use React features such as state, effects and shared logic.
Local state:
import { useState } from "react";
export function LoanSearchBox() {
const [searchText, setSearchText] = useState("");
return (
<input
value={searchText}
onChange={event => setSearchText(event.target.value)}
placeholder="Search loans..."
/>
);
}
useState stores component-level state. When state changes, React re-renders the component.
But state is not just data. State is UI truth.
For example:
type LoanListState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; loans: LoanApplication[] }
| { status: "error"; message: string };
This is more professional than five random booleans:
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [loans, setLoans] = useState([]);
A state machine-style union makes impossible states harder to create.
Effects are for synchronizing with things outside React rendering:
import { useEffect, useState } from "react";
export function LoanListPage() {
const [state, setState] = useState<LoanListState>({
status: "idle"
});
useEffect(() => {
let cancelled = false;
async function loadLoans() {
try {
setState({ status: "loading" });
const response = await fetch("/api/v1/loans");
const loans = await response.json();
if (!cancelled) {
setState({ status: "success", loans });
}
} catch {
if (!cancelled) {
setState({
status: "error",
message: "Could not load loans"
});
}
}
}
loadLoans();
return () => {
cancelled = true;
};
}, []);
if (state.status === "loading") {
return <p>Loading loans...</p>;
}
if (state.status === "error") {
return <p>{state.message}</p>;
}
if (state.status === "success") {
return (
<>
{state.loans.map(loan => (
<LoanCard
key={loan.id}
loan={loan}
onSelect={id => console.log(id)}
/>
))}
</>
);
}
return null;
}
This is a lot of code, but it teaches the mechanics.
Render happens first. Effect runs after render. Data arrives later. State changes. React re-renders. Cleanup prevents updates after unmount.
Modern React development includes function components, hooks, Suspense, use, useDeferredValue, useTransition, forms and compiler-driven optimisation.
My rule of thumb:
useEffect is not a dumping ground. Use it when React must synchronize with something outside rendering.
6. Context, Redux and routing: application-level concerns
Small apps can use local state.
Medium apps need shared state.
Enterprise apps need clear state ownership.
React Context is good for relatively stable shared values:
type AuthContextValue = {
user: AuthUser | null;
isAuthenticated: boolean;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
But Context is not automatically a full state management solution. If values change frequently and many components depend on them, careless Context usage can trigger broad re-renders.
Redux Toolkit is useful when state transitions are important and shared across many parts of the application.
Auth slice example:
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { login } from "../../api/authApi";
type AuthState = {
accessToken: string | null;
user: AuthUser | null;
status: "idle" | "loading" | "authenticated" | "error";
error?: string;
};
const initialState: AuthState = {
accessToken: null,
user: null,
status: "idle"
};
export const loginThunk = createAsyncThunk(
"auth/login",
async (request: LoginRequest) => {
return login(request);
}
);
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
logout(state) {
state.accessToken = null;
state.user = null;
state.status = "idle";
}
},
extraReducers: builder => {
builder
.addCase(loginThunk.pending, state => {
state.status = "loading";
})
.addCase(loginThunk.fulfilled, (state, action) => {
state.accessToken = action.payload.accessToken;
state.user = action.payload.user;
state.status = "authenticated";
})
.addCase(loginThunk.rejected, (state) => {
state.status = "error";
state.error = "Login failed";
});
}
});
export const { logout } = authSlice.actions;
export default authSlice.reducer;
Redux Toolkit uses Immer internally, so this “mutation-looking” code is safely converted into immutable updates.
Routing:
import { createBrowserRouter } from "react-router";
export const router = createBrowserRouter([
{
path: "/login",
element: <LoginPage />
},
{
path: "/loans",
element: (
<ProtectedRoute>
<LoanListPage />
</ProtectedRoute>
)
},
{
path: "/admin",
element: (
<AdminRoute>
<AdminDashboardPage />
</AdminRoute>
)
}
]);
Protected route:
type ProtectedRouteProps = {
children: React.ReactNode;
};
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const isAuthenticated = useAppSelector(
state => state.auth.status === "authenticated"
);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
Important security point:
Frontend protected routes improve user experience. They do not secure the system.
The backend must still enforce authorization.
My rule of thumb:
React can hide screens. The API must protect data.
7. API client: stop scattering fetch everywhere
A messy React app calls fetch directly from random components.
A better app creates a typed API layer.
const API_BASE_URL = import.meta.env.VITE_API_URL;
async function request<TResponse>(
path: string,
options: RequestInit = {}
): Promise<TResponse> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers
}
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return response.json() as Promise<TResponse>;
}
export const apiClient = {
get: <TResponse>(path: string) =>
request<TResponse>(path),
post: <TRequest, TResponse>(path: string, body: TRequest) =>
request<TResponse>(path, {
method: "POST",
body: JSON.stringify(body)
})
};
Typed endpoint:
export function getLoans() {
return apiClient.get<LoanApplication[]>("/api/v1/loans");
}
export function createLoan(request: CreateLoanRequest) {
return apiClient.post<CreateLoanRequest, LoanApplication>(
"/api/v1/loans",
request
);
}
Now components do not care about URLs, headers, serialization or response handling.
They call business functions:
const loans = await getLoans();
This is the same principle as your .NET backend. Controllers should not contain raw SQL everywhere. React components should not contain raw HTTP wiring everywhere.
My rule of thumb:
Centralize API communication. Type it. Test it. Keep components focused on UI behaviour.
8. Node.js and Express: the backend side
Node.js allows JavaScript to run outside the browser using the V8 engine and an event-loop model. A working understanding includes Node internals, native modules, libuv, HTTP servers, request and response objects, status codes, headers and routing.
A raw Node server can handle HTTP, but Express gives structure.
import express from "express";
const app = express();
app.use(express.json());
app.get("/healthz", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.listen(3000, () => {
console.log("API running on port 3000");
});
Loan route:
import { Router } from "express";
export const loanRoutes = Router();
loanRoutes.get("/", async (req, res) => {
const loans = await loanService.getLoans();
res.json(loans);
});
loanRoutes.post("/", async (req, res) => {
const loan = await loanService.createLoan(req.body);
res.status(201).json(loan);
});
App registration:
app.use("/api/v1/loans", loanRoutes);
A senior Express app should have clear layers:
routes/
loans.routes.ts
services/
loans.service.ts
data/
prisma.ts
middleware/
authenticate.ts
authorize.ts
errorHandler.ts
validation/
loanSchemas.ts
Do not put all logic in route handlers.
Bad:
loanRoutes.post("/", async (req, res) => {
// validate body
// check permissions
// call database
// handle errors
// send email
// build response
});
Better:
loanRoutes.post(
"/",
authenticate,
authorize("Broker"),
validate(createLoanSchema),
asyncHandler(async (req, res) => {
const loan = await loanService.createLoan(req.body, req.user);
res.status(201).json(loan);
})
);
My rule of thumb:
Express routes should orchestrate HTTP. Business rules belong in services. Database access belongs behind a data layer.
9. PostgreSQL and Prisma: persistence with type safety
A real application needs memory beyond the process.
If your Node server restarts and all data disappears, you do not have a business system. You have a demo.
PostgreSQL stores durable relational data. Prisma gives a type-safe ORM layer.
The persistence layer combines PostgreSQL relational design with Prisma schemas, migrations, CRUD, relations, transactions, error handling, seeding, raw SQL and database testing.
Prisma schema example:
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
role Role @default(BROKER)
loans LoanApplication[]
createdAt DateTime @default(now())
}
model LoanApplication {
id String @id @default(uuid())
applicantName String
requestedAmount Decimal
status LoanStatus @default(DRAFT)
submittedAt DateTime?
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
}
enum Role {
BROKER
ADMIN
CREDIT_OFFICER
}
enum LoanStatus {
DRAFT
SUBMITTED
UNDER_REVIEW
APPROVED
REJECTED
}
Prisma query:
export async function getLoansForUser(userId: string) {
return prisma.loanApplication.findMany({
where: {
createdById: userId
},
orderBy: {
createdAt: "desc"
},
select: {
id: true,
applicantName: true,
requestedAmount: true,
status: true,
createdAt: true
}
});
}
Notice the select. Do not blindly return everything.
Create loan:
export async function createLoan(
request: CreateLoanRequest,
userId: string
) {
return prisma.loanApplication.create({
data: {
applicantName: request.applicantName,
requestedAmount: request.requestedAmount,
status: "DRAFT",
createdById: userId
}
});
}
Transaction:
export async function submitLoan(loanId: string, userId: string) {
return prisma.$transaction(async tx => {
const loan = await tx.loanApplication.findFirst({
where: {
id: loanId,
createdById: userId
}
});
if (!loan) {
throw new Error("Loan not found");
}
if (loan.status !== "DRAFT") {
throw new Error("Only draft loans can be submitted");
}
return tx.loanApplication.update({
where: { id: loanId },
data: {
status: "SUBMITTED",
submittedAt: new Date()
}
});
});
}
My rule of thumb:
Prisma gives developer productivity, but you must still understand SQL, indexes, transactions and query shape.
10. Authentication and authorization: identity is not optional
Authentication asks:
“Who are you?”
Authorization asks:
“What are you allowed to do?”
A complete authentication design can involve JWTs, access and refresh tokens, Argon2 password hashing, middleware, role-based access control, cookies, Helmet, rate limiting and deliberate token-storage decisions.
Password hashing:
import argon2 from "argon2";
export async function hashPassword(password: string) {
return argon2.hash(password);
}
export async function verifyPassword(
hash: string,
password: string
) {
return argon2.verify(hash, password);
}
JWT creation:
import { SignJWT } from "jose";
export async function createAccessToken(user: AuthUser) {
const secret = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET);
return new SignJWT({
sub: user.id,
email: user.email,
role: user.role
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
}
Authentication middleware:
export async function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ message: "Unauthorized" });
}
const token = header.slice("Bearer ".length);
try {
const user = await verifyAccessToken(token);
req.user = user;
next();
} catch {
res.status(401).json({ message: "Invalid token" });
}
}
Authorization middleware:
export function authorize(...roles: Role[]) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: "Unauthorized" });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ message: "Forbidden" });
}
next();
};
}
Usage:
loanRoutes.get(
"/admin/all",
authenticate,
authorize("ADMIN", "CREDIT_OFFICER"),
asyncHandler(async (req, res) => {
const loans = await loanService.getAllLoansForReview();
res.json(loans);
})
);
My rule of thumb:
Never rely on frontend roles alone. Every protected API endpoint must enforce authorization server-side.
11. Testing: test behaviour, not implementation gossip
Vitest and React Testing Library provide the frontend test layer, while Supertest can exercise Node and Express APIs.
Component test:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoanCard } from "./LoanCard";
test("calls onSelect when user clicks view details", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
<LoanCard
loan={{
id: "LN-1001",
applicantName: "Sarah Khan",
requestedAmount: 250000,
status: "Submitted",
submittedAt: "2026-07-27"
}}
onSelect={onSelect}
/>
);
await user.click(screen.getByRole("button", { name: /view details/i }));
expect(onSelect).toHaveBeenCalledWith("LN-1001");
});
API test:
import request from "supertest";
import { app } from "../src/app";
test("GET /healthz returns ok", async () => {
const response = await request(app)
.get("/healthz")
.expect(200);
expect(response.body.status).toBe("ok");
});
Testing rule:
Do not test that useState was called.
Do not test internal implementation names.
Test what the user or API consumer observes.
My rule of thumb:
Good tests protect behaviour. Bad tests protect implementation details and punish refactoring.
12. Deployment, Docker and production thinking
Local code is not finished software.
Production needs environment variables, builds, health checks, logging, containers, secrets, database migrations, backups, CI/CD, security headers, rate limiting and observability.
Production delivery involves containers, Docker Compose, automated workflows, database migrations, security checks, infrastructure configuration and post-deployment operations.
Simple Dockerfile for API:
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Docker Compose mental model:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: loanapp
POSTGRES_PASSWORD: secret
POSTGRES_DB: loanapp
api:
build: ./api
environment:
DATABASE_URL: postgresql://loanapp:secret@db:5432/loanapp
PORT: 3000
depends_on:
- db
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- api
This gives you a full local production rehearsal:
React frontend
↓
Express API
↓
PostgreSQL database
My rule of thumb:
Deployment is not copying files. Deployment is packaging, configuration, secrets, migrations, health checks, rollback strategy and monitoring.
13. Observability: logs, metrics and traces
Monitoring tells you something is wrong.
Observability helps you understand why.
Observability brings logs, metrics, traces, correlation, OpenTelemetry, Node instrumentation, Grafana, sampling, noise reduction, frontend signals and alerting into one operational picture.
A serious system needs correlation.
When a user says:
“My loan submission failed.”
You should be able to trace:
Frontend click
↓
POST /api/v1/loans
↓
Authentication middleware
↓
Validation
↓
Loan service
↓
Prisma query
↓
PostgreSQL transaction
↓
Response
Without correlation IDs and traces, you are guessing.
Express request logging example:
app.use((req, res, next) => {
const correlationId = crypto.randomUUID();
req.correlationId = correlationId;
res.setHeader("x-correlation-id", correlationId);
console.log({
correlationId,
method: req.method,
path: req.path
});
next();
});
My rule of thumb:
If production fails and you cannot see the journey of the request, you do not have observability. You have hope.
14. Mentoring build: one loan submission across the whole stack
The framework tour becomes useful when one business operation crosses every boundary. Our feature lets an authenticated broker create a draft loan application, then view its status.
The contract is:
POST /api/v1/loans
authenticate browser session
validate request shape
derive tenant and broker from identity
enforce product permission
create once under an idempotency key
persist loan and outbox event atomically
return 201 with explicit DTO
Possible outcomes:
400 invalid data
401 no valid identity
403 identity lacks permission
409 idempotency key reused differently
429 capacity/rate limit
503 temporary dependency failure
Junior: Should we start by building the React form?>
Senior: Start with the user journey and HTTP contract. Frontend and API can then develop against the same examples without sharing runtime assumptions.The form must remain usable if JavaScript is slow, the network retries or the API returns a conflict. The server remains authoritative; browser validation improves feedback but cannot enforce security.
15. Organise by feature and dependency direction
A small monorepo might look like:
apps/
web/
src/features/loans/
api/
components/
routes/
schemas/
tests/
api/
src/features/loans/
domain/
application/
http/
persistence/
tests/
packages/
api-contracts/
eslint-config/
tsconfig/
Feature folders keep related code navigable. Layers inside the API separate pure rules from Express and Prisma. Do not create dozens of packages before independent versioning or reuse exists.
The api-contracts package can contain generated or carefully maintained wire types and schemas. It must not contain Prisma entities, React components or server-only authority decisions. Sharing a TypeScript interface does not validate runtime JSON.
Junior: If both sides use TypeScript, why do we need schemas?>
Senior: Types disappear at runtime. A browser, old deployment or attacker can send anything. Parse every external value before treating it as typed.
16. Enable strict TypeScript and preserve unknown at boundaries
Use strict checking and additional flags that fit the codebase:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}
Adopt stricter flags deliberately in brownfield projects because they can reveal broad existing assumptions. The goal is real narrowing, not replacing every error with as or !.
Network input begins as unknown:
type LoanStatus = 'Draft' | 'Submitted' | 'Approved' | 'Declined';
type CreateLoanResponse = Readonly<{
applicationId: string;
status: LoanStatus;
version: number;
location: string;
}>;
async function readJson(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw new ApiProtocolError('Expected a JSON response');
}
return response.json() as Promise<unknown>;
}
A runtime schema library can parse unknown into CreateLoanResponse. Generate schemas from OpenAPI or keep the schema as the source of truth; do not maintain three hand-copied shapes without drift checks.
Use discriminated unions for UI states:
type SubmissionState =
| { kind: 'editing' }
| { kind: 'submitting'; operationId: string }
| { kind: 'succeeded'; loan: CreateLoanResponse }
| { kind: 'failed'; error: UiError; valuesPreserved: true };
This prevents contradictory loading, error and success booleans.
17. Design money and identifiers honestly
JavaScript numbers use binary floating point. The browser may collect an amount as text and send a decimal string if the API contract requires exact decimal representation.
type CreateLoanRequest = Readonly<{
applicantReference: string;
requestedAmount: string;
currency: 'GBP';
declaredAnnualIncome: string;
productCode: string;
idempotencyKey: string;
}>;
The server parses and validates scale/range using a decimal-capable strategy or stores minor units where product rules permit. Do not add monetary values as JavaScript number and assume two decimal places survive every operation.
Branded types can prevent accidental identifier mixing at compile time:
declare const loanIdBrand: unique symbol;
type LoanId = string & { readonly [loanIdBrand]: true };
function parseLoanId(value: string): LoanId {
if (!/^ln_[a-zA-Z0-9]{20,40}$/.test(value)) {
throw new Error('Invalid loan identifier');
}
return value as LoanId;
}
The cast is safe only because runtime validation precedes it. Branding is not authorisation; a syntactically valid ID can still belong to another tenant.
18. Build the API boundary with validation and trusted identity
Express route code should be thin:
router.post('/api/v1/loans', requireSession, async (req, res, next) => {
try {
const input = createLoanSchema.parse(req.body);
const identity = requireRequestIdentity(req);
const result = await createLoan.execute({
tenantId: identity.tenantId,
brokerId: identity.userId,
applicantReference: input.applicantReference,
requestedAmount: input.requestedAmount,
currency: input.currency,
declaredAnnualIncome: input.declaredAnnualIncome,
productCode: input.productCode,
idempotencyKey: input.idempotencyKey,
});
res
.status(201)
.location(`/api/v1/loans/${result.applicationId}`)
.json(toCreateLoanResponse(result));
} catch (error: unknown) {
next(error);
}
});
tenantId and brokerId never come from JSON. Authentication middleware validates the session/token; the use case or authorizer checks product and resource permission.
Set a request-body size limit before parsing. Reject unsupported content types. Apply rate limits based on meaningful identity and trusted proxy configuration, not an unvalidated forwarded header.
An error handler maps known failures into a stable Problem Details-style response while unexpected errors remain generic:
type Problem = Readonly<{
type: string;
title: string;
status: number;
code: string;
traceId: string;
errors?: Readonly<Record<string, readonly string[]>>;
}>;
Do not send stack traces, Prisma errors, SQL or token details to the browser.
19. Idempotency is end-to-end retry safety
The React client creates one operation ID when submission starts and reuses it for retries. The server binds it to tenant, operation and request fingerprint under a database uniqueness constraint.
function beginSubmission(values: LoanFormValues): PendingSubmission {
return {
operationId: crypto.randomUUID(),
values,
startedAt: Date.now(),
};
}
Do not generate a new ID inside every fetch retry. That defeats deduplication.
Prisma schema concept:
model IdempotencyRecord {
tenantId String
operation String
key String
requestHash String
state String
resultJson Json?
expiresAt DateTime
@@id([tenantId, operation, key])
}
Two requests can both read “absent”; the composite primary key resolves the race. On duplicate, reload and compare requestHash. Same request returns the stored outcome; different request returns 409.
Junior: Can the frontend just disable the submit button?>
Senior: Do that for user experience, but double clicks, refresh, network retries, multiple tabs and malicious clients remain. Server idempotency protects the business operation.Do not persist raw request bodies if they contain unnecessary personal data. Canonicalise approved business fields and hash them. Document key retention and replay window.
20. Use a database transaction for local consistency
The loan, idempotency result and outbox event should commit atomically:
const result = await prisma.$transaction(async tx => {
const existing = await tx.idempotencyRecord.findUnique({
where: { tenantId_operation_key: { tenantId, operation: 'create-loan', key } },
});
if (existing) return matchExisting(existing, requestHash);
const loan = await tx.loan.create({ data: mapLoanData(command) });
const event = toLoanCreatedEvent(loan);
await tx.outboxMessage.create({ data: event });
await tx.idempotencyRecord.create({ data: completedRecord(command, loan) });
return loan;
});
This is a sketch; concurrent insertion can throw a unique violation after the initial read. Catch that specific database error outside the transaction, reload and match. Do not parse every database exception as duplicate.
Avoid network calls inside the transaction. They hold connections and locks while waiting and cannot join the local atomic commit. Use an outbox for downstream notification. Consumers deduplicate because publication is at least once.
Prisma’s generated types prove compile-time query shape, not that the database has the expected migration or index. Run migrations and integration tests against the production engine.
21. React form state: keep the server out of every keystroke
Form input is local state. Server state libraries can own remote cache, but they should not store each character unless a collaborative or autosave requirement demands it.
function LoanForm() {
const [values, setValues] = useState<LoanFormValues>(emptyValues);
const [submission, setSubmission] = useState<SubmissionState>({ kind: 'editing' });
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const clientErrors = validateForFastFeedback(values);
if (clientErrors.length > 0) return;
const pending = beginSubmission(values);
setSubmission({ kind: 'submitting', operationId: pending.operationId });
try {
const loan = await loansApi.create(values, pending.operationId);
setSubmission({ kind: 'succeeded', loan });
} catch (error: unknown) {
setSubmission({
kind: 'failed',
error: toUiError(error),
valuesPreserved: true,
});
}
}
return <form onSubmit={handleSubmit}>{/* labelled fields */}</form>;
}
The submit request belongs in the event handler because it happens due to a user event. React’s official guidance describes Effects as escape hatches for synchronising with external systems; derived state and event-specific work normally do not need an Effect.
Junior: I usually watch a>shouldSubmitstate inuseEffect. Why avoid it?
Senior: It separates cause from action, can repeat on remount/dependency changes and adds intermediate state. The event handler already knows why submission should occur.Disable duplicate submission while pending, but preserve values and show a status accessible to screen readers. Focus the error summary on failure. Do not clear the form until success is confirmed.
22. Effects need cleanup and race protection
Effects are appropriate for synchronising a displayed loan ID with a network subscription or external widget. A basic fetch needs cancellation or stale-result protection:
useEffect(() => {
const controller = new AbortController();
void loansApi.get(loanId, controller.signal)
.then(setLoan)
.catch(error => {
if (!controller.signal.aborted) setError(toUiError(error));
});
return () => controller.abort();
}, [loanId]);
Aborting the browser request does not prove the server stopped or rolled back. GET is safe; writes need idempotency.
A data-fetching/router framework or server-state library can provide caching, deduplication, retries and race handling more consistently than hand-written effects. Understand its defaults: refetch-on-focus, retry count, stale time and cache keys affect behaviour.
React development behaviour may remount components to reveal missing cleanup. Do not “fix” duplicate effects with a global flag that hides lifecycle bugs. Make synchronization idempotent and cleanup correct.
23. Avoid redundant state
Do not store fullName, filtered loans or canSubmit when they can be calculated during rendering from current inputs:
const validation = validateForFastFeedback(values);
const canSubmit = validation.length === 0 && submission.kind !== 'submitting';
Redundant state drifts. An Effect that copies props into state renders once with stale data and again after synchronization.
Memoisation is a performance tool, not a correctness requirement. Use useMemo only for measured expensive calculation or identity needed by another memoised boundary. A simple filter over ten rows does not need ceremony.
State should represent the minimum information needed to reconstruct the view. Store a selected ID, derive the selected entity. Preserve URL-shareable filter and page in routing/query parameters.
24. Build one typed API client boundary
Do not scatter fetch and token/error logic across components:
class LoansApi {
constructor(private readonly baseUrl: URL) {}
async create(values: LoanFormValues, operationId: string): Promise<CreateLoanResponse> {
const response = await fetch(new URL('/api/v1/loans', this.baseUrl), {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/json',
'idempotency-key': operationId,
},
body: JSON.stringify(toCreateLoanRequest(values, operationId)),
});
if (!response.ok) throw await parseProblem(response);
return createLoanResponseSchema.parse(await readJson(response));
}
}
The example assumes secure cookie-based authentication. If bearer tokens are used, centralise acquisition and never store long-lived tokens in unsafe browser storage without a threat model. Cross-site request forgery protections matter for cookie-authenticated state-changing requests; SameSite alone may not cover every architecture.
fetch does not reject for HTTP 4xx/5xx, so check ok. Add an explicit deadline through AbortController. Retry GETs or safe requests according to policy; retry POST only with the same idempotency key.
Treat response schema failure as a protocol incident distinct from a business validation error. Capture correlation ID and client/server versions without logging the body.
25. Authentication session design
For a same-site web application, an HTTP-only, Secure cookie can keep session tokens out of JavaScript. Configure SameSite, domain/path and expiry for the real topology. Protect state-changing requests from CSRF with origin checks and/or anti-forgery tokens according to the framework design.
For separate origins and OAuth/OIDC, use Authorization Code with PKCE through a well-reviewed library or backend-for-frontend pattern. Do not implement token protocols from scratch. Avoid putting access tokens in URLs, logs or analytics.
Refresh has concurrency pressure: several API calls discover expiry simultaneously. A client token manager should single-flight refresh so one refresh occurs and waiting calls share its result. If refresh fails, clear session once and return to authentication without an infinite loop.
The API validates token/session and authorises every resource. A protected React route only prevents a confusing screen; it is not security.
Junior: Can roles in the JWT decide whether a broker may edit this loan?>
Senior: Roles can express broad permission. Resource ownership, tenant, product and current state still require server-side checks against authoritative data.Session revocation, logout, rotation and clock skew need tests. Never log the cookie or token.
26. CORS is a browser permission, not API authentication
CORS tells browsers which origins may read responses. Non-browser clients and attackers can still call the API. Authentication and authorisation remain required.
Allow exact production origins, methods and headers. Do not combine wildcard origin with credentialed requests. Validate environment configuration at startup; a typo should not silently fall back to *.
Preflight requests can affect latency and caching. Configure Access-Control-Max-Age deliberately and ensure proxies vary responses by Origin where dynamic policies are used.
The simplest deployment may serve frontend and API from one site, reducing CORS and cookie complexity. Separate origins should be justified by hosting/ownership needs.
27. Optimistic UI and concurrency conflicts
Creating a new draft can be represented optimistically with a temporary ID, but rollback and reconciliation add complexity. For a consequential loan submission, showing a pending state until 201 may be clearer.
Editing an existing loan requires an expected version or ETag. Send it with the command. The database performs a conditional update. On conflict, fetch current state and ask the user to review rather than silently overwriting or retrying.
type UpdateLoanCommand = Readonly<{
applicationId: LoanId;
expectedVersion: number;
requestedAmount: string;
operationId: string;
}>;
If the UI optimistically changes a cache, retain the previous value and bind rollback to the operation ID. A late failure from operation A must not undo a newer success B.
Conflict is a normal workflow outcome, not “unexpected server error.” Give the user a comparison or refresh path. Record conflict rate operationally because a rise may reveal a usability or concurrency problem.
28. Node’s event loop and server capacity
Node handles many I/O operations efficiently, but JavaScript execution on the event-loop thread can be blocked by CPU-heavy work, synchronous filesystem calls, large JSON parsing or catastrophic regular expressions.
Junior: Node is single-threaded, so can it process only one request?>
Senior: JavaScript callbacks execute on an event loop, while the runtime and operating system handle asynchronous I/O and a worker pool for certain operations. It can keep many requests in flight, but long synchronous JavaScript delays them all on that loop.Measure event-loop delay, CPU, memory, GC, request queue and dependency time. Move substantial CPU work to worker threads, a bounded job worker or specialised service. Do not create an unbounded worker per request.
Streams can process large uploads with bounded memory, but they need backpressure and error handling. Enforce file size before and during streaming. Avoid buffering an arbitrary upload in memory.
Use a supported LTS Node release in production. The current official schedule lists Node 24 as an LTS line; pin an approved patch/base-image digest and update through a tested dependency process rather than using a floating major forever.
29. Validate configuration at API startup
Environment variables are strings and may be missing:
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().min(1),
SESSION_ISSUER: z.string().url(),
ALLOWED_ORIGINS: z.string().transform(value => value.split(',')),
});
export const config = configSchema.parse(process.env);
Do not print the parsed object if it contains secrets. Prefer workload identity or a secret manager. Validate production URLs and allowed origins more strictly than a generic string.
Fail before listening for traffic. A healthy process that discovers a missing issuer on its first authenticated request is harder to operate.
30. Prisma schema and migration discipline
Model tenant and concurrency explicitly:
model Loan {
id String @id
tenantId String
applicantReference String
requestedAmountMinor BigInt
currency String
status String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, status, updatedAt])
@@unique([tenantId, id])
}
BigInt in JavaScript cannot be JSON-stringified directly. Map it to an approved decimal/string DTO. Alternatively use the database decimal type with a library-aware mapping. The schema choice follows money requirements.
Every query includes tenant scope from trusted identity. A composite unique constraint does not automatically add filters to every Prisma query. Create focused repositories/use cases and integration tests with multiple tenants.
Migrations are production artefacts. Review SQL, locking and data backfill. Use expand/contract for rolling deployment: add compatible structures, deploy code that handles both, backfill, then remove later. Run migrations as a controlled job, not from every API replica racing at startup unless the platform explicitly manages it safely.
31. Cache only with ownership and invalidation
Browser server-state cache keys must include all inputs that affect the result: tenant/session scope, loan ID, filters and API version where relevant. Clear protected cache on logout so the next user cannot see prior data on a shared device.
API caches must include authorisation scope. Never cache /loans/:id solely by path when different tenants can use similar identifiers. Prefer not to cache sensitive personalised responses until the policy is clear.
HTTP caching can use ETag and conditional requests for read-only resources. Private responses should declare appropriate cache directives. CDN caching of authenticated API data is dangerous without precise configuration.
Invalidate or refetch after mutation. Optimistic cache edits must still accept the server response as canonical. A five-minute cache is a product staleness decision, not a library default to inherit unnoticed.
32. Testing the vertical slice
Frontend component test:
it('preserves values and displays validation returned by the API', async () => {
server.use(createLoanHandler.returningValidation({
requestedAmount: ['Amount exceeds the product limit'],
}));
render(<LoanForm />);
await user.type(screen.getByLabelText(/requested amount/i), '500000');
await user.click(screen.getByRole('button', { name: /create draft/i }));
expect(await screen.findByText(/exceeds the product limit/i)).toBeVisible();
expect(screen.getByLabelText(/requested amount/i)).toHaveValue('500000');
});
Mock at the network boundary, not the fetch call in every component. This exercises form, client and error mapping while keeping the API process out of the component suite.
API unit tests cover pure domain policy. Application tests fake repository boundaries. Integration tests run Express, Prisma and the production database engine. They prove tenant filters, unique idempotency and transaction/outbox behaviour.
Contract tests compare OpenAPI/schema and generated frontend clients. One end-to-end test creates a synthetic draft through the browser and verifies the API/database outcome. Security tests prove another tenant cannot read it.
33. Race-test duplicate submissions
Send two concurrent POSTs with one idempotency key and identical payload against a real database. Assert one loan and one outbox event. Then send the same key with a different amount and assert 409.
Do not insert setTimeout and hope requests overlap. Add a controlled hook or database barrier in the test environment so both reach the critical window. Put a safety timeout around the coordination.
Frontend test a double click and a response-lost retry. The browser should reuse the pending operation ID. A disabled button is helpful but the database constraint remains the final local race protection.
34. Accessibility is a full-stack quality attribute
Associate every input with a visible label. Use appropriate input modes and autocomplete without exposing sensitive data unnecessarily. Group errors in a summary and link them to fields with aria-describedby. Move focus to the summary after submit failure and announce pending/success status through a suitable live region.
Do not rely on colour to indicate validation. Keep the submit button state understandable. Avoid replacing native form semantics with clickable divs.
Server validation messages need stable field codes so the frontend can associate them. Human-readable text can evolve/localise. Unknown/global errors remain in the summary.
Automated accessibility checks catch missing labels and common violations; keyboard and screen-reader review remains necessary. Test the journey under slow network and zoom.
35. Error boundaries and server errors
React error boundaries catch rendering errors in their subtree, not ordinary async event-handler rejections or server Problem responses. Handle expected API failures in the feature state. Use route-level boundaries to keep navigation/retry possible after unexpected render failure.
Log a safe error ID and release version; do not upload component props containing applicant data. Show a generic recovery action. An error boundary should not automatically retry a state-changing request.
Node process-level uncaughtException or unhandledRejection indicates an untrusted process state. Record safe diagnostics and shut down gracefully according to platform policy rather than continuing indefinitely. Prevent them by awaiting promises and centralising request error handling.
36. Observability across browser, API and database
Accept or create a trace context at the edge using standard propagation. Do not trust an arbitrary correlation ID as authority; validate length/format and generate internal trace IDs through the telemetry system.
Browser signals:
- route and user action category;
- request duration and outcome code;
- web performance and error-boundary rate;
- release version;
- no raw form values.
- route template, status, duration and request size;
- authentication/authorisation outcome category;
- idempotency replay/conflict;
- database and outbox duration;
- event-loop delay, CPU, memory and pool waits;
- safe tenant/operation correlation.
37. Incident clinic: the form created two drafts
Start with the user’s correlation or operation ID. Trace both browser requests, idempotency records, loans and outbox events.
Possible paths:
- The browser generated a new key for its retry.
- The server accepted the header but used a body field inconsistently.
- Key uniqueness omitted tenant/operation or was absent.
- The retry path bypassed the use case.
- One apparent duplicate is only a duplicated UI cache entry.
Junior: Should we add debounce to the button?>
Senior: It improves one click pattern but does not repair network or multi-tab retries. Fix operation identity and database idempotency first.Add regression tests at browser and database race layers. Dashboard idempotency replay/conflict rates. Document the client key lifecycle.
38. Incident clinic: API latency rises while CPU stays low
Trace time by stage. Low CPU may accompany exhausted database connections, slow queries, locked rows, remote calls or event-loop blocking that does not saturate all cores.
Inspect event-loop delay, Prisma pool waits, query duration, PostgreSQL locks, request queue and downstream latency. A new N+1 query from mapping related entities can increase round trips. An unbounded Promise fan-out can exhaust the pool.
Do not increase pool size blindly; PostgreSQL has finite connections and query capacity. Fix the query, bound concurrency, add the right index or reduce work. Load-test the correction with production-shaped data.
39. Dockerfile and supply-chain hardening
The existing Node 24 multi-stage Dockerfile is a reasonable start. Improve it by pinning an approved image digest, running as a non-root user, copying only necessary artefacts, using a lock file with npm ci, and scanning dependencies and the final image.
Separate build-time public configuration from runtime secrets. React static assets cannot hide a secret; anything shipped to the browser is public. Runtime API secrets remain in the server environment/secret store.
Add a .dockerignore so .git, local secrets, test artefacts and node_modules do not enter build context. Generate a software bill of materials where organisational policy requires it.
Handle termination: stop accepting traffic, finish or cancel requests within the host grace period, close the HTTP server and database client, and exit. Readiness should go false before termination. Liveness should not depend on every downstream service and trigger restart storms.
40. Deployment sequencing and rollback
Build frontend and API artefacts once, promote immutably, and include release identifiers in telemetry. Database migrations follow expand/contract. API changes remain compatible with cached older frontend bundles during the rollout window.
Deployment order for an additive field:
add nullable/defaulted database column
-> deploy API that reads/writes safely but keeps old response compatible
-> deploy frontend using new field
-> backfill/observe
-> enforce requirement in a later release
If the frontend requires a new API field before all API replicas support it, canary routing can produce intermittent failures. Contract compatibility matters across mixed versions.
Rollback restores compatible image and configuration. A destructive migration or event schema can make rollback impossible. Test the previous API against the migrated database before release.
41. CI/CD quality gates
install from lock file
-> TypeScript compile, lint and formatting check
-> frontend unit/component tests
-> API domain/application tests
-> PostgreSQL integration and migration tests
-> contract/security tests
-> build web/API artefacts and container
-> scan dependencies/image
-> smoke test final artefact
-> deploy test, then canary with monitored gate
Do not rebuild frontend configuration differently per environment if immutability matters; inject public runtime configuration through an approved pattern. Validate that production source maps and telemetry do not expose sensitive source or data beyond policy.
Keep flaky tests visible with owners. A pipeline rerun button is not a quality strategy.
42. Code-review checklist across the stack
- Is every external value runtime-validated before typing?
- Are money and identifiers represented without accidental coercion?
- Does the browser preserve one idempotency key across retry?
- Are tenant and actor derived from authenticated server context?
- Does resource authorisation happen in the API/use case?
- Are transaction boundaries local and network-free?
- Can stale UI or API responses overwrite newer state?
- Is React state minimal, with Effects only for synchronization?
- Are fetch errors, schema errors and business errors distinguished?
- Are database migrations compatible with mixed deployments?
- Do tests cover the real database constraints and API contracts?
- Can logs, metrics, caches or browser storage expose sensitive data?
- Are Node event-loop and downstream capacity observable?
- Does shutdown and rollback preserve accepted work?
43. Exercises for the full-stack developer
Exercise one: parse unknown
Return malformed JSON, an unknown status, missing version and a numeric amount where a string is required. Prove the API client rejects each as a protocol error rather than rendering corrupted state.
Exercise two: remove an unnecessary Effect
Find derived state or click-driven work implemented with useEffect. Move calculation to render or work to the event handler. Test remount and rapid prop change.
Exercise three: race idempotency
Coordinate two real API requests with one key at the database critical point. Assert one loan and event. Reuse the key differently and assert conflict.
Exercise four: cross-tenant attack
Create two users and loans. Try route IDs, query filters, cache keys and API calls across tenants. Verify the browser receives no existence/data leak according to policy.
Exercise five: break the database pool
Create unbounded concurrent queries under load, capture pool wait and latency, then bound concurrency or improve query shape. Explain why a larger pool is not automatically the fix.
Exercise six: rolling deployment rehearsal
Serve an old frontend against the new API and the new frontend against a mixed API set. Run migrations and rollback. Record incompatible assumptions and turn them into contract gates.
44. Cross-links for the next mentoring session
Continue with React and TypeScript Production Mastery for deeper component architecture. Use Node.js Inside Out for event-loop, streams and production server behaviour. Testing JavaScript and TypeScript Applications expands the test strategy. HTTP and Web APIs from First Principles deepens caching, idempotency and status semantics, while Web Security for Full-Stack Developers covers browser and API threats end to end.
The SQL Server and Relational Databases material uses a different database platform but reinforces transactions, constraints, indexing and query evidence that apply here conceptually.
45. List endpoints need stable pagination contracts
Returning every loan works in a demo and fails as data grows. Define filters, sort, page size and continuation semantics.
Offset pagination is simple:
GET /api/v1/loans?status=Submitted&page=3&pageSize=25&sort=-updatedAt
It can become expensive at large offsets and can skip/duplicate items when rows change between requests. Cursor pagination uses a stable ordered key, such as (updatedAt, id):
GET /api/v1/loans?status=Submitted&limit=25&after=opaque-cursor
Use a deterministic tie-breaker. Encode and validate the cursor; do not accept raw SQL fragments. A cursor is not authorisation and may need signing or expiry if it reveals internal data.
Response contract:
type LoanPage = Readonly<{
items: readonly LoanSummary[];
nextCursor: string | null;
resultAsOf: string;
}>;
The repository query applies tenant and authorised scope before pagination. Create an index matching common filter and order. Test with duplicate timestamps and concurrent inserts.
Junior: Can the frontend sort the current page by applicant name?>
Senior: It can sort what it has, but that is not the global result order. If users expect server-wide sorting, make it an API query and index it appropriately.Browser cache keys include every filter, sort and cursor. When a mutation changes membership, invalidate/refetch affected queries rather than pretending the client can reproduce every server rule.
46. Search input, URL state and cancellation
Search text the user wants to share belongs in the URL. Keep transient composition local, then navigate after a small debounce or explicit submit. Data fetching reacts to the URL/query key.
Cancel obsolete GETs with AbortController, but still reject stale responses by query identity. A service worker, cache or future adapter may resolve after logical cancellation.
Do not send a request per keystroke without minimum length, debounce and server capacity policy. Apply database-safe query patterns; %term% over an unindexed large table is not rescued by React debouncing.
Escape and parameterise through Prisma rather than constructing SQL. If raw queries are required, use safe parameter APIs and review query plans. Search result snippets must be output-encoded in React; do not inject highlighted HTML from the server unless sanitised under a strict policy.
47. Server-state libraries and cache correctness
A server-state library can manage query caching, stale time, retries, cancellation and mutation invalidation. It should not become a second domain model.
Use stable key factories:
const loanKeys = {
all: ['loans'] as const,
lists: () => [...loanKeys.all, 'list'] as const,
list: (query: LoanQuery) => [...loanKeys.lists(), canonicalQuery(query)] as const,
detail: (id: LoanId) => [...loanKeys.all, 'detail', id] as const,
};
Do not put a newly allocated non-canonical object with unstable fields into a key. Exclude non-semantic values, include all semantic ones.
On logout, cancel protected queries and clear their cache before presenting another identity. On token/session refresh, do not duplicate mutations automatically. Retry policy should distinguish idempotent reads, operation-key writes and business conflicts.
Junior: If the cache says data is fresh, can we skip version checks on update?>
Senior: No. Another user or service can change the server. Cache freshness is a user-experience policy; optimistic concurrency protects durable truth.Persisted browser cache needs security, expiry and schema/version review. Avoid persisting financial entities by default. Offline capability is a separate product requirement with encryption, conflict and deletion design.
48. Background outbox dispatcher in Node
Publishing an event during the HTTP transaction is unsafe. A separate worker leases pending outbox rows, publishes and records completion.
async function dispatchBatch(signal: AbortSignal): Promise<void> {
const messages = await outbox.leaseBatch({
owner: workerId,
maximum: 50,
leaseUntil: new Date(Date.now() + 30_000),
});
for (const message of messages) {
if (signal.aborted) break;
try {
await broker.publish(message.type, message.payload, {
messageId: message.id,
});
await outbox.markPublished(message.id, workerId);
} catch (error: unknown) {
await outbox.recordFailure(message.id, workerId, safeFailure(error));
}
}
}
Leasing must be atomic in the database; selecting then updating without protection lets workers take the same row. Publication can succeed and markPublished fail, so duplicate publish remains possible. Consumers deduplicate by message ID.
The worker observes termination, stops leasing new messages and completes or releases current leases within grace. Work survives restart because the database owns state. Monitor oldest pending age, attempts, dead letters and publish latency.
Do not use setInterval(async () => ...) without preventing overlap and observing errors. A loop awaits each poll and sleeps with cancellation, or a job platform schedules controlled executions.
49. Password and session hardening when local credentials exist
Prefer a trusted identity provider when appropriate. If the product owns passwords, use an established adaptive password-hashing implementation with reviewed parameters and per-password salts. Never encrypt passwords for later recovery and never log them.
Registration and reset must avoid account enumeration, rate-limit abuse and use single-use expiring tokens stored safely. MFA, recovery and support override require product/security design.
Sessions should rotate after authentication and privilege change. Store only a hashed session identifier server-side where practical, with user, tenant, expiry, created/revoked times and device metadata limited by privacy policy.
Logout revokes the server session and clears the cookie. “Delete token from React state” alone leaves a reusable server token. Global logout and password reset should define which sessions are revoked.
Cookie settings (HttpOnly, Secure, SameSite, Path/Domain) follow actual topology. Test through the production proxy because TLS termination and forwarded headers affect secure-cookie behaviour. Configure Express trust proxy only for known infrastructure; trusting arbitrary forwarded headers affects scheme, IP and rate limiting.
50. Input validation is layered
The browser validates required fields and formatting for quick feedback. The API validates schema, size and domain commands. The database enforces uniqueness, foreign keys, nullability and concurrency. Each layer sees different risks.
Do not coerce surprising input silently. A schema that transforms an empty string to zero can turn missing income into a valid-looking value. Normalise intentionally, preserve original field errors and reject unknown properties where the contract benefits.
Unicode requires thought: length in code units is not user-perceived characters, and visually similar characters can differ. Do not invent identifier normalisation rules casually. Use generated opaque IDs where possible and domain-approved comparison for names/references.
Mass assignment occurs when request objects flow directly into Prisma data. Map allowed fields explicitly. Fields such as tenant, status, approvedBy, version and internal flags never come from the request.
51. Protect file upload paths
If loan documents are added, stream uploads to approved object storage rather than trusting original filenames or buffering everything. Enforce content length, actual type checks, malware scanning, tenant ownership and quarantine before processing.
Generate storage keys. Original filenames are display metadata and may contain path characters or personal data. Never construct local paths by concatenation.
Return an accepted upload/job state; scanning and extraction happen asynchronously. The user sees Pending, Ready or Rejected. Download uses an authorised endpoint or short-lived scoped URL created after resource checks.
Protect against decompression bombs, parser vulnerabilities and oversized extracted text. Isolate risky processors and bound CPU/memory/time. Delete quarantined and expired files according to retention.
52. Overload and backpressure in Express
The API can accept requests faster than PostgreSQL or a provider can serve them. Connection pools queue. Node memory rises. Client timeouts cause retries.
Set request/body limits, rate and concurrency policy, database pool capacity, outbound agent limits and deadlines as one system. Reject excess early with 429/503 and stable retry guidance when appropriate rather than accepting work that cannot meet its deadline.
Do not place an unbounded Promise.all over a user-sized array:
// Dangerous for an unbounded list.
await Promise.all(documentIds.map(id => processDocument(id)));
Use a bounded worker queue, batch API or durable job. Validate the maximum number of IDs at the boundary.
Monitor event-loop delay and queue wait separately from processing. More API replicas can increase total database connections; capacity planning must multiply per-process pools by replicas.
53. Graceful API shutdown
On termination:
mark readiness false
-> stop accepting new connections
-> allow in-flight requests within grace
-> stop background leasing
-> close broker/database clients
-> exit before platform kill deadline
State-changing requests remain idempotent because a connection may close after commit. The client retries with the same operation key.
Node’s HTTP server close behaviour and connection handling depend on version and configuration. Test with keep-alive connections and the actual reverse proxy. Do not rely on a signal handler that starts async work but never awaits/coordinates process exit.
If shutdown exceeds grace, durable jobs and outbox leases recover. HTTP requests cannot be made durable automatically; design operation lookup by key or resource location.
54. Incident clinic: tenant data appears after logout/login
On a shared browser, user A logs out and user B logs in. The loan list briefly shows A’s cached data.
Immediate response is to assess disclosure scope, disable the affected cache/release if necessary and follow the security incident process. Do not dismiss a “brief flash” as cosmetic.
Trace cache ownership, logout ordering, persisted storage, service worker and component state. Common cause: query keys omit identity scope and logout does not clear cache before navigation.
Fix by cancelling in-flight protected queries, clearing memory/persisted state, resetting feature stores and server session, then navigating. Identity transitions should create a new application data scope. Still include tenant/user authorization on the API so cache bugs cannot fetch new unauthorised data.
Add an end-to-end test with two identities in one browser context. Seed visually distinctive synthetic records and assert A never appears after B authenticates, including offline/cache conditions.
Junior: Should we include user ID in every cache key?>
Senior: It can help, but clearing protected state on identity change is still required. Keying alone leaves sensitive data resident and can leak through components that use broader keys.
55. Incident clinic: deployment produces intermittent schema errors
Only some requests fail validation after a frontend release. Traces show the new frontend reaches a mixture of old and new API replicas; the new response field is absent on old replicas.
Restore compatibility by rolling back frontend or completing a safe API-first deployment. Then revise sequencing: API adds the optional field, all replicas deploy, compatibility probes pass, frontend begins using it with fallback, and a later version may make it required.
Contract tests should run new consumer against oldest supported API and old consumer against candidate API. Add release versions to responses/traces, not sensitive payloads, so mixed behaviour is diagnosable.
Schema libraries should distinguish missing optional compatibility from corrupt required protocol. Avoid as casts that suppress the failure until rendering.
56. Performance review from user action to query plan
Measure:
input delay and React render
-> browser request queue/network
-> proxy/API queue
-> authentication/validation
-> application logic
-> Prisma pool wait and query
-> response serialisation/transfer
-> client parsing/cache/render
A slow form is not automatically React. A fast SQL query can still wait for a pool connection. A fast API can return too much JSON and block browser parsing. Distributed tracing plus browser performance gives the decomposition.
Optimise the largest measured contributor. Add indexes after reviewing query plans and write cost. Paginate and project fields. Reduce bundle size and render work where browser evidence points. Cache only with correctness policy.
Set performance budgets for page JavaScript, largest content, API p95/p99 and query/pool time. Test representative data volumes; ten local rows prove little.
57. Production-readiness gate
Before release, demonstrate:
- strict TypeScript compiles without broad
any/unsafe assertions; - wire data is runtime-validated on both trusted boundaries;
- money, IDs and optional values have explicit semantics;
- browser event/effect/state ownership is clear and accessible;
- authentication sessions, CSRF/CORS and resource authorisation are tested;
- idempotency and concurrency pass real PostgreSQL races;
- transaction and outbox state are atomic; consumers deduplicate;
- pagination, cache keys and invalidation are correct under mutation;
- migrations and schemas tolerate rolling mixed versions;
- Node LTS patch, dependencies and final image are reproducible/scanned;
- overload, shutdown and response-loss retry have defined behaviour;
- telemetry traces the journey without sensitive payload capture;
- canary, alert and rollback thresholds are rehearsed;
- browser, API, database and worker ownership is named.
58. Mentoring roadmap for the full-stack developer
Week one: implement the request/response schemas and pure loan validation in TypeScript. Build the accessible form against a network mock. Practise narrowing unknown.
Week two: build Express authentication fixture, route/use case and PostgreSQL persistence. Prove tenant separation and transactions with integration tests.
Week three: add idempotency, outbox worker and concurrent race tests. Inject response loss and worker termination. Build observability across the flow.
Week four: package the Node 24 LTS API and frontend, run migration/contract/security gates, deploy a synthetic canary and rehearse rollback. Diagnose one deliberately slow query and one stale frontend response.
At every review, ask the developer to trace one value from input to database and one failure back to the user. They should identify where runtime validation, authority, consistency and telemetry are enforced.
Junior: When can I call myself full-stack?>
Senior: When you can deliver and support a user outcome across boundaries, know where your understanding ends, and collaborate with specialists without treating any layer as magic.Finish the month by removing one unnecessary abstraction, one unsafe cast and one duplicated source of state. Document the operation ID contract, tenant boundary, migration order and incident lookup path. Those small acts of simplification and explanation are as important as adding another library.
The stack is mature when a new engineer can follow the loan submission from labelled input to authorised row, reproduce a failure from its trace and change one layer without guessing about the others.
What I want you to take away
This is not really about “React only.” It is about becoming capable across the full web application stack.
The journey starts with TypeScript because large applications need contracts. You learn types, interfaces, unions, generics, utility types and strict configuration so JavaScript becomes safer and more maintainable. Then ES6+ gives you the language mechanics React relies on: destructuring, spread, array functions, modules and async/await.
React then becomes easier to understand. A React app is a component tree. Props pass data in. Events pass actions out. State represents UI truth. Hooks give function components memory, side effects and reusable behaviour. Context handles lighter shared values. Redux Toolkit manages application-level state where transitions matter. React Router turns URLs into screens, and protected routes improve user experience while the backend still enforces real security.
On the server side, Node runs JavaScript outside the browser. Express gives structure to HTTP APIs through routing and middleware. PostgreSQL gives the application durable memory. Prisma gives typed database access, schema modelling, migrations, relations and transactions. Authentication brings JWTs, password hashing, refresh tokens, role-based access control and server-side authorization.
Then the system becomes real. The frontend and backend talk through a typed API client. CORS must be configured properly. Token refresh race conditions must be handled. Optimistic updates make the UI feel responsive. Error boundaries protect the frontend experience. Tests verify behaviour. Docker packages the system. Compose rehearses the full stack locally. CI/CD ships it. Observability tells you what is happening after deployment.
The mature interview answer is this:
“A modern full-stack React application is not just components. It is a typed frontend, structured state management, routed user journeys, tested UI behaviour, a secure API, durable database design, production deployment, observability, and disciplined engineering practices from local development to cloud release.”That is the difference between someone who has built React screens and someone who can deliver a full-stack production application.
