Architecture
A deep dive into how the codebase is structured and the design decisions behind it.
# Overview
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 15 (App Router) | SSR, API routes, middleware |
| Auth | Clerk | User management, sessions, OAuth |
| Payments | Stripe | Subscriptions, billing portal, webhooks |
| Database | PostgreSQL + Prisma | Data persistence, type-safe ORM |
| Resend | Transactional emails | |
| Styling | Tailwind CSS 4 | Utility-first CSS with theme variables |
| Validation | Zod | Runtime type validation |
| Blog | MDX | Static 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 |
|---|---|---|
id | String (PK) | Clerk user ID (user_...) |
email | String (unique) | User's email address |
name | String? | Full name |
imageUrl | String? | Profile picture URL |
role | Role enum | USER or ADMIN |
plan | Plan enum | FREE, PRO, or BUSINESS |
stripeCustomerId | String? (unique) | Stripe customer ID |
stripeSubscriptionId | String? (unique) | Active subscription ID |
stripePriceId | String? | Current price ID |
stripeCurrentPeriodEnd | DateTime? | Subscription expiry |
createdAt | DateTime | Account creation |
updatedAt | DateTime | Last update |
ApiKey Model
Per-user API keys for programmatic access.
| Field | Type | Description |
|---|---|---|
id | String (PK) | Auto-generated cuid |
name | String | User-defined key name |
key | String (unique) | The API key value |
userId | String (FK) | Owner's Clerk ID |
lastUsed | DateTime? | Last usage timestamp |
createdAt | DateTime | Creation timestamp |
WaitlistEntry Model
Standalone model for collecting emails before launch.
| Field | Type | Description |
|---|---|---|
id | String (PK) | Auto-generated cuid |
email | String (unique) | Email address |
createdAt | DateTime | Signup 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 | null | Optional auth check |
requireUser() | User | Protected pages/API (throws "Unauthorized") |
requireAdmin() | User (admin) | Admin-only pages/API (throws "Forbidden") |
syncUser() | User | null | Create/update user in DB from Clerk data |
getUserPlan(user) | Plan | Get current plan |
isAdmin(user) | boolean | Check admin status |
hasActiveSubscription(user) | boolean | Check if subscription is active |
User Sync Strategy
Users are synced from Clerk to your database in two ways:
- Webhook (primary): Clerk sends
user.created,user.updated,user.deletedevents to/api/webhooks/clerk. This keeps the database in sync even when users manage their account outside your app. - 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 incomponents/landing/. Only truly reusable primitives go incomponents/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 theSTRIPE_WEBHOOK_SECRET. Invalid signatures return a 400 error. - Clerk: Webhook requests include
svix-id,svix-timestamp, andsvix-signatureheaders 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.