The T3 Stack was created to solve one specific problem: full-stack TypeScript apps where the type safety falls apart at the API boundary. You define types on the server, you redefine them on the client, and when they drift apart you get runtime bugs that TypeScript never caught.
tRPC eliminates that boundary. You call server functions from the client like local functions, and TypeScript knows the exact input and output types of every call. Combined with Drizzle ORM (typed queries from your database schema) and Neon (serverless Postgres with a free tier), you get a stack where types flow from the database all the way to the UI component.
The full guide with complete code is at stacknotice.com/blog/t3-stack-complete-guide-2026
What the Modern T3 Stack Looks Like in 2026
The original T3 Stack defaulted to Prisma + PlanetScale. PlanetScale dropped their free tier in 2024, and Prisma has meaningful overhead in serverless environments. Most new projects now use:
- Next.js 15 App Router (not Pages Router)
- tRPC v11 with React Query integration
- Drizzle ORM (not Prisma — faster, lighter, SQL-first)
- Neon (not PlanetScale — free tier still alive, serverless HTTP driver)
- Clerk for auth (not NextAuth — simpler, fewer sessions to manage)
Bootstrap With create-t3-app
npm create t3-app@latest my-app
Select:
- TypeScript: Yes
- tRPC: Yes
- Drizzle: Yes (not Prisma)
- Auth: No (we'll add Clerk manually)
- Tailwind: Yes
Drizzle Schema as Your Source of Truth
// src/server/db/schema.ts
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core'
export const tasks = pgTable('tasks', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
done: boolean('done').notNull().default(false),
userId: text('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
// TypeScript type inferred from the schema — no duplication
export type Task = typeof tasks.$inferSelect
export type NewTask = typeof tasks.$inferInsert
The $inferSelect and $inferInsert types eliminate the separate interface you'd write with Prisma. Change a column, the type changes everywhere.
Database Connection with Neon HTTP Driver
// src/server/db/index.ts
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'
import * as schema from './schema'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
The Neon HTTP driver works in Edge Runtime and serverless functions without a connection pool to manage. For long-running Node.js servers, use the WebSocket driver instead.
tRPC Router with Authentication
// src/server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { auth } from '@clerk/nextjs/server'
const t = initTRPC.context<{ userId: string | null }>().create()
// Only authenticated users can call protected procedures
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, userId: ctx.userId } })
})
export const createTRPCRouter = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed)
// src/server/api/routers/tasks.ts
import { z } from 'zod'
import { createTRPCRouter, protectedProcedure } from '../trpc'
import { db } from '@/server/db'
import { tasks } from '@/server/db/schema'
import { eq, and } from 'drizzle-orm'
export const tasksRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return db.select().from(tasks).where(eq(tasks.userId, ctx.userId))
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(200) }))
.mutation(async ({ ctx, input }) => {
const [task] = await db
.insert(tasks)
.values({ title: input.title, userId: ctx.userId })
.returning()
return task!
}),
toggle: protectedProcedure
.input(z.object({ id: z.string(), done: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const [task] = await db
.update(tasks)
.set({ done: input.done, updatedAt: new Date() })
.where(and(eq(tasks.id, input.id), eq(tasks.userId, ctx.userId)))
.returning()
return task!
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await db
.delete(tasks)
.where(and(eq(tasks.id, input.id), eq(tasks.userId, ctx.userId)))
}),
})
Notice the ownership check in toggle and delete: and(eq(tasks.id, input.id), eq(tasks.userId, ctx.userId)). Never trust the client's claim that a task belongs to them — always filter by both ID and userId.
Client Component with Optimistic Updates
'use client'
import { api } from '@/trpc/react'
import { useState } from 'react'
export function TaskList() {
const utils = api.useUtils()
const { data: tasks, isLoading } = api.tasks.list.useQuery()
const toggle = api.tasks.toggle.useMutation({
onMutate: async ({ id, done }) => {
await utils.tasks.list.cancel()
const previous = utils.tasks.list.getData()
utils.tasks.list.setData(undefined, (old) =>
old?.map((t) => (t.id === id ? { ...t, done } : t))
)
return { previous }
},
onError: (_, __, ctx) => {
utils.tasks.list.setData(undefined, ctx?.previous)
},
onSettled: () => utils.tasks.list.invalidate(),
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={(e) => toggle.mutate({ id: task.id, done: e.target.checked })}
/>
<span className={task.done ? 'line-through opacity-50' : ''}>
{task.title}
</span>
</li>
))}
</ul>
)
}
The onMutate → onError → onSettled pattern gives you instant UI feedback with automatic rollback if the server rejects the mutation.
Server-Side Prefetching
For pages that need data immediately (no loading spinner on first paint):
// app/dashboard/page.tsx
import { HydrateClient, api } from '@/trpc/server'
import { TaskList } from './_components/TaskList'
export default async function DashboardPage() {
// Prefetch on the server — data is ready when the component mounts
void api.tasks.list.prefetch()
return (
<HydrateClient>
<TaskList />
</HydrateClient>
)
}
HydrateClient serializes the prefetched data into the HTML. The client-side TaskList component finds the data already in the React Query cache when it mounts — no loading state, no extra fetch.
Summary
The T3 Stack in 2026 is cleaner than it's ever been:
- Drizzle gives you SQL-native queries with TypeScript types inferred from the schema
- Neon gives you serverless Postgres that works in Edge Runtime
-
tRPC v11 with
protectedProceduregives you auth at the API layer without middleware duplication -
Optimistic updates with
onMutate/onError/onSettledgive you fast UI without complexity
The full guide with environment setup, migrations, form validation with Zod + React Hook Form, and deployment is at stacknotice.com/blog/t3-stack-complete-guide-2026.
Top comments (0)