Senior 6 min · April 12, 2026

T3 Stack Gotcha: Stale Prisma Client After Migration

API endpoints returned 500 errors with 'Unknown field legacy_status' from stale Prisma client.

N
Naren — Founder & Principal Engineer LinkedIn ↗
20+ years in enterprise software — production Java systems serving millions of transactions, large-scale batch automation in banking & fintech. All examples on this site are drawn from real systems.
Last updated: ✓ Verified in Production About the author →
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • The T3 Stack is a full-stack TypeScript template combining Next.js, tRPC, Prisma, Tailwind CSS, and TypeScript for end-to-end type safety
  • Next.js 15/16 App Router with Server Components eliminates API route boilerplate — data fetching happens directly in server-rendered components
  • tRPC v11 provides end-to-end type inference from database schema through API layer to frontend components with zero code generation
  • Prisma generates a type-safe client from your schema.prisma — every query is checked at compile time against your actual database schema
  • shadcn/ui replaces traditional component libraries — you copy components into your project and own the code, no version-lock dependency
  • The stack's power is the type chain: Prisma schema infers Prisma types, tRPC infers API types, React infers prop types — one change propagates everywhere
✦ Definition~90s read
What is T3 Stack Gotcha?

The T3 Stack is a curated, opinionated TypeScript-first web development stack created by Theo Browne (t3.gg) that solves the fundamental impedance mismatch between frontend and backend types. It combines Next.js (App Router), tRPC, Prisma, NextAuth.js, and Tailwind CSS into a single coherent architecture where types flow end-to-end without code generation or manual API contracts.

Imagine building a house where the architect's blueprint automatically updates the electrician's wiring plan, the plumber's pipe layout, and the carpenter's measurements every time you move a wall.

The stack's core insight is that if your entire application—database schema, API layer, authentication, and UI—is written in TypeScript, you can eliminate entire categories of bugs by having the compiler verify that your frontend code correctly uses the same types your backend defines. This isn't just a starter template; it's a deliberate constraint system that forces type safety at every layer, from your Prisma schema to your React components.

The T3 Stack exists because traditional full-stack TypeScript setups suffer from type fragmentation: your Prisma types don't automatically propagate to your API responses, your API types don't flow into your React components, and you end up writing duplicate type definitions or relying on brittle code generation. By using tRPC as the glue layer, the T3 Stack makes your backend procedures directly callable from the frontend with full type inference—no REST endpoints, no GraphQL resolvers, no OpenAPI specs.

Prisma provides the database layer with generated types that tRPC can consume directly, while NextAuth.js handles authentication with types that integrate into both Prisma and tRPC. The result is a development experience where changing a column in your database schema automatically updates the types available in your React components, and the TypeScript compiler catches mismatches before they reach production.

Where the T3 Stack shines is in full-stack TypeScript applications where type safety and developer velocity are paramount—think SaaS products, internal tools, or any project where a single team owns both frontend and backend. It's less suitable for projects that need to expose public REST APIs to third parties, require polyglot backends (e.g., Python ML services), or have existing non-TypeScript infrastructure.

The stack also assumes you're comfortable with its specific tool choices: if you prefer Drizzle over Prisma, or want to use React Query directly instead of tRPC's built-in caching, you're working against the grain. Real-world adoption includes companies like Cal.com, Dub.co, and countless startups that value the stack's ability to reduce boilerplate and catch type errors at compile time rather than runtime.

The tradeoff is that you're buying into a tightly coupled ecosystem where upgrading one piece (like tRPC v10 to v11) can cascade through your entire codebase—which is exactly the kind of gotcha this article addresses.

Plain-English First

Imagine building a house where the architect's blueprint automatically updates the electrician's wiring plan, the plumber's pipe layout, and the carpenter's measurements every time you move a wall. That's the T3 Stack — change your database schema, and the type system automatically updates your API contracts, your frontend components, and your form validations. No manual synchronization, no type drift, no 'I forgot to update the frontend' bugs.

The T3 Stack is not a framework — it's a curated set of tools that together provide end-to-end type safety from database to browser. Each tool solves a specific problem: Next.js for rendering and routing, tRPC for type-safe APIs, Prisma for type-safe database access, Tailwind for styling, and TypeScript as the connective tissue.

In 2026, T3 has split into two main patterns: (1) RSC-first: Server Components read directly from Prisma with Server Actions for writes (zero network hop), and (2) tRPC-everywhere: classic T3 with tRPC for both reads and writes. This guide covers pattern #2 because it is still the default create-t3-app setup, but we’ll clearly call out where the RSC-first pattern wins.

The 2026 iteration has evolved significantly. Next.js 15/16’s App Router with Server Components changes where data fetching happens. tRPC v11 integrates natively with TanStack Query for caching and optimistic updates. Prisma’s edge runtime makes serverless deployments viable. shadcn/ui replaces bloated component libraries with copy-paste components you fully own.

This guide covers the architecture decisions, the type chain that makes the stack powerful, the production pitfalls that catch teams off guard, and the migration path from earlier versions.

Why T3 Stack Is More Than a Starter Template

The T3 Stack is a curated, opinionated full-stack TypeScript framework combining tRPC, Prisma, Next.js, and Tailwind CSS. Its core mechanic is end-to-end type safety: you define a Prisma schema, tRPC automatically infers types from your database queries, and the client consumes those types without any code generation step. This eliminates the traditional REST/GraphQL boundary where type definitions drift from implementation.

In practice, the T3 Stack enforces a strict server-first architecture. All data fetching happens in Next.js server components or tRPC procedures, never on the client. This means your Prisma client runs exclusively on the server, avoiding the common mistake of leaking database credentials to the browser. The stack also mandates Zod for input validation at the API layer, giving you runtime type checking that matches your compile-time types.

Use the T3 Stack when you're building a data-intensive web application where type safety across the entire stack is non-negotiable. It shines in teams of 3-10 developers who want to move fast without sacrificing correctness. The real win is catching schema mismatches at compile time rather than in production — a Prisma migration that breaks your API surfaces immediately as a type error, not a runtime crash.

Stale Prisma Client After Migration
Running prisma migrate dev does not automatically regenerate the Prisma client — you must run prisma generate separately or the old client will silently use the wrong schema.
Production Insight
A team ran a migration that added a required column, deployed without running prisma generate, and the old client inserted rows with a null value for the new column, causing a cascade of foreign key violations.
The symptom: no TypeScript error, no build failure — just silent data corruption in production that only surfaced hours later when a downstream service tried to join on the missing column.
Rule of thumb: always run prisma generate immediately after any migration, and add a CI check that fails if the generated client is older than the latest migration file.
Key Takeaway
Prisma client is not automatically regenerated after migrations — you must run prisma generate explicitly.
A stale client can silently write invalid data to your database with zero compile-time warnings.
Always add a CI step that compares the migration timestamp with the generated client timestamp.

The Type Chain: How the T3 Stack Propagates Types End-to-End

The T3 Stack's core value proposition is the type chain — a single change to your database schema propagates type safety through every layer of your application without manual synchronization.

It starts with Prisma's schema.prisma. When you define a model, Prisma generates a TypeScript client where every query is typed against your schema. The output of a Prisma query is a typed object that flows into your tRPC procedure. tRPC infers the procedure's output type from the return value. On the client, tRPC's useQuery hook infers the response type from the procedure definition. React components receive typed props from the tRPC response.

The chain: schema.prisma → Prisma Client types → tRPC procedure types → React component props. One change at the top propagates to the bottom. If you rename a column, TypeScript catches every reference that's now broken — in your API layer, your frontend components, and your form validations.

io/thecodeforge/t3-stack/prisma/schema.prismaTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// io/thecodeforge/t3-stack/prisma/schema.prisma
// This schema generates the type chain — every layer derives types from here

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  role      Role     @default(MEMBER)
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  createdAt DateTime @default(now())

  @@index([authorId])
  @@index([published, createdAt])
  @@map("posts")
}

enum Role {
  ADMIN
  MEMBER
  VIEWER
}
Output
Prisma Client generated: User, Post, Role types available throughout the stack
Type Chain Mental Model
  • Schema.prisma is the single source of truth for all data types in the application
  • Prisma Client generates TypeScript types from the schema — every query is type-checked
  • tRPC infers procedure input/output types from the Prisma Client return values
  • React components infer prop types from tRPC's useQuery/useMutation hooks
  • One schema change propagates type errors to every layer — TypeScript catches broken references at compile time
Production Insight
The type chain catches rename and deletion errors at compile time — no runtime surprises.
If the chain breaks (stale Prisma client), TypeScript compiles but runtime queries fail.
Rule: always regenerate Prisma client after schema changes — stale types are worse than no types.
Key Takeaway
The type chain is the T3 Stack's core value — one schema change propagates type safety to every layer.
Prisma schema → Prisma Client → tRPC procedures → React props — each layer infers from the previous.
Punchline: if the Prisma client is stale, the type chain is broken — TypeScript compiles but runtime queries fail.
Type Chain Design Decisions
IfAdding a new field to a model
UseAdd to schema.prisma, run prisma generate, types propagate to tRPC and React automatically
IfRenaming a column
UseUpdate schema.prisma + migration, regenerate client — TypeScript catches every broken reference
IfRemoving a field
UseRemove from schema.prisma + migration, regenerate client — TypeScript catches every usage that needs cleanup
IfNeed a computed/derived type not in the database
UseDefine it in a shared types module and import into both tRPC and React — don't add phantom columns to Prisma

tRPC v11: Type-Safe APIs Without Code Generation

tRPC v11 eliminates the manual synchronization between backend API definitions and frontend type expectations. You define a procedure (query or mutation) on the server with a zod input schema and a typed resolver. The client imports the router type and gets full autocompletion, compile-time type checking, and zero code generation.

The v11 improvements over v10 include native TanStack Query v5 integration, better error handling with typed TRPCError responses, and improved middleware composition. The httpBatchLink now supports streaming — multiple procedures in a single HTTP request with progressive response delivery.

The key architectural decision is router composition. You define separate routers for each domain (users, posts, comments) and merge them into a root appRouter. The client sees a flat API surface: trpc.users.getById.useQuery() — the router hierarchy is invisible on the client side.

io/thecodeforge/t3-stack/server/trpc/routers/post.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

export const postRouter = createTRPCRouter({
  // Public query — no auth required
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(20),
      cursor: z.string().nullish(),
    }))
    .query(async ({ ctx, input }) => {
      const posts = await ctx.db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
        include: { author: { select: { name: true, email: true } } },
      });

      let nextCursor: string | undefined;
      if (posts.length > input.limit) {
        nextCursor = posts.pop()!.id;
      }

      return { posts, nextCursor };
    }),

  // Protected mutation — auth middleware applied
  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1),
      published: z.boolean().default(false),
    }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
        },
      });

      return post;
    }),

  // Protected query with authorization check
  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
        include: { author: { select: { name: true } } },
      });

      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `Post ${input.id} not found`,
        });
      }

      // Authorization: only author or admin can view unpublished posts
      if (!post.published && post.authorId !== ctx.session.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You do not have access to this post',
        });
      }

      return post;
    }),
});
Output
tRPC router compiled: post.list, post.create, post.getById — types inferred automatically
Watch Out: tRPC Middleware Order Matters
tRPC middleware executes in the order it's chained. If you add auth middleware before input validation, an unauthenticated request with invalid input will fail with an auth error instead of a validation error. Chain validation middleware first, then auth — so the user gets the most relevant error message.
Production Insight
tRPC eliminates the manual sync between backend routes and frontend type expectations.
Router composition keeps the codebase modular — each domain has its own router file.
Rule: define separate routers per domain (users, posts, comments) and merge into a root appRouter.
Key Takeaway
tRPC v11 gives end-to-end type inference from database to browser with zero code generation.
Router composition keeps the codebase modular — one router per domain, merged into appRouter.
Punchline: define a procedure once on the server — the client gets autocompletion, type checking, and TanStack Query caching for free.
tRPC Procedure Design Decisions
IfData is publicly accessible, no auth required
UseUse publicProcedure — no middleware overhead
IfData requires authentication
UseUse protectedProcedure — auth middleware applied automatically
IfProcedure needs to return cached data
UseUse query — TanStack Query caches and deduplicates automatically
IfProcedure modifies data (create, update, delete)
UseUse mutation — not cached, triggers query invalidation
IfNeed real-time updates (chat, notifications)
UseUse subscription with wsLink — WebSocket-based push from server to client

Prisma: Type-Safe Database Access in the Edge Runtime Era

Prisma generates a TypeScript client from your schema.prisma file. Every query — findMany, create, update, delete — is typed against your actual database schema. If you rename a column, TypeScript catches every query that references the old name at compile time.

The 2026 evolution is Prisma's edge runtime support. Traditional Prisma requires a Node.js binary for database connections — incompatible with edge runtimes (Cloudflare Workers, Vercel Edge Functions). Prisma's driver adapters (for Neon, PlanetScale, Turso) enable edge-compatible database access by using platform-native drivers instead of Prisma's binary engine.

The architectural decision is connection pooling. In serverless/edge environments, each function invocation opens a new database connection. Without pooling, you exhaust the database's connection limit at ~100 concurrent requests. Prisma Accelerate or a database-native pooler (PgBouncer, Neon's built-in pooler) is mandatory for production serverless deployments.

io/thecodeforge/t3-stack/lib/db.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { PrismaClient } from '@prisma/client';
import { withAccelerate } from '@prisma/extension-accelerate';

// Singleton pattern: prevent multiple PrismaClient instances in development
// Hot reloading creates new instances on every file change without this
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

// In production, use Prisma Accelerate for connection pooling and edge caching
// In development, use the standard client
export const db = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
}).$extends(withAccelerate());

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = db;
}

// Usage in Server Components and tRPC procedures:
// const posts = await db.post.findMany({
//   where: { published: true },
//   orderBy: { createdAt: 'desc' },
//   take: 20,
// });
Output
Prisma Client initialized with Accelerate extension — connection pooling and edge caching enabled
Pro Tip: Always Add @@index for Foreign Keys and Filtered Columns
Prisma doesn't automatically create indexes on foreign keys in all databases. Add @@index([fieldName]) explicitly for every foreign key and every column you filter or sort by. A missing index on a frequently queried column causes full table scans — query latency goes from 2ms to 500ms at 100K rows.
Production Insight
Prisma edge runtime requires driver adapters — the traditional binary engine doesn't work on Cloudflare Workers.
Connection pooling is mandatory in serverless — without it, you exhaust DB connections at ~100 concurrent requests.
Rule: use PgBouncer, Prisma Accelerate, or Neon built-in pooling for production serverless deployments.
Key Takeaway
Prisma generates a type-safe client from schema.prisma — every query is checked at compile time.
Edge runtime requires driver adapters — the traditional binary engine doesn't work outside Node.js.
Punchline: connection pooling is mandatory in serverless — without it, you hit the database connection limit at ~100 concurrent requests.
Prisma Deployment Decisions
IfDeploying to Node.js server (VPS, container)
UseStandard Prisma Client — binary engine, direct connection
IfDeploying to edge runtime (Cloudflare Workers, Vercel Edge)
UseUse driver adapter (Neon, PlanetScale, Turso) — no binary engine available
IfDeploying to serverless (Vercel Functions, AWS Lambda)
UseUse Prisma Accelerate for connection pooling — prevents connection exhaustion
IfDatabase has 100K+ rows and queries are slow
UseAdd @@index on filtered/sorted columns, run EXPLAIN ANALYZE on generated SQL

shadcn/ui: Owning Your Component Code Instead of Depending on It

shadcn/ui is not a component library — it's a collection of copy-paste components built on Radix UI primitives and styled with Tailwind CSS. You run npx shadcn-ui@latest add button and the component source code is copied into your project. You own it. You modify it. There's no version-locked dependency to update.

This solves a real production problem: traditional component libraries (MUI, Ant Design, Chakra) ship as npm packages. When you need to customize a component beyond the library's API surface, you fight the abstraction. shadcn/ui gives you the source code — customization is just editing a file in your project.

The architecture is built on three layers: Radix UI provides accessible primitives (keyboard navigation, ARIA attributes, focus management), Tailwind CSS provides the styling system (utility classes, CSS variables for theming), and shadcn/ui provides the composition layer that combines them into production-ready components.

io/thecodeforge/t3-stack/components/ui/data-table.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// Custom DataTable component built on shadcn/ui primitives
// This is YOUR code — modify it to match your design system

'use client';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
import { useState } from 'react';

interface Column<T> {
  key: keyof T;
  header: string;
  render?: (value: T[keyof T], row: T) => React.ReactNode;
}

interface DataTableProps<T> {
  data: T[];
  columns: Column<T>[];
  pageSize?: number;
  searchable?: boolean;
}

export function DataTable<T extends { id: string }>({
  data,
  columns,
  pageSize = 10,
  searchable = true,
}: DataTableProps<T>) {
  const [page, setPage] = useState(0);
  const [search, setSearch] = useState('');

  const filtered = search
    ? data.filter((row) =>
        columns.some((col) =>
          String(row[col.key]).toLowerCase().includes(search.toLowerCase())
        )
      )
    : data;

  const paginated = filtered.slice(page * pageSize, (page + 1) * pageSize);
  const totalPages = Math.ceil(filtered.length / pageSize);

  return (
    <div className="space-y-4">
      {searchable && (
        <div className="relative">
          <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
          <Input
            placeholder="Search..."
            value={search}
            onChange={(e) => { setSearch(e.target.value); setPage(0); }}
            className="pl-9"
          />
        </div>
      )}

      <Table>
        <TableHeader>
          <TableRow>
            {columns.map((col) => (
              <TableHead key={String(col.key)}>{col.header}</TableHead>
            ))}
          </TableRow>
        </TableHeader>
        <TableBody>
          {paginated.map((row) => (
            <TableRow key={row.id}>
              {columns.map((col) => (
                <TableCell key={String(col.key)}>
                  {col.render ? col.render(row[col.key], row) : String(row[col.key])}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>

      <div className="flex items-center justify-between">
        <p className="text-sm text-muted-foreground">
          Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, filtered.length)} of {filtered.length}
        </p>
        <div className="flex gap-2">
          <Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>
            <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  );
}
Output
DataTable component built on shadcn/ui primitives — fully owned, customizable, type-safe
Why shadcn/ui Wins Over Traditional Component Libraries
Traditional libraries (MUI, Ant Design) give you a component as a black box. When the design team wants a button that doesn't match the library's API, you fight overrides, CSS specificity wars, and theme provider hacks. shadcn/ui gives you the source code — the button IS your button. Customize it like any other file in your project.
Production Insight
shadcn/ui components are your code — no version-lock dependency to update or break.
Radix UI primitives handle accessibility (ARIA, keyboard nav) — you never need to rebuild a11y from scratch.
Rule: run npx shadcn-ui@latest diff before updating components — see what changed upstream before merging.
Key Takeaway
shadcn/ui is not a library — it's copy-paste component code you own and modify.
Radix UI handles accessibility, Tailwind handles styling, shadcn/ui handles composition.
Punchline: you own the source code — customize it like any other file. No version-lock dependency, no abstraction fights.
Component Library Decisions
IfNeed full customization control over every component
UseUse shadcn/ui — you own the source code, modify anything
IfNeed a large pre-built component set with strict design system
UseConsider MUI or Ant Design — more opinionated, faster initial setup
IfBuilding a design system from scratch
UseUse shadcn/ui as a foundation — Radix primitives + Tailwind tokens = custom design system
IfTeam doesn't want to maintain component code
UseUse a traditional library — shadcn/ui requires you to own and maintain the components

Project Structure: Organizing the T3 Stack for Scale

The T3 Stack's project structure determines how maintainable the codebase is at scale. A flat structure works for prototypes but collapses at 50+ procedures and 100+ components. The recommended structure separates concerns by domain, not by technology layer.

The key organizational principle is domain-driven: each feature (users, posts, comments) gets its own directory containing its Prisma model, tRPC router, Server Components, Client Components, and shared types. This keeps related code together — when you modify the posts feature, everything you need is in one directory.

The alternative — organizing by technology layer (all routers in one directory, all components in another) — forces you to jump between 5 directories to understand a single feature. At scale, this cognitive overhead slows development to a crawl.

io/thecodeforge/t3-stack/project-structure.txtTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Recommended T3 Stack project structure for production scale
// Domain-driven organization — each feature is self-contained

io/thecodeforge/t3-stack/
├── src/
│   ├── app/                          # Next.js App Router
│   │   ├── layout.tsx                # Root layout (Server Component)
│   │   ├── page.tsx                  # Landing page
│   │   ├── dashboard/                # Dashboard feature
│   │   │   ├── layout.tsx            # Dashboard layout with sidebar
│   │   │   ├── page.tsx              # Dashboard overview (Server Component)
│   │   │   ├── posts/                # Posts sub-feature
│   │   │   │   ├── page.tsx          # Posts list (Server Component)
│   │   │   │   ├── [id]/page.tsx     # Post detail (Server Component)
│   │   │   │   └── new/page.tsx      # Create post (Client Component form)
│   │   │   └── settings/             # Settings sub-feature
│   │   │       └── page.tsx          # Settings page
│   │   └── api/
│   │       └── trpc/[trpc]/route.ts  # tRPC API handler
│   │
│   ├── server/                       # Server-side code
│   │   ├── trpc/
│   │   │   ├── trpc.ts               # tRPC initialization + context
│   │   │   ├── routers/
│   │   │   │   ├── _app.ts           # Root router (merges all routers)
│   │   │   │   ├── post.ts           # Post procedures
│   │   │   │   └── user.ts           # User procedures
│   │   │   └── middleware.ts         # Auth, rate limiting middleware
│   │   └── auth.ts                   # Auth.js v5 configuration
│   │
│   ├── components/                   # Shared UI components
│   │   ├── ui/                       # shadcn/ui components (owned code)
│   │   │   ├── button.tsx
│   │   │   ├── table.tsx
│   │   │   └── input.tsx
│   │   └── shared/                   # App-specific shared components
│   │       ├── header.tsx
│   │       └── sidebar.tsx
│   │
│   ├── lib/                          # Shared utilities
│   │   ├── db.ts                     # Prisma client singleton
│   │   ├── utils.ts                  # cn() helper, formatters
│   │   └── validations/              # Shared zod schemas
│   │       ├── post.ts
│   │       └── user.ts
│   │
│   ├── prisma/
│   │   ├── schema.prisma             # Database schema (source of truth)
│   │   └── migrations/               # Migration history
│   │
│   ├── tailwind.config.ts            # Tailwind configuration
│   ├── components.json               # shadcn/ui configuration
│   └── next.config.js                # Next.js configuration
Output
Domain-driven structure: related code together — one directory per feature, not one per technology layer.
Pro Tip: Share Zod Schemas Between Server and Client
Define zod validation schemas in lib/validations/ and import them into both tRPC procedures and React form components. This prevents validation drift — one schema validates on the server and the client, with identical rules.
Production Insight
Domain-driven structure keeps related code together — one directory per feature.
Shared zod schemas in lib/validations/ prevent validation drift between server and client.
Punchline: organize by domain, not by layer — cognitive overhead kills velocity at 50+ procedures.
Key Takeaway
Domain-driven structure keeps related code together — one directory per feature, not one per technology.
Shared zod schemas in lib/validations/ prevent validation drift between server and client.
Punchline: organize by domain, not by layer — cognitive overhead kills velocity at 50+ procedures.
Project Structure Decisions
IfSmall project with fewer than 10 procedures and 20 components
UseFlat structure is fine — domain-driven adds overhead for small codebases
IfMedium project with 10-50 procedures across multiple features
UseUse domain-driven structure — one directory per feature with colocated router, components, and types
IfLarge project with 50+ procedures and multiple teams
UseUse domain-driven with package boundaries — each feature could become a separate npm package
IfShared types needed across multiple features
UsePlace in lib/validations/ or lib/types/ — single source of truth, imported by all features

Auth That Doesn't Leak: NextAuth.js in Production

You don't get to skip authentication just because you're building a portfolio. The T3 stack ships NextAuth.js for a reason — it plugs into your Prisma schema, your tRPC context, and your type chain without forcing you to write glue code.

The trap is thinking auth is just a login button. It's not. It's session invalidation, role guards, and audit trails. NextAuth.js gives you adapters for Prisma out of the box. That means your User model, Session model, and Account model are all typed end-to-end. When you call getServerSession in a tRPC middleware, the user object is fully typed. No type assertions. No runtime surprises.

The real win: you define your auth logic once, and every route, every query, every mutation inherits it. You don't scatter if (!session) in every resolver. You wrap your procedures with protectedProcedure and move on. That's the difference between a demo and a deploy.

AuthMiddleware.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// io.thecodeforge — javascript tutorial

import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';
import { authOptions } from './auth';

export const t = initTRPC.create();

const isAuthenticated = t.middleware(async ({ ctx, next }) => {
  const session = await getServerSession(ctx.req, ctx.res, authOptions);
  if (!session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { user: session.user } });
});

export const protectedProcedure = t.procedure.use(isAuthenticated);

// Usage in a resolver:
export const guestbookRouter = t.router({
  addEntry: protectedProcedure
    .input(z.object({ message: z.string().max(500) }))
    .mutation(async ({ ctx, input }) => {
      const entry = await ctx.prisma.guestbookEntry.create({
        data: { message: input.message, userId: ctx.user.id },
      });
      return entry;
    }),
});
Output
No output — this is a middleware pattern. The tRPC resolver returns the created guestbook entry object.
Production Trap:
Never trust session.user directly. Always validate against your database in the middleware. Sessions can be stale or tampered with if you're not using JWT on the edge.
Key Takeaway
Wrap auth into a tRPC middleware once, reuse protectedProcedure everywhere. Don't repeat session checks.

Edge Runtime: Prisma Without the Connection Pool Panic

Edge functions are stateless and short-lived. That kills traditional database connections. Prisma’s default connection pooling assumes a persistent server, but in a serverless edge environment, each invocation gets a fresh runtime. Open too many connections and your database hits its limit—connection pool panic. The fix is straightforward: use Prisma Accelerate or a direct data-proxy connection that routes queries through a centralized pooler. This avoids holding open connections in the edge function itself. For smaller projects, you can also disable connection pooling entirely by setting connectionLimit: 0 in the Postgres connection string. But the real point is architectural: don’t manage pools at the edge. They don’t live long enough. Offload persistence to a middleware layer that can hold connections steady. Your edge function queries Firebase, Neon’s serverless driver, or Prisma Accelerate, not Postgres directly. That keeps cold starts fast and database connections under control.

prisma-edge-safe.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — edge-safe prisma client

import { PrismaClient } from '@prisma/client'
import { PrismaNeon } from '@prisma/adapter-neon'
import { Pool } from '@neondatabase/serverless'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaNeon(pool)
const prisma = new PrismaClient({ adapter })

export async function GET(request) {
  const users = await prisma.user.findMany()
  return Response.json(users)
}
Output
[
{ "id": 1, "name": "Alex", "email": "alex@example.com" }
]
Production Trap:
Never instantiate PrismaClient inside an edge handler repeatedly. Use a singleton pattern or the Neon adapter shown above to reuse a single connection across invocations.
Key Takeaway
Edge functions must never manage persistent database pools—route queries through a serverless adapter or data proxy instead.
● Production incidentPOST-MORTEMseverity: high

Prisma migration drops column still referenced by tRPC procedures — silent runtime errors for 3 hours

Symptom
API endpoints returned 500 errors with 'Unknown field legacy_status'. No errors during build — TypeScript compiled because the Prisma client hadn't been regenerated on the deployment server. The deployed Prisma client was stale.
Assumption
The team assumed that running prisma migrate deploy during CI would regenerate the client. They didn't realize the build step ran before the migration step, so the old client was baked into the deployment artifact.
Root cause
The CI pipeline ran next build before prisma migrate deploy. The build used the old Prisma client (which still had legacy_status in its types). The migration then dropped the column. The deployed application had a client that referenced a column that no longer existed.
Fix
Reordered CI pipeline: (1) prisma migrate deploy, (2) prisma generate, (3) next build. Added a CI step that runs prisma validate before build to catch schema drift. Added prisma generate as a postinstall script to ensure the client always matches the checked-in schema.
Key lesson
  • Always run prisma generate AFTER prisma migrate deploy in CI — the client must match the deployed schema
  • Add prisma generate to postinstall scripts so the client regenerates on every install
  • Stale Prisma client types create a false sense of safety — TypeScript compiles but runtime fails
  • Run prisma validate in CI before build to catch schema drift between migrations and client
Production debug guideCommon T3 Stack failures and how to diagnose them7 entries
Symptom · 01
tRPC procedure returns type error at runtime but compiles fine
Fix
Run prisma generate to regenerate the Prisma client. A stale client has outdated types that don't match the deployed database schema.
Symptom · 02
Server Component fetches data but the page shows 'Loading...' forever
Fix
Check if the component is accidentally marked 'use client'. Server Components with async/await must NOT have 'use client' — it makes them Client Components that can't use async.
Symptom · 03
tRPC query returns stale data after a mutation
Fix
Call trpc.useUtils().entity.invalidate() after the mutation. Without invalidation, TanStack Query's cache serves the old response.
Symptom · 04
Prisma query is slow — P99 latency above 500ms
Fix
Run EXPLAIN ANALYZE on the generated SQL. Check for missing indexes on foreign keys and frequently filtered columns. Add @@index in schema.prisma.
Symptom · 05
shadcn/ui component styles don't match the design system
Fix
Check tailwind.config.js for correct theme extension. shadcn/ui uses CSS variables defined in globals.css — verify the variable names match the component's expected tokens.
Symptom · 06
Build fails with 'Module not found' after adding a new shadcn/ui component
Fix
Run npx shadcn-ui@latest add [component] from the project root. The command installs dependencies and copies the component. Manual copy-paste often misses dependency declarations.
Symptom · 07
Hydration mismatch with tRPC query in Server Component
Fix
tRPC queries must run inside a Server Component or use a Client Component wrapper. Move the query to a Server Component or use useSuspenseQuery in a Client Component.
T3 Stack Component Comparison
ComponentProblem It SolvesWhat It Replaces2026 Evolution
Next.js 15/16Rendering, routing, data fetchingExpress + React SPA, manual SSR setupApp Router, Server Components, Server Actions, PPR
tRPC v11Type-safe API layer without code generationREST/GraphQL with manual type definitionsNative TanStack Query v5, streaming, better error types
PrismaType-safe database queries from schemaRaw SQL, Sequelize, TypeORMEdge runtime driver adapters, Prisma Accelerate
Tailwind CSSUtility-first styling with design tokensCSS modules, styled-components, SassOxide engine (10x faster builds), container queries
shadcn/uiOwned component code with accessible primitivesMUI, Ant Design, Chakra (dependency-locked)New York style, improved theming, date picker
TypeScriptEnd-to-end type safety across the stackJavaScript (no type checking)Satisfies operator, const type parameters, isolated declarations

Key takeaways

1
The T3 Stack's core value is the type chain
schema.prisma → Prisma Client → tRPC → React — one change propagates everywhere.
2
tRPC v11 eliminates manual API type synchronization
define a procedure once, the client gets autocompletion and caching for free.
3
shadcn/ui gives you component source code you own
no version-lock dependency, no abstraction fights.
4
Connection pooling is mandatory for serverless Prisma
without it, you exhaust database connections at ~100 concurrent users.
5
Organize by domain, not by layer
one directory per feature keeps related code together at scale.

Common mistakes to avoid

5 patterns
×

Not regenerating Prisma client after schema changes in CI

Symptom
TypeScript compiles successfully but runtime queries fail with 'Unknown field' errors. The deployed Prisma client is stale — its types don't match the actual database schema after a migration.
Fix
Reorder CI pipeline: (1) prisma migrate deploy, (2) prisma generate, (3) next build. Add prisma generate to postinstall scripts. Run prisma validate before build to catch schema drift.
×

Placing 'use client' at the top of the T3 Stack app router (e.g., dashboard/layout.tsx)

Symptom
Every component becomes a Client Component — Server Components are completely disabled. Bundle size is identical to a traditional React SPA. No RSC performance benefits.
Fix
Remove 'use client' from layout.tsx. Only mark leaf components that need useState, useEffect, or event handlers. Keep Server Components as the default.
×

Not invalidating tRPC query cache after mutations

Symptom
User creates a post, sees success message, but the post list still shows old data. The mutation succeeded but TanStack Query's cache wasn't invalidated.
Fix
Call trpc.useUtils().post.list.invalidate() after the create mutation completes. Use onSettled in useMutation options to invalidate regardless of success or failure.
×

Organizing the project by technology layer instead of domain

Symptom
Adding a feature requires touching 5+ directories (routers/, components/, types/, validations/, hooks/). Developers spend more time navigating than coding. Feature ownership is unclear.
Fix
Restructure to domain-driven: each feature gets its own directory with its router, components, and types. Shared code goes in lib/ and components/ui/.
×

Using Prisma without connection pooling in serverless

Symptom
Database connection errors at ~100 concurrent users. Each serverless function opens a new connection — the database's max_connections limit (default 100 for PostgreSQL) is exhausted.
Fix
Use Prisma Accelerate or a database-native pooler (PgBouncer, Neon's built-in pooler). Connection pooling reuses connections across function invocations.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the type chain in the T3 Stack. How does a schema change propaga...
Q02SENIOR
How does tRPC differ from REST and GraphQL? When would you choose one ov...
Q03JUNIOR
What is the difference between shadcn/ui and a traditional component lib...
Q04SENIOR
How does Prisma's edge runtime support work, and why is connection pooli...
Q05SENIOR
Walk me through the recommended CI/CD pipeline for a T3 Stack project. W...
Q01 of 05SENIOR

Explain the type chain in the T3 Stack. How does a schema change propagate type safety through the entire application?

ANSWER
The type chain starts with schema.prisma — Prisma generates a TypeScript client where every query is typed against the schema. The output of a Prisma query flows into tRPC procedures, which infer their return types. On the client, tRPC's useQuery hook infers the response type. React components receive typed props. One schema change breaks TypeScript compilation at every layer that references the changed field — catching errors at compile time instead of runtime.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Can I use the T3 Stack without tRPC?
02
How does shadcn/ui handle component updates?
03
What database should I use with the T3 Stack in 2026?
04
Can I use the T3 Stack with a monorepo?
N
Naren — Founder & Principal Engineer, TheCodeForge
20+ years building production systems in enterprise Java, banking automation, and fintech. I built TheCodeForge because every other tutorial explains what to type but never explains why it works — or what breaks it at 3am. Everything here is drawn from real systems. No content mills. No AI padding.
🔥

That's React.js. Mark it forged?

6 min read · try the examples if you haven't

Previous
How to Build Your Own AI Coding Assistant with Next.js 16, OpenAI & RAG (2026 Stack)
37 / 47 · React.js
Next
10 Advanced shadcn/ui Tricks Most Developers Don't Know