webdev.complete
๐Ÿ—๏ธ The T3 Stack โ€” Putting It Together
๐Ÿš€Next.js & T3
Lesson 101 of 117
40 min

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

bash
pnpm create t3-app@latest

# or with npm
npm create t3-app@latest

# or with bun
bun create t3-app@latest

You'll be asked a handful of questions. The good defaults are:

bash
? 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? Yes

Five seconds of installing, then you're ready to go:

bash
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 dev
env.js will yell
T3 validates every env var with Zod on startup. If DATABASE_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)

bash
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 env

End-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)

src/server/db/schema.ts
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(),
});
bash
pnpm db:push      # syncs schema to your dev database

Drizzle's inferred type already includes published: boolean. There's nothing to regenerate.

2. Procedure

src/server/api/routers/post.ts
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)

src/app/page.tsx
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>
  );
}
src/app/_components/post-feed.tsx
"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

src/app/_components/publish-button.tsx
"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>
  );
}
Trace any feature through these four files
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

bash
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 tab

What to do after pnpm dev

  1. Open schema.ts. Adjust the example table or add your own.
  2. Run pnpm db:push to sync. Open db:studio if you want a UI for it.
  3. Add or edit a procedure in routers/post.ts (rename or copy this file for new resources).
  4. Register the router in server/api/root.tsif it's new.
  5. Read it in a server component (with api.x.y.prefetch + HydrateClient) or a client component (with api.x.y.useQuery).

Common pitfalls

  • Forgetting to register a router in root.ts. Your procedure exists but api.x is 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.tsx by default.
  • Importing server-only modules into Client Components. @/server/db uses Node APIs; importing it from a "use client" file will throw at build. Go through tRPC instead.
  • Skipping env validation. If you bypass env.js and read process.env.X directly, you lose the typed safety. Always import from @/env.

Quiz

Quiz1 / 3

After editing schema.ts to add a column in a Drizzle T3 app, what's the next command?

Recap

  • pnpm create t3-app@latest scaffolds a project with your choices for ORM, auth, and Tailwind.
  • Backend code lives under src/server/; client tRPC plumbing lives under src/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.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.