Introduction
The T3 Stack (create-t3-app) is the gold standard for TypeScript full-stack development. It provides end-to-end type safety from your database to your frontend. This guide covers everything you need to build production apps with T3.
What Is T3 Stack?
The Components
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ T3 Stack Architecture โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Next.js (App Router) โ โ
โ โ React Server Components โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ tRPC โ โ
โ โ Type-safe API calls โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Prisma ORM โ โ
โ โ Database access โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ PostgreSQL โ โ
โ โ (Supabase / Neon) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Tailwind CSS + NextAuth.js โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Quick Start
# Create T3 app
npm create t3-app@latest my-startup
# Select:
# โ TypeScript
# โ Tailwind CSS
# โ tRPC
# โ Prisma
# โ NextAuth.js
# โ App Router
Database with Prisma
Schema Definition
// 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
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Post {
id String @id @default(cuid())
name String
content String @db.Text
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Type-Safe Queries
// src/server/api/routers/post.ts
import { z } from 'zod'
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc'
export const postRouter = createTRPCRouter({
// Public: read all posts
getAll: publicProcedure.query(async ({ ctx }) => {
return ctx.db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
})
}),
// Public: get single post
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.post.findUnique({
where: { id: input.id },
})
}),
// Protected: create post
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
content: z.string().min(10),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
name: input.name,
content: input.content,
authorId: ctx.session.user.id,
},
})
}),
// Protected: update post
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).optional(),
content: z.string().min(10).optional(),
published: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return ctx.db.post.update({
where: { id },
data,
})
}),
// Protected: delete post
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.delete({
where: { id: input.id },
})
}),
})
tRPC API
Setting Up
// src/server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await getServerSession(authOptions)
return {
prisma,
session,
...opts,
}
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const createCallerFactory = t.createCallerFactory
export const router = t.router
export const publicProcedure = t.procedure
React Query Integration
// src/utils/api.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { useState } from 'react'
import superjson from 'superjson'
import type { AppRouter } from '@/server/api/root'
export const api = createTRPCReact<AppRouter>()
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
api.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
})
)
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</api.Provider>
)
}
Using API in Components
// src/app/page.tsx
import { api } from '@/trpc/react'
export default function Home() {
// This is fully typed!
const { data: posts, isLoading } = api.post.getAll.useQuery()
if (isLoading) return <div>Loading...</div>
return (
<main>
<h1>Posts</h1>
{posts?.map((post) => (
<div key={post.id}>{post.name}</div>
))}
</main>
)
}
// src/app/create-post.tsx
'use client'
import { useState } from 'react'
import { api } from '@/trpc/react'
export function CreatePost() {
const [name, setName] = useState('')
const [content, setContent] = useState('')
const createPost = api.post.create.useMutation({
onSuccess: () => {
setName('')
setContent('')
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
createPost.mutate({ name, content })
}}
>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Title"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Content"
/>
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
Tailwind CSS
Configuration
// tailwind.config.ts
import { type Config } from 'tailwindcss'
import { fontFamily } from 'tailwindcss/defaultTheme'
export default {
content: ['./src/**/*.tsx'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
},
},
},
plugins: [require('@tailwindcss/forms')],
} satisfies Config
Example Components
// src/components/PostCard.tsx
interface PostCardProps {
post: {
id: string
name: string
content: string
published: boolean
}
}
export function PostCard({ post }: PostCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md">
<h3 className="text-lg font-semibold text-gray-900">
{post.name}
</h3>
<p className="mt-2 text-gray-600">{post.content}</p>
<div className="mt-4 flex items-center gap-2">
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
post.published
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{post.published ? 'Published' : 'Draft'}
</span>
</div>
</div>
)
}
Key Takeaways
- End-to-end type safety - From database to frontend
- tRPC - No API documentation needed
- Prisma - Type-safe database queries
- Tailwind - Rapid styling
Comments