Skip to main content

How to Build a Full-Stack App with the T3 Stack

·PkgPulse Team
0

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

Why the T3 Stack

The T3 Stack was created by Theo Browne as an opinionated answer to a specific question: what's the fastest way to build a production-quality TypeScript web app without compromising on type safety?

The core insight is that TypeScript's value comes from end-to-end coverage. Type errors in your React components are useful. Type errors at the boundary between your React components and your API are where bugs actually ship to production. Most stacks treat this boundary as a gap — you define REST endpoints, write TypeScript types for the responses, and manually keep them in sync. When the API changes, you update the types. Sometimes. The T3 Stack closes that gap.

tRPC is the key component. It generates a type-safe API client directly from your server-side procedure definitions. There's no codegen step, no OpenAPI spec, no GraphQL schema — the TypeScript types flow directly from server to client through tRPC's type inference. If you change the return type of a post.getAll procedure, TypeScript tells you everywhere in your React components that the type changed, at compile time, before you deploy.

This matters more as applications scale. In a 5-component app, keeping types in sync manually is fine. In a 50-component app with 20 API routes, it's a constant maintenance burden. T3's end-to-end types eliminate this entire category of bugs.

The stack is opinionated enough to make decisions for you (Prisma vs Drizzle, NextAuth vs Clerk, Tailwind for styling) but not so opinionated that it prevents you from swapping components. Teams that want a different ORM can use Drizzle in T3 apps. Teams that prefer Clerk for auth can replace NextAuth. The scaffold is a starting point, not a constraint.


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>
  );
}

tRPC vs REST vs GraphQL

Teams evaluating T3 often ask whether tRPC is really better than REST or GraphQL for their use case. The honest answer is that it depends on your client diversity.

tRPC only works when both the client and server are TypeScript and you control both codebases. The type inference that makes tRPC powerful requires access to the server's TypeScript types at compile time. This means tRPC is ideal for full-stack TypeScript applications (Next.js, SvelteKit, Remix + TypeScript backend) where the same developer works on both sides.

REST becomes the right choice when you need to support multiple client types — a public API that third parties consume, a mobile app written in Swift, or a situation where different teams control the frontend and backend independently. REST's interface is language-agnostic; tRPC's is not.

GraphQL makes sense when clients need flexible data fetching — different views need different subsets of the same data and you want to avoid over-fetching. GraphQL's overhead (schema definition, resolver implementation, codegen for typed clients) is only worth it at a scale where that flexibility saves more time than the setup costs.

For the typical T3 target use case — a startup or SaaS product where one team builds a Next.js frontend and Node.js backend — tRPC's zero-overhead type safety is a clear win over REST's manual types and GraphQL's codegen ceremony.


Authentication: NextAuth vs Clerk

The T3 scaffold offers NextAuth (now Auth.js v5) as the default auth option, with Clerk as an alternative. They have meaningfully different tradeoffs.

NextAuth is open source, runs in your own infrastructure, and stores sessions in your database. Configuration is code — you define OAuth providers, database adapter, session strategy, and callbacks in auth.ts. This gives you full control over the auth flow and no additional monthly cost.

Clerk is a managed auth service with a pre-built UI for sign-up, sign-in, user profile, and organization management. The integration is simpler — install the SDK, wrap your app in ClerkProvider, and use auth() to get the current user. Clerk handles email verification, magic links, MFA, and social providers without any configuration code.

The cost difference is real at scale. NextAuth is free (you pay for your database storage). Clerk is free up to 10K monthly active users, then $25-50/month depending on features. For most startups, Clerk's MAU threshold won't matter for a long time and the development velocity saved by not building auth UI is worth the eventual cost.


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

Common Pitfalls

Forgetting to add NEXTAUTH_URL in production. NextAuth requires NEXTAUTH_URL to be set to your production URL for OAuth callbacks to work. It defaults to localhost:3000 in development, but this environment variable is required in production. Vercel doesn't set it automatically.

Using Prisma on serverless without connection pooling. Prisma creates a new database connection per invocation on serverless functions, which can exhaust your database's connection limit under moderate traffic. Add PgBouncer or use Neon's serverless driver with the @neondatabase/serverless package to get connection pooling built in.

Running prisma generate on deployment. The Prisma client is generated at install time via a postinstall script, but Vercel's build cache can skip this. Add "vercel-build": "prisma generate && next build" to your package.json scripts to ensure Prisma is always generated before the Next.js build.

tRPC input validation errors reaching the client as opaque 500s. Zod validation errors in tRPC procedures are thrown as TRPCError with code: 'BAD_REQUEST' by default. Configure the tRPC error formatter in trpc.ts to expose the Zod issue details in development so you can see which field failed validation.


Environment Variable Management with t3-env

T3 Stack applications use many environment variables — database URLs, OAuth credentials, API keys, and feature flags. Accessing these through process.env.NEXT_PUBLIC_* directly has two failure modes: you don't discover a missing variable until it causes a runtime error, and there's no type safety (TypeScript treats process.env.X as string | undefined).

t3-env (@t3-oss/env-nextjs) solves both problems by declaring your environment variables as a Zod schema that's validated at build time. If a required variable is missing, the build fails with a clear error instead of a confusing runtime crash:

// src/env.ts
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    NEXTAUTH_SECRET: z.string().min(32),
    GITHUB_CLIENT_ID: z.string(),
    GITHUB_CLIENT_SECRET: z.string(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
    GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
});

Import env instead of process.env throughout your codebase. TypeScript infers the correct type from your Zod schema (DATABASE_URL is string, not string | undefined), eliminating the null checks that would otherwise litter your code.

Drizzle ORM as an Alternative to Prisma

The T3 scaffold offers Drizzle as an alternative to Prisma. The choice reflects different philosophies about ORM design.

Prisma uses a separate schema file (schema.prisma) with its own DSL. The schema defines models, relations, and migrations in one place, with Prisma generating a type-safe client from it. This is the higher-abstraction choice — you work with Prisma's client API rather than SQL, and Prisma handles query optimization and type generation.

Drizzle uses TypeScript to define the schema. Your schema is TypeScript code that Drizzle interprets to generate SQL. The query API exposes more of the underlying SQL — you write db.select().from(users).where(eq(users.id, id)) instead of prisma.user.findUnique({ where: { id } }). This closer-to-SQL approach gives more control over query shape and makes complex joins more explicit.

The practical difference in T3 applications: Prisma's abstraction produces cleaner application code for standard CRUD operations but adds a compilation step (prisma generate) that can catch newcomers off guard. Drizzle's TypeScript-first approach integrates more naturally with existing TypeScript tooling and has no codegen step.

Both work with Neon, PlanetScale, and standard PostgreSQL. For new T3 projects, Prisma remains the most-documented choice with the richest Next.js ecosystem integration. Drizzle is the better choice for teams comfortable with SQL who want tighter control over query behavior.

Prisma Studio and Database Exploration

Prisma Studio provides a GUI for your database that runs locally and connects via the same DATABASE_URL that your application uses:

npx prisma studio
# Opens at http://localhost:5555

The interface shows all tables from your schema, allows browsing and filtering records, and supports basic CRUD operations. For debugging data issues during development — checking that a mutation actually wrote to the database, inspecting relation data, verifying migration results — Prisma Studio eliminates the need to write raw SQL queries.

For production databases, Prisma Studio requires your production DATABASE_URL, which is a security consideration. Use it with read-only database credentials if inspection access is needed in production, and never commit production credentials to your repository.

Frequently Asked Questions

Is the T3 Stack suitable for large teams?

Yes, with some adaptations. The T3 Stack's end-to-end TypeScript type safety scales well with team size — TypeScript's compiler catches integration errors that would otherwise be caught only in code review or production. For larger teams, the recommended adjustments are: splitting tRPC routers by domain (one file per domain area), adding explicit API versioning conventions for public endpoints, and establishing team conventions around procedure naming and error handling. The T3 Stack doesn't dictate these conventions, but establishing them early prevents divergence as the team grows.

How does tRPC compare to a REST API for mobile apps?

tRPC requires TypeScript on both client and server and works only when you control both codebases. A React Native mobile app in the same monorepo can use tRPC with the same type safety as the Next.js web app. If your mobile app is in Swift or Kotlin (separate codebase, different team), tRPC doesn't help — REST with an OpenAPI spec is the better choice for cross-language API consumption. The T3 Stack's tRPC choice assumes a TypeScript monorepo for both web and any API consumers.

What's the upgrade path when T3 Stack packages have major versions?

The T3 Stack bundles several packages that release independently (Next.js, tRPC, Prisma, Auth.js). Major version upgrades require checking each package's migration guide separately. The T3 community maintains migration guides for common upgrade paths. The most disruptive historical upgrades have been Next.js's Pages Router to App Router migration and Auth.js v4 to v5. Starting new projects with the current scaffold defaults avoids these migrations, but established projects may need to work through them as ecosystem support shifts.

Can I use the T3 Stack without a database?

Yes. The database and ORM are optional — you can scaffold without Prisma or Drizzle. tRPC procedures can fetch data from external APIs, read from files, or use any data source. The most common database-less T3 setup uses an external service for data (a headless CMS like Sanity, or a third-party API) with tRPC as the type-safe API layer and Clerk for authentication. This setup is common for content sites that want T3's type safety without the operational overhead of managing a database.

Compare T3 Stack packages on PkgPulse. Related: Best Monorepo Tools 2026, Best Next.js Auth Solutions 2026, and Best JavaScript Testing Frameworks 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.