Architecture

A deep dive into how the codebase is structured and the design decisions behind it.

# Overview

Layer Technology Purpose
FrameworkNext.js 15 (App Router)SSR, API routes, middleware
AuthClerkUser management, sessions, OAuth
PaymentsStripeSubscriptions, billing portal, webhooks
DatabasePostgreSQL + PrismaData persistence, type-safe ORM
EmailResendTransactional emails
StylingTailwind CSS 4Utility-first CSS with theme variables
ValidationZodRuntime type validation
BlogMDXStatic content with rich formatting

# Architecture Diagram

                        +------------------+
                        |     Browser      |
                        +--------+---------+
                                 |
                                 | HTTPS
                                 |
                        +--------v---------+
                        |  Next.js App     |
                        |  (App Router)    |
                        |                  |
                        |  +------------+  |
                        |  | Middleware  |  |
                        |  | (Clerk)    |  |
                        |  +------+-----+  |
                        |         |         |
            +-----------+---------+---------+-----------+
            |           |                   |           |
    +-------v---+  +---v--------+  +-------v---+  +---v--------+
    |   Clerk   |  |  Stripe    |  | PostgreSQL|  |   Resend   |
    |   (Auth)  |  | (Payments) |  | (Database)|  |   (Email)  |
    +-----------+  +------------+  +-----------+  +------------+

    Sessions        Subscriptions   Users          Transactional
    OAuth           Checkout        API Keys       Welcome
    User mgmt       Webhooks        Waitlist       Subscription
                    Billing Portal

# Request Flows

Public Page Request

Browser
  -> GET /pricing
  -> Middleware (Clerk) -- not a protected route, pass through
  -> Next.js renders Server Component
  -> Returns HTML

Protected Page Request

Browser
  -> GET /dashboard
  -> Middleware (Clerk) -- matches /dashboard(.*)
  -> auth.protect() checks session
  -> No session? Redirect to /sign-in
  -> Valid session? Continue to page
  -> Server Component calls getCurrentUser()
  -> Prisma fetches user from PostgreSQL
  -> Returns rendered page with user data

Authenticated API Call

Browser
  -> POST /api/stripe/checkout { priceId }
  -> Middleware (Clerk) -- matches /api/stripe(.*)
  -> auth.protect() verifies session
  -> Route handler extracts userId from auth()
  -> Creates Stripe Checkout Session
  -> Returns { url } to redirect user

Stripe Webhook Flow

Stripe
  -> POST /api/webhooks/stripe
  -> Middleware -- does NOT match protected routes, passes through
  -> Route handler reads raw body
  -> Verifies stripe-signature header against STRIPE_WEBHOOK_SECRET
  -> Invalid signature? Returns 400
  -> Parses event type:
     - checkout.session.completed  -> Updates user plan + Stripe fields
     - invoice.payment_succeeded   -> Updates stripeCurrentPeriodEnd
     - customer.subscription.updated -> Updates plan + period end
     - customer.subscription.deleted -> Resets user to FREE plan
  -> Returns { received: true }

Clerk Webhook Flow

Clerk
  -> POST /api/webhooks/clerk
  -> Middleware -- passes through (not a protected route)
  -> Route handler checks svix headers
  -> Parses event type:
     - user.created -> Creates user in DB + sends welcome email
     - user.updated -> Updates user in DB
     - user.deleted -> Deletes user from DB
  -> Returns { received: true }

# Data Model

User Model

The User model uses the Clerk user ID as its primary key (not auto-generated). This creates a direct mapping between Clerk's user system and your database.

Field Type Description
idString (PK)Clerk user ID (user_...)
emailString (unique)User's email address
nameString?Full name
imageUrlString?Profile picture URL
roleRole enumUSER or ADMIN
planPlan enumFREE, PRO, or BUSINESS
stripeCustomerIdString? (unique)Stripe customer ID
stripeSubscriptionIdString? (unique)Active subscription ID
stripePriceIdString?Current price ID
stripeCurrentPeriodEndDateTime?Subscription expiry
createdAtDateTimeAccount creation
updatedAtDateTimeLast update

ApiKey Model

Per-user API keys for programmatic access.

Field Type Description
idString (PK)Auto-generated cuid
nameStringUser-defined key name
keyString (unique)The API key value
userIdString (FK)Owner's Clerk ID
lastUsedDateTime?Last usage timestamp
createdAtDateTimeCreation timestamp

WaitlistEntry Model

Standalone model for collecting emails before launch.

Field Type Description
idString (PK)Auto-generated cuid
emailString (unique)Email address
createdAtDateTimeSignup timestamp

Enums

Role: USER | ADMIN
Plan: FREE | PRO | BUSINESS

# Authentication Architecture

Clerk handles all authentication complexity: sign-up, sign-in, sessions, OAuth, email verification, and password management. Your app never stores passwords.

Middleware Protection

src/middleware.ts defines which routes require authentication:

Protected routes:
  /dashboard(.*)     -> All dashboard pages
  /admin(.*)         -> All admin pages
  /api/stripe(.*)    -> Stripe API routes (checkout, portal)

Public routes (everything else):
  /                  -> Landing page
  /blog(.*)          -> Blog
  /pricing           -> Pricing page
  /sign-in(.*)       -> Auth pages
  /sign-up(.*)       -> Auth pages
  /api/webhooks(.*)  -> Webhook endpoints

Server-Side Auth Helpers

src/lib/auth.ts provides these functions:

Function Returns Use Case
getCurrentUser()User | nullOptional auth check
requireUser()UserProtected pages/API (throws "Unauthorized")
requireAdmin()User (admin)Admin-only pages/API (throws "Forbidden")
syncUser()User | nullCreate/update user in DB from Clerk data
getUserPlan(user)PlanGet current plan
isAdmin(user)booleanCheck admin status
hasActiveSubscription(user)booleanCheck if subscription is active

User Sync Strategy

Users are synced from Clerk to your database in two ways:

  1. Webhook (primary): Clerk sends user.created, user.updated, user.deleted events to /api/webhooks/clerk. This keeps the database in sync even when users manage their account outside your app.
  2. On-demand (fallback): The syncUser() function upserts the current Clerk user data into the database. This catches cases where the webhook might have been missed.

# Payment Architecture

Plan Hierarchy

FREE (default)
  |-- No Stripe subscription
  |-- Limited features (1 project, 100 API calls/month)
  |
PRO ($29/month or $290/year)
  |-- Stripe subscription active
  |-- 10 projects, 10,000 API calls/month
  |-- Priority support, API access, CSV exports
  |
BUSINESS ($99/month or $990/year)
  |-- Stripe subscription active
  |-- Unlimited projects and API calls
  |-- Dedicated support, webhooks, SSO (coming soon)

Checkout Flow

1. User clicks "Subscribe to Pro" on /pricing or /dashboard/billing
2. Frontend sends POST /api/stripe/checkout with { priceId }
3. Backend:
   a. Verifies user is authenticated
   b. Gets or creates Stripe Customer (linked to user)
   c. Creates Stripe Checkout Session
   d. Returns { url } pointing to Stripe-hosted checkout page
4. User completes payment on Stripe
5. Stripe sends checkout.session.completed webhook
6. Webhook handler:
   a. Retrieves subscription details from Stripe
   b. Maps priceId to Plan (PRO or BUSINESS)
   c. Updates user in database (plan, Stripe fields)
   d. Sends subscription confirmation email
7. User is redirected to /dashboard/billing?success=true

Billing Portal

1. User clicks "Manage Subscription" on /dashboard/billing
2. Frontend sends POST /api/stripe/portal
3. Backend creates Stripe Billing Portal session
4. Returns { url } pointing to Stripe-hosted portal
5. User can: update payment method, change plan, cancel, view invoices

Cancellation Flow

1. User cancels via Stripe Billing Portal
2. Subscription stays active until period end
3. At period end, Stripe sends customer.subscription.deleted webhook
4. Webhook handler resets user:
   - plan -> FREE
   - stripePriceId -> null
   - stripeSubscriptionId -> null
   - stripeCurrentPeriodEnd -> null

# File Organization

src/
├── app/                          # Next.js App Router
│   ├── (auth)/                   # Route Group: auth pages
│   │   ├── sign-in/[[...sign-in]]/page.tsx
│   │   └── sign-up/[[...sign-up]]/page.tsx
│   ├── (marketing)/              # Route Group: public pages
│   │   ├── page.tsx              # Landing page
│   │   ├── blog/                 # Blog listing + detail
│   │   └── pricing/              # Pricing page
│   ├── dashboard/                # Protected: user dashboard
│   ├── admin/                    # Protected: admin panel
│   └── api/                      # API routes + webhooks
├── components/
│   ├── ui/                       # Reusable UI primitives
│   ├── landing/                  # Landing page sections
│   ├── dashboard/                # Dashboard components
│   └── shared/                   # App-wide shared
├── lib/                          # Utilities and services
│   ├── auth.ts                   # Auth helpers
│   ├── prisma.ts                 # Prisma client singleton
│   ├── stripe.ts                 # Stripe client + PLANS
│   ├── email.ts                  # Email functions
│   ├── blog.ts                   # MDX blog utilities
│   ├── rate-limit.ts             # In-memory rate limiter
│   ├── utils.ts                  # cn(), formatDate, etc.
│   └── config.ts                 # Site config
└── middleware.ts                  # Clerk route protection

Design Decisions

  • Route Groups(auth) and (marketing) let you use different layouts without affecting the URL structure. Auth pages get a minimal centered layout. Marketing pages get the full header/footer.
  • Colocation — Dashboard-specific components live in components/dashboard/, landing components in components/landing/. Only truly reusable primitives go in components/ui/.
  • Lib Pattern — Each file in lib/ is a focused module. No barrel exports or circular dependencies. Import what you need directly.

# Security

Route Protection

All protected routes are guarded by Clerk middleware. The middleware runs on the Edge Runtime for minimal latency. It checks for a valid session before any page code executes.

Webhook Verification

  • Stripe: Every webhook request is verified using stripe.webhooks.constructEvent() with the STRIPE_WEBHOOK_SECRET. Invalid signatures return a 400 error.
  • Clerk: Webhook requests include svix-id, svix-timestamp, and svix-signature headers for verification.

Rate Limiting

The rateLimit() function in src/lib/rate-limit.ts provides in-memory rate limiting. Default: 10 requests per 60 seconds per key.

const { success } = rateLimit(userId, 10, 60_000);
if (!success) {
  return NextResponse.json({ error: "Rate limited" }, { status: 429 });
}

Input Validation

Use Zod schemas to validate all user input in API routes:

import { z } from "zod";

const schema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

const result = schema.safeParse(body);
if (!result.success) {
  return NextResponse.json({ error: result.error }, { status: 400 });
}

# Performance

  • Server Components by Default — All pages are Server Components unless explicitly marked with "use client". Zero client-side JavaScript for pages that don't need interactivity.
  • Edge Middleware — Clerk middleware runs on the Edge Runtime, providing sub-millisecond auth checks at CDN locations worldwide.
  • Static Generation — Blog posts are statically generated from MDX at build time. Marketing pages with no dynamic data can be statically cached.
  • Database Efficiency — Prisma client is a singleton to avoid connection pool exhaustion. Unique constraints on frequently queried fields.
  • Lazy Imports — Heavy client-side libraries should be dynamically imported with next/dynamic.