Skip to main content

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.