How to Build a Full-Stack App with the T3 Stack
·PkgPulse Team
TL;DR
The T3 Stack is the 2026 standard for TypeScript full-stack apps. create-t3-app scaffolds Next.js + tRPC + Prisma + NextAuth + Tailwind with everything wired together. End-to-end TypeScript from database to browser with zero codegen. This guide builds a real app from scaffold to deployment.
Key Takeaways
npm create t3-app@latest— scaffold everything in 60 seconds- End-to-end types: database schema → Prisma types → tRPC procedures → React components
- Auth included: NextAuth v5 (Clerk recommended for production)
- No REST API needed: tRPC procedures replace HTTP routes
- Deploy to Vercel: built for it
Scaffold
npm create t3-app@latest my-app
# Interactive prompts:
# TypeScript? Yes
# Tailwind? Yes
# tRPC? Yes
# Auth? Yes (NextAuth or Clerk)
# ORM? Yes (Prisma or Drizzle)
# CI? Yes (GitHub Actions)
cd my-app
npm install
Project Structure
src/
├── app/ # Next.js App Router
│ ├── _components/ # Shared React components
│ ├── (auth)/ # Auth pages (login, register)
│ ├── dashboard/ # Protected pages
│ └── layout.tsx
├── server/
│ ├── api/
│ │ ├── routers/ # tRPC routers (one per domain)
│ │ │ ├── post.ts
│ │ │ └── user.ts
│ │ ├── root.ts # Root router (combines all)
│ │ └── trpc.ts # tRPC setup, middleware, context
│ ├── auth.ts # NextAuth config
│ └── db.ts # Prisma client
├── trpc/
│ ├── react.tsx # Client-side tRPC setup
│ └── server.ts # Server-side tRPC caller
└── styles/
└── globals.css
Database Schema (Prisma)
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
image String?
createdAt DateTime @default(now())
posts Post[]
sessions Session[]
accounts Account[]
}
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())
updatedAt DateTime @updatedAt
}
// NextAuth required models
model Account { ... }
model Session { ... }
model VerificationToken { ... }
# Push schema to database
npx prisma db push
# Or create migration
npx prisma migrate dev --name init
tRPC Router
// src/server/api/routers/post.ts
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const postRouter = createTRPCRouter({
// Public: anyone can read
getAll: publicProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(20) }))
.query(async ({ ctx, input }) => {
return ctx.db.post.findMany({
where: { published: true },
take: input.limit,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true, image: true } } },
});
}),
// Protected: only authenticated users
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
...input,
authorId: ctx.session.user.id, // ctx.session is guaranteed by protectedProcedure
},
});
}),
publish: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return ctx.db.post.update({
where: { id: input.id },
data: { published: true },
});
}),
});
// src/server/api/root.ts — combine all routers
import { createCallerFactory, createTRPCRouter } from './trpc';
import { postRouter } from './routers/post';
export const appRouter = createTRPCRouter({
post: postRouter,
});
export type AppRouter = typeof appRouter;
React Components with tRPC
// src/app/dashboard/page.tsx — Server Component with tRPC
import { api } from '@/trpc/server';
export default async function DashboardPage() {
// Calls tRPC directly from Server Component — no fetch overhead
const posts = await api.post.getAll({ limit: 10 });
return (
<div>
<h1>My Posts</h1>
{posts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>by {post.author.name}</p>
</div>
))}
<CreatePost />
</div>
);
}
// src/app/_components/create-post.tsx — Client Component with mutations
'use client';
import { api } from '@/trpc/react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
});
export function CreatePost() {
const utils = api.useUtils();
const createPost = api.post.create.useMutation({
onSuccess: () => utils.post.getAll.invalidate(),
});
const form = useForm({ resolver: zodResolver(schema) });
return (
<form onSubmit={form.handleSubmit(data => createPost.mutate(data))}>
<input {...form.register('title')} placeholder="Post title" />
{form.formState.errors.title && <span>{form.formState.errors.title.message}</span>}
<textarea {...form.register('content')} placeholder="Content..." />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Deploy to Vercel
# 1. Push to GitHub
git push origin main
# 2. Import in Vercel
# vercel.com/new → import from GitHub
# 3. Set environment variables in Vercel dashboard:
# DATABASE_URL=postgresql://...
# NEXTAUTH_SECRET=your-secret-here
# NEXTAUTH_URL=https://your-app.vercel.app
# GITHUB_CLIENT_ID=... (if using GitHub OAuth)
# GITHUB_CLIENT_SECRET=...
# 4. Deploy
# Vercel auto-deploys on push to main
Compare T3 Stack packages on PkgPulse.
See the live comparison
View trpc vs. graphql on PkgPulse →