The Senior Frontend Engineering Blueprint, Part 1: Docker, Strict TypeScript, and a Real Prisma Data Layer
Most 'add a database' tutorials stop at new PrismaClient() and quietly ship a connection-pool leak the first time someone edits a file in dev. Part 1 of an eight-part series builds the foundation properly: Docker Postgres, a genuinely strict tsconfig, branded types, and a Prisma layer with cursor pagination and real transactions.
Most "add a database to your Next.js app" tutorials get you to new PrismaClient() and stop, right before the part where the tutorial's own advice breaks itself. Run next dev, edit any file that imports that client, and Next.js clears the module cache for hot reload — which reruns your file, which calls new PrismaClient() again, which opens a fresh connection pool on top of the one from thirty seconds ago. Do that for an afternoon and Postgres starts refusing connections. The fix is one globalThis check, and it's the kind of thing nobody tells you until you've hit it in production logs at 2am.
This is part 1 of an eight-part series building one real platform end to end — a Next.js 16 / React 19 app with a proper Prisma-backed data layer, authentication, edge routing, a virtualised admin table, input validation hardened against the usual attacks, and a real test suite. Each part stands alone; this one lays the foundation everything else sits on.
Step 1: infrastructure and scaffolding
A reproducible local Postgres beats a shared dev database every time — no state some other change left behind, no "works on my machine" from a locally-installed Postgres with a different config. docker-compose.yml in the project root:
services:
postgres:
image: postgres:16-alpine
container_name: nextjs_postgres
restart: always
environment:
POSTGRES_USER: dev_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: dev_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:docker compose up -dIsolating Postgres in a container means the local environment can't drift from what production runs, and a docker compose down -v gives you a clean database in seconds when a migration goes sideways.
Scaffold the app and install the dependencies this series uses:
npx create-next-app@latest modern-next-app \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*" \
--use-pnpm
cd modern-next-app
pnpm add @prisma/client zod
pnpm add -D prisma tsx @types/pg(Later parts add next-auth, @tanstack/react-query, class-variance-authority and a few others as they come up — no need to install everything up front.)
A genuinely strict tsconfig.json
"Strict mode" in most codebases means "strict": true and nothing else, which leaves real holes. noUncheckedIndexedAccess, in particular, is the one flag most teams skip and the one that would have caught the most production TypeError: Cannot read properties of undefined crashes if it were on:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}With noUncheckedIndexedAccess on, array[i] types as T | undefined rather than T — because it genuinely might be, if i is out of range — and every array/dictionary lookup in this series is written to account for that rather than trust it away with a non-null assertion.
Step 2: type-level foundations worth having early
One small module of type utilities, before any business logic, pays for itself the first time two string-typed IDs get swapped by accident:
// Branded types: stop a UserId being accepted where a PostId is expected,
// even though both are strings underneath.
declare const __brand: unique symbol;
export type Brand<K, T> = K & { readonly [__brand]: T };
export type UserId = Brand<string, "UserId">;
export type PostId = Brand<string, "PostId">;
export const toUserId = (id: string): UserId => id as UserId;
export const toPostId = (id: string): PostId => id as PostId;
// Recursive deep-partial, useful for PATCH payloads and form drafts
export type DeepPartial<T> = T extends (...args: unknown[]) => unknown
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
// An explicit ok/err result, instead of a function that can throw or
// return null and leaves the caller guessing which
export type Result<T, E = Error> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });| Approach | What the compiler catches | What it costs |
|---|---|---|
Plain string IDs |
Nothing — getUser(postId) compiles fine and fails at runtime |
Nothing, until it does |
Branded types (UserId/PostId) |
Passing the wrong ID type anywhere — a compile error, not a 500 | One Brand<K, T> helper and a constructor per ID type |
Result<T, E> return values |
Callers that forget to handle the error path — .value isn't accessible until .ok is checked |
Every call site does an explicit if (result.ok) instead of a try/catch |
None of this is exotic — it's the same trick a compiled language gets for free with a real type system, applied to the plain strings and exceptions JavaScript defaults to.
Step 3: full-stack persistence with Prisma
Schema
A schema with the relationships and indexes an admin dashboard actually needs — 1:N and M:N relations, and indexes on the columns that get filtered or sorted, not just primary keys:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
categories Category[]
comments Comment[]
viewCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([slug])
@@index([createdAt(sort: Desc)])
}
model Category {
id String @id @default(cuid())
name String @unique
posts Post[]
}
model Comment {
id String @id @default(cuid())
text String
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([postId, createdAt(sort: Desc)])
}echo 'DATABASE_URL="postgresql://dev_user:dev_password@localhost:5432/dev_db?schema=public"' > .env
npx prisma init
npx prisma migrate dev --name init_platformThe global singleton, and why it matters
This is the fix for the connection-pool leak from the opening paragraph. Next.js's dev server clears the module cache on every hot-reload cycle; globalThis survives that reload, so stashing the client there means a naive new PrismaClient() only ever runs once per process, not once per save:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}In production this pattern is a no-op — the process only starts once — but in dev it's the difference between one connection pool for the whole session and a new one every time a file is saved.
Cursor pagination and a real transaction
Offset pagination (SKIP n LIMIT m) is everywhere, and everyone half-knows it's not great: to return rows 10,000–10,020, Postgres still has to scan and discard the first 10,000. That's O(N) work for a constant-size page, and it gets worse — not just slower but inconsistent — under concurrent writes, because rows can shift between pages while a user is paging through them. Cursor (keyset) pagination replaces SKIP with an indexed WHERE id < :cursor predicate, which a B-tree index answers in O(log N) regardless of how deep the page is. The honest trade-off: cursor pagination can't jump to "page 47" the way offset pagination can — only forward and backward from a known position — which is exactly why so many APIs default to the easier, worse option anyway.
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
export async function getPaginatedPosts({
limit = 10,
cursor,
}: {
limit?: number;
cursor?: string;
}) {
const posts = await prisma.post.findMany({
take: limit + 1, // fetch one extra row to detect whether a next page exists
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: "desc" },
include: {
author: { select: { id: true, name: true, email: true } },
categories: true,
_count: { select: { comments: true } },
},
});
let nextCursor: string | undefined = undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return { posts, nextCursor };
}
// An interactive transaction: verifies the author, upserts categories, and
// creates the post as one atomic unit — either all of it happens, or none of it does.
export async function createPostWithAudit({
title,
slug,
content,
authorId,
categoryNames,
}: {
title: string;
slug: string;
content: string;
authorId: string;
categoryNames: string[];
}) {
return await prisma.$transaction(
async (tx) => {
const author = await tx.user.findUnique({ where: { id: authorId } });
if (!author) throw new Error("Author record does not exist.");
const categories = await Promise.all(
categoryNames.map((name) =>
tx.category.upsert({ where: { name }, update: {}, create: { name } })
)
);
return await tx.post.create({
data: {
title,
slug,
content,
authorId,
categories: { connect: categories.map((c) => ({ id: c.id })) },
},
include: { categories: true, author: true },
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
}Serializable is the strictest isolation level Postgres offers — it guarantees the transaction behaves as if it ran alone, even under concurrent load, at the cost of Postgres occasionally aborting one of two conflicting transactions and forcing a retry. For a write this size (an author check, a handful of upserts, one insert), that cost is negligible; for a hot-path transaction touching thousands of rows, it's a real trade-off against ReadCommitted, Postgres's default.
The compressed version: a container gives you a Postgres that behaves the same on every machine; noUncheckedIndexedAccess turns silent undefined crashes into compile errors; a globalThis singleton stops dev-mode hot reload from leaking connections; and cursor pagination trades "jump to page 47" for O(log N) reads that stay fast as the table grows. None of it is exotic — it's just the detail that "add a database" tutorials usually skip.
Next in the series: Server Components, Server Actions, and optimistic UI in the App Router.