Build a T3 App
create-t3-app walkthrough. Real end-to-end type safety.
Enough theory. In this lesson you generate a fresh T3 app, walk through what the CLI prompts you for, then trace one feature end to end: a database column, through a tRPC procedure, into a React component, with types flowing the whole way. By the end you'll know exactly where to add a new feature in a T3 app.
Scaffold a project
pnpm create t3-app@latest
# or with npm
npm create t3-app@latest
# or with bun
bun create t3-app@latestYou'll be asked a handful of questions. The good defaults are:
? What will your project be called? my-app
? Will you be using TypeScript or JavaScript? TypeScript
? Will you be using Tailwind CSS for styling? Yes
? Would you like to use tRPC? Yes
? What authentication provider would you like to use? Auth.js
? What database ORM would you like to use? Drizzle
? Would you like to use Next.js App Router? Yes
? What database provider would you like to use? PostgreSQL
? Should we initialize a Git repository and install dependencies? YesFive seconds of installing, then you're ready to go:
cd my-app
cp .env.example .env
# fill in DATABASE_URL and AUTH_SECRET, then:
pnpm db:push # apply your schema to the database
pnpm devDATABASE_URLis missing or malformed, the dev server refuses to start with a precise error. Don't skip copying .env.example to .env and filling it in.The generated structure (Drizzle + App Router)
src/
โโโ app/
โ โโโ _components/ # shared client components
โ โโโ api/
โ โ โโโ auth/[...nextauth]/route.ts
โ โ โโโ trpc/[trpc]/route.ts
โ โโโ layout.tsx
โ โโโ page.tsx
โโโ server/
โ โโโ auth/
โ โ โโโ config.ts # Auth.js options
โ โ โโโ index.ts # auth() helper for RSCs
โ โโโ api/
โ โ โโโ root.ts # appRouter
โ โ โโโ trpc.ts # publicProcedure / protectedProcedure
โ โ โโโ routers/
โ โ โโโ post.ts # example procedures
โ โโโ db/
โ โโโ index.ts # drizzle client
โ โโโ schema.ts # tables
โโโ styles/globals.css # tailwind directives
โโโ trpc/
โ โโโ react.tsx # TRPCReactProvider + hooks
โ โโโ server.ts # server caller + HydrateClient
โ โโโ query-client.ts # QueryClient factory
โโโ env.js # zod-validated envEnd-to-end walkthrough: add a "published" flag to posts
This is the dance. Schema โ procedure โ component. We'll add a column, expose it through tRPC, and read it in a server component.
1. Schema (Drizzle)
import { pgTable, serial, varchar, text, boolean, timestamp } from "drizzle-orm/pg-core";
export const posts = pgTable("post", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 256 }).notNull(),
body: text("body").notNull(),
published: boolean("published").default(false).notNull(), // NEW
createdAt: timestamp("created_at").defaultNow().notNull(),
});pnpm db:push # syncs schema to your dev databaseDrizzle's inferred type already includes published: boolean. There's nothing to regenerate.
2. Procedure
import { z } from "zod";
import { eq } from "drizzle-orm";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "@/server/api/trpc";
import { posts } from "@/server/db/schema";
export const postRouter = createTRPCRouter({
list: publicProcedure
.input(z.object({ onlyPublished: z.boolean().default(true) }))
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(posts)
.where(input.onlyPublished ? eq(posts.published, true) : undefined)
.orderBy(posts.createdAt);
}),
publish: protectedProcedure
.input(z.object({ id: z.number().int() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.update(posts).set({ published: true }).where(eq(posts.id, input.id));
}),
});Hover input in your editor. TypeScript already knows onlyPublished is a boolean with a default. Hover the return value of ctx.db.select(): it knows every column on posts, including the new published field.
3. Component (server-rendered)
import { api, HydrateClient } from "@/trpc/server";
import { PostFeed } from "./_components/post-feed";
export default async function Home() {
// Prefetch on the server. Result is inlined into the client cache.
void api.post.list.prefetch({ onlyPublished: true });
return (
<HydrateClient>
<main className="container mx-auto py-12">
<h1 className="text-3xl font-bold">Latest posts</h1>
<PostFeed />
</main>
</HydrateClient>
);
}"use client";
import { api } from "@/trpc/react";
export function PostFeed() {
const [posts] = api.post.list.useSuspenseQuery({ onlyPublished: true });
return (
<ul className="mt-6 space-y-4">
{posts.map((post) => (
<li key={post.id} className="rounded border p-4">
<h2 className="font-semibold">{post.title}</h2>
<p className="text-sm text-gray-500">
{post.published ? "Published" : "Draft"}
</p>
</li>
))}
</ul>
);
}Type the component, hit save, refresh: posts on screen. No loading spinner, because the data was already in the React Query cache thanks to HydrateClient.
4. A mutation with optimistic update
"use client";
import { api } from "@/trpc/react";
export function PublishButton({ id }: { id: number }) {
const utils = api.useUtils();
const publish = api.post.publish.useMutation({
async onMutate({ id }) {
await utils.post.list.cancel();
const previous = utils.post.list.getData({ onlyPublished: true }) ?? [];
utils.post.list.setData({ onlyPublished: true }, (old = []) =>
old.map((p) => (p.id === id ? { ...p, published: true } : p)),
);
return { previous };
},
onError(_e, _v, ctx) {
if (ctx?.previous) utils.post.list.setData({ onlyPublished: true }, ctx.previous);
},
onSettled() {
void utils.post.list.invalidate();
},
});
return (
<button onClick={() => publish.mutate({ id })} disabled={publish.isPending}>
{publish.isPending ? "Publishing..." : "Publish"}
</button>
);
}schema.ts โ routers/... โ page.tsx โ _components/.... That's the spine of every T3 feature. If you can find these four files in any T3 codebase, you can ship in it.Useful scripts T3 ships with
pnpm dev # start the dev server with turbopack
pnpm build # production build
pnpm lint # eslint
pnpm typecheck # tsc --noEmit
# database (Drizzle)
pnpm db:push # push schema to dev DB (no migration files)
pnpm db:generate # generate migration files
pnpm db:migrate # apply migrations
pnpm db:studio # open Drizzle Studio in a browser tabWhat to do after pnpm dev
- Open
schema.ts. Adjust the example table or add your own. - Run
pnpm db:pushto sync. Opendb:studioif you want a UI for it. - Add or edit a procedure in
routers/post.ts(rename or copy this file for new resources). - Register the router in
server/api/root.tsif it's new. - Read it in a server component (with
api.x.y.prefetch+HydrateClient) or a client component (withapi.x.y.useQuery).
Common pitfalls
- Forgetting to register a router in
root.ts. Your procedure exists butapi.xis undefined on the client. - Querying inside a Client Component without a provider. The TRPCReactProvider must wrap the page. T3 puts it in
src/app/layout.tsxby default. - Importing server-only modules into Client Components.
@/server/dbuses Node APIs; importing it from a"use client"file will throw at build. Go through tRPC instead. - Skipping env validation. If you bypass
env.jsand readprocess.env.Xdirectly, you lose the typed safety. Always import from@/env.
Quiz
After editing schema.ts to add a column in a Drizzle T3 app, what's the next command?
Recap
pnpm create t3-app@latestscaffolds a project with your choices for ORM, auth, and Tailwind.- Backend code lives under
src/server/; client tRPC plumbing lives undersrc/trpc/. - A feature flows: schema โ procedure โ Server Component prefetch + HydrateClient โ Client Component with useSuspenseQuery.
- Mutations follow the standard React Query optimistic-update shape through
api.useUtils(). env.jsis your safety net. Don't bypass it.