Skip to content
← Journal
8 min readAta Mohammadi

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 -d

Isolating 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_platform

The 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.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.