Skip to content
Homeβ€Ί JavaScriptβ€Ί The New T3 Stack in 2026 – Complete Updated Guide

The New T3 Stack in 2026 – Complete Updated Guide

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 37 of 38
Everything you need to know about the modern T3 Stack with Next.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Everything you need to know about the modern T3 Stack with Next.
  • The T3 Stack's core value is the type chain: schema.prisma β†’ Prisma Client β†’ tRPC β†’ React β€” one change propagates everywhere.
  • tRPC v11 eliminates manual API type synchronization β€” define a procedure once, the client gets autocompletion and caching for free.
  • shadcn/ui gives you component source code you own β€” no version-lock dependency, no abstraction fights.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑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
Production IncidentPrisma migration drops column still referenced by tRPC procedures β€” silent runtime errors for 3 hoursA team ran a Prisma migration that dropped a 'legacy_status' column. The migration succeeded, Prisma client regenerated, but tRPC procedures still referenced the column. Production returned 500 errors for 3 hours until a developer noticed the Prisma client type errors in CI that were ignored.
SymptomAPI 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.
AssumptionThe 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 causeThe 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.
FixReordered 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 schemaAdd prisma generate to postinstall scripts so the client regenerates on every installStale Prisma client types create a false sense of safety β€” TypeScript compiles but runtime failsRun prisma validate in CI before build to catch schema drift between migrations and client
Production Debug GuideCommon T3 Stack failures and how to diagnose them
tRPC procedure returns type error at runtime but compiles fine→Run prisma generate to regenerate the Prisma client. A stale client has outdated types that don't match the deployed database schema.
Server Component fetches data but the page shows 'Loading...' forever→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.
tRPC query returns stale data after a mutation→Call trpc.useUtils().entity.invalidate() after the mutation. Without invalidation, TanStack Query's cache serves the old response.
Prisma query is slow — P99 latency above 500ms→Run EXPLAIN ANALYZE on the generated SQL. Check for missing indexes on foreign keys and frequently filtered columns. Add @@index in schema.prisma.
shadcn/ui component styles don't match the design system→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.
Build fails with 'Module not found' after adding a new shadcn/ui component→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.
Hydration mismatch with tRPC query in Server Component→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.

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.

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.prisma Β· TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243
// 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
Mental Model
Type Chain Mental Model
Think of the type chain like a river flowing downhill β€” water (types) at the source (schema) automatically reaches every tributary (API, components, forms) downstream. Dam the river at the source (change the schema) and every tributary feels it.
  • 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.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
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.ts Β· TYPESCRIPT
12345678910111213141516171819202122232425
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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// 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.txt Β· TEXT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// 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
πŸ—‚ T3 Stack Component Comparison
What each tool solves and what it replaces
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

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

⚠ Common Mistakes to Avoid

    βœ•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 Questions on This Topic

  • QExplain the type chain in the T3 Stack. How does a schema change propagate type safety through the entire application?Mid-levelReveal
    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.
  • QHow does tRPC differ from REST and GraphQL? When would you choose one over the others?Mid-levelReveal
    tRPC provides end-to-end type inference without code generation β€” REST requires manual type definitions or OpenAPI codegen, GraphQL requires schema definitions and codegen. tRPC is best for TypeScript-only stacks where client and server share a codebase. REST is better for public APIs consumed by external clients. GraphQL is better when multiple client types (web, mobile, CLI) need different data shapes from the same API.
  • QWhat is the difference between shadcn/ui and a traditional component library like MUI? Why would you choose one over the other?JuniorReveal
    shadcn/ui copies component source code into your project β€” you own and modify it. MUI ships as an npm package β€” you consume it as a dependency. shadcn/ui gives full customization control but requires you to maintain the components. MUI gives faster initial setup but customization fights the abstraction. Choose shadcn/ui when you need full control; choose MUI when you want a large pre-built set with minimal customization.
  • QHow does Prisma's edge runtime support work, and why is connection pooling mandatory in serverless?SeniorReveal
    Prisma's traditional binary engine requires Node.js β€” incompatible with edge runtimes. Edge support uses driver adapters (Neon, PlanetScale, Turso) that use platform-native drivers. Connection pooling is mandatory because each serverless function opens a new database connection. Without pooling, you exhaust the database's max_connections limit (~100 for PostgreSQL) at moderate traffic. Prisma Accelerate or a database-native pooler reuses connections across invocations.
  • QWalk me through the recommended CI/CD pipeline for a T3 Stack project. What order should build steps run in, and why?SeniorReveal
    The correct order: (1) prisma migrate deploy β€” apply pending migrations, (2) prisma generate β€” regenerate the Prisma client to match the deployed schema, (3) next build β€” compile the application with the correct types. Running build before migrate deploy bakes a stale Prisma client into the artifact. Add prisma validate before build as a safety net to catch schema drift.

Frequently Asked Questions

Can I use the T3 Stack without tRPC?

Yes. The T3 Stack is modular β€” you can replace tRPC with REST API routes, GraphQL, or Server Actions. The type chain still works through Prisma, but you lose end-to-end inference on the API layer. You'd need to manually define request/response types or use OpenAPI codegen. tRPC is the biggest type-safety multiplier in the stack β€” removing it is possible but leaves a gap in the chain.

How does shadcn/ui handle component updates?

shadcn/ui components are copied into your project β€” they don't update automatically like npm packages. Run npx shadcn-ui@latest diff to see what changed upstream. Then manually merge the changes into your customized components. This is intentional: you own the code, so you control when and how updates are applied. No breaking changes surprise you in a minor version bump.

What database should I use with the T3 Stack in 2026?

PostgreSQL is the default and recommended choice β€” Prisma has the deepest support, and edge-compatible options (Neon, Supabase) are mature. MySQL is fully supported. SQLite works for local development and small apps. For edge deployments, use Neon (Postgres) or Turso (SQLite) with Prisma's driver adapters. Avoid databases without Prisma support unless you're willing to write raw SQL.

Can I use the T3 Stack with a monorepo?

Yes β€” the T3 Stack works well in Turborepo or Nx monorepos. The recommended pattern: put the Prisma schema and generated client in a shared package, import it into the Next.js app and any other services. Share zod validation schemas the same way. This prevents schema drift between services that share the same database.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousHow to Build Your Own AI Coding Assistant with Next.js 16, OpenAI & RAG (2026 Stack)Next β†’10 Advanced shadcn/ui Tricks Most Developers Don't Know
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged