learn()
{ start }
build++
const tutorial

Building a Production-Ready Next.js App: Complete Architecture Guide

Complete guide to building scalable, production-ready Next.js applications with TypeScript, authentication, database, payments, and deployment. Includes code repository access.

Cuppa Team10 min read
nextjstypescriptproductionarchitecturefull-stack

Introduction

Building a toy app is easy. Building a production-ready application that can scale to thousands of users, handle payments, maintain security, and stay performant? That's a different story.

In this premium guide, we'll build a complete, production-ready Next.js application from scratch. You'll get access to the full source code repository, deployment templates, and all the patterns we use at Cuppa to ship production apps.

What You'll Build

By the end of this guide, you'll have built a complete SaaS application with:

  • Authentication - Email/password, OAuth (Google, Apple, GitHub)
  • Database - Supabase with Row Level Security
  • Payments - Stripe subscriptions and one-time payments
  • Email - Transactional emails with Resend
  • Analytics - PostHog for product analytics
  • Monitoring - Sentry for error tracking
  • Deployment - Vercel with preview deployments
  • Testing - Playwright E2E + Vitest unit tests
  • CI/CD - GitHub Actions for automated testing

Project Structure

your-saas-app/
├── src/
│   ├── app/                    # Next.js 14 App Router
│   │   ├── (auth)/            # Auth pages (login, signup, etc.)
│   │   ├── (dashboard)/       # Protected dashboard
│   │   ├── (marketing)/       # Public marketing pages
│   │   ├── api/               # API routes
│   │   │   ├── auth/          # Auth endpoints
│   │   │   ├── stripe/        # Stripe webhooks
│   │   │   └── trpc/          # tRPC API
│   │   └── layout.tsx
│   ├── components/
│   │   ├── ui/                # Reusable UI components
│   │   ├── auth/              # Auth-related components
│   │   ├── dashboard/         # Dashboard components
│   │   └── marketing/         # Marketing components
│   ├── lib/
│   │   ├── auth/              # Auth utilities
│   │   ├── db/                # Database client and queries
│   │   ├── email/             # Email templates and sending
│   │   ├── stripe/            # Stripe integration
│   │   └── utils/             # Shared utilities
│   ├── server/
│   │   ├── routers/           # tRPC routers
│   │   └── context.ts         # tRPC context
│   └── types/                 # TypeScript types
├── tests/
│   ├── e2e/                   # Playwright tests
│   └── unit/                  # Vitest tests
├── public/                    # Static assets
├── supabase/
│   ├── migrations/            # Database migrations
│   └── seed.sql               # Seed data
└── .github/
    └── workflows/             # GitHub Actions

Step 1: Core Setup

Package.json with All Dependencies

{
  "name": "production-saas-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "test": "vitest",
    "test:e2e": "playwright test",
    "db:push": "supabase db push",
    "db:studio": "supabase studio"
  },
  "dependencies": {
    "@supabase/ssr": "^0.1.0",
    "@supabase/supabase-js": "^2.39.0",
    "@tanstack/react-query": "^5.17.0",
    "@trpc/client": "^10.45.0",
    "@trpc/next": "^10.45.0",
    "@trpc/react-query": "^10.45.0",
    "@trpc/server": "^10.45.0",
    "next": "14.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-hook-form": "^7.49.0",
    "stripe": "^14.10.0",
    "zod": "^3.22.4",
    "zustand": "^4.4.7"
  },
  "devDependencies": {
    "@playwright/test": "^1.40.0",
    "@types/node": "^20.10.0",
    "@types/react": "^18.2.0",
    "autoprefixer": "^10.4.16",
    "postcss": "^8.4.32",
    "tailwindcss": "^3.4.0",
    "typescript": "^5.3.0",
    "vitest": "^1.1.0"
  }
}

Environment Variables

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Supabase
NEXT_PUBLIC_SUPABASE_URL=your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

# Email (Resend)
RESEND_API_KEY=re_xxx

# Analytics (PostHog)
NEXT_PUBLIC_POSTHOG_KEY=phc_xxx
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com

# Monitoring (Sentry)
SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx

Step 2: Database Schema

Supabase Tables

-- Users table (managed by Supabase Auth)
-- We extend it with a profiles table

-- Profiles
CREATE TABLE profiles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  email TEXT NOT NULL,
  display_name TEXT,
  avatar_url TEXT,

  -- Subscription
  subscription TEXT DEFAULT 'free' CHECK (subscription IN ('free', 'pro', 'enterprise')),
  subscription_status TEXT CHECK (subscription_status IN ('active', 'canceled', 'past_due', 'trialing')),
  stripe_customer_id TEXT UNIQUE,
  stripe_subscription_id TEXT,
  subscription_started_at TIMESTAMPTZ,
  subscription_expires_at TIMESTAMPTZ,

  -- Metadata
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  last_active_at TIMESTAMPTZ DEFAULT NOW(),

  UNIQUE(user_id),
  UNIQUE(email)
);

-- Projects (example resource)
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES profiles(user_id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT,

  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Row Level Security
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policies
CREATE POLICY "Users can read own profile"
  ON profiles FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can update own profile"
  ON profiles FOR UPDATE
  USING (auth.uid() = user_id);

CREATE POLICY "Users can read own projects"
  ON projects FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can create own projects"
  ON projects FOR INSERT
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update own projects"
  ON projects FOR UPDATE
  USING (auth.uid() = user_id);

CREATE POLICY "Users can delete own projects"
  ON projects FOR DELETE
  USING (auth.uid() = user_id);

-- Indexes
CREATE INDEX idx_profiles_user_id ON profiles(user_id);
CREATE INDEX idx_profiles_stripe_customer_id ON profiles(stripe_customer_id);
CREATE INDEX idx_projects_user_id ON projects(user_id);

-- Updated at trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER profiles_updated_at
  BEFORE UPDATE ON profiles
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at();

CREATE TRIGGER projects_updated_at
  BEFORE UPDATE ON projects
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at();

Step 3: Authentication Setup

Supabase Client (Server-Side)

// src/lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export function createClient() {
  const cookieStore = cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value, ...options })
          } catch (error) {
            // Server component - can't set cookies
          }
        },
        remove(name: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value: '', ...options })
          } catch (error) {
            // Server component - can't remove cookies
          }
        },
      },
    }
  )
}

Auth Context (Client-Side)

// src/contexts/AuthContext.tsx
'use client'

import { createContext, useContext, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { User } from '@supabase/supabase-js'

interface AuthContextType {
  user: User | null
  isLoading: boolean
  signOut: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  const supabase = createClient()

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null)
      setIsLoading(false)
    })

    // Listen for changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null)
    })

    return () => subscription.unsubscribe()
  }, [supabase])

  const signOut = async () => {
    await supabase.auth.signOut()
  }

  return (
    <AuthContext.Provider value={{ user, isLoading, signOut }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) throw new Error('useAuth must be used within AuthProvider')
  return context
}

Step 4: tRPC API Setup

tRPC Context

// src/server/context.ts
import { type CreateNextContextOptions } from '@trpc/server/adapters/next'
import { createClient } from '@/lib/supabase/server'

export async function createContext(opts: CreateNextContextOptions) {
  const supabase = createClient()

  const {
    data: { session },
  } = await supabase.auth.getSession()

  return {
    supabase,
    user: session?.user ?? null,
    session,
  }
}

export type Context = Awaited<ReturnType<typeof createContext>>

tRPC Router

// src/server/routers/projects.ts
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'

export const projectsRouter = router({
  list: protectedProcedure.query(async ({ ctx }) => {
    const { data } = await ctx.supabase
      .from('projects')
      .select('*')
      .eq('user_id', ctx.user.id)
      .order('created_at', { ascending: false })

    return data ?? []
  }),

  create: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        description: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { data, error } = await ctx.supabase
        .from('projects')
        .insert({
          user_id: ctx.user.id,
          name: input.name,
          description: input.description,
        })
        .select()
        .single()

      if (error) throw new Error(error.message)
      return data
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const { error } = await ctx.supabase
        .from('projects')
        .delete()
        .eq('id', input.id)
        .eq('user_id', ctx.user.id)

      if (error) throw new Error(error.message)
    }),
})

Step 5: Stripe Integration

Webhook Handler

// src/app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})

export async function POST(req: Request) {
  const body = await req.text()
  const signature = req.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  const supabase = createClient()

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      const customerId = subscription.customer as string

      await supabase
        .from('profiles')
        .update({
          stripe_subscription_id: subscription.id,
          subscription_status: subscription.status,
          subscription:
            subscription.status === 'active' ? 'pro' : 'free',
        })
        .eq('stripe_customer_id', customerId)

      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      const customerId = subscription.customer as string

      await supabase
        .from('profiles')
        .update({
          subscription: 'free',
          subscription_status: 'canceled',
        })
        .eq('stripe_customer_id', customerId)

      break
    }
  }

  return NextResponse.json({ received: true })
}

Checkout Session Creation

// src/server/routers/billing.ts
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})

export const billingRouter = router({
  createCheckoutSession: protectedProcedure
    .input(
      z.object({
        priceId: z.string(),
        successUrl: z.string().url(),
        cancelUrl: z.string().url(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // Get or create Stripe customer
      let customerId = ctx.profile?.stripe_customer_id

      if (!customerId) {
        const customer = await stripe.customers.create({
          email: ctx.user.email,
          metadata: {
            userId: ctx.user.id,
          },
        })

        customerId = customer.id

        await ctx.supabase
          .from('profiles')
          .update({ stripe_customer_id: customerId })
          .eq('user_id', ctx.user.id)
      }

      // Create checkout session
      const session = await stripe.checkout.sessions.create({
        customer: customerId,
        mode: 'subscription',
        payment_method_types: ['card'],
        line_items: [
          {
            price: input.priceId,
            quantity: 1,
          },
        ],
        success_url: input.successUrl,
        cancel_url: input.cancelUrl,
      })

      return { sessionId: session.id }
    }),
})

Step 6: Testing

E2E Test Example

// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test('should sign up new user', async ({ page }) => {
    await page.goto('/signup')

    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'SecurePassword123!')
    await page.fill('input[name="confirmPassword"]', 'SecurePassword123!')

    await page.click('button[type="submit"]')

    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard')

    // Should show welcome message
    await expect(page.locator('h1')).toContainText('Welcome')
  })

  test('should sign in existing user', async ({ page }) => {
    await page.goto('/login')

    await page.fill('input[name="email"]', 'existing@example.com')
    await page.fill('input[name="password"]', 'password123')

    await page.click('button[type="submit"]')

    await expect(page).toHaveURL('/dashboard')
  })
})

Step 7: Deployment

Vercel Configuration

// vercel.json
{
  "buildCommand": "pnpm build",
  "devCommand": "pnpm dev",
  "installCommand": "pnpm install",
  "framework": "nextjs",
  "regions": ["sfo1"],
  "env": {
    "NEXT_PUBLIC_APP_URL": "@app-url",
    "NEXT_PUBLIC_SUPABASE_URL": "@supabase-url",
    "NEXT_PUBLIC_SUPABASE_ANON_KEY": "@supabase-anon-key"
  }
}

GitHub Actions CI/CD

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install
      - run: pnpm lint
      - run: pnpm type-check
      - run: pnpm test
      - run: pnpm build

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install
      - run: pnpm playwright install --with-deps
      - run: pnpm test:e2e

      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Next Steps

  1. Clone the starter repository (link in premium dashboard)
  2. Follow the setup guide to configure all services
  3. Deploy to Vercel
  4. Start building your features!

Premium Resources

As a premium member, you get access to:

  • Full source code repository - Complete Next.js starter
  • Video walkthrough - 3-hour guided implementation
  • Deployment templates - One-click Vercel setup
  • Production checklist - 50-point launch checklist
  • Live Q&A sessions - Monthly calls with our team
  • Priority support - Direct Slack channel access

Ready to build? Access your premium dashboard to get started.

Questions? Book a 1-on-1 call with our team.