Skip to main content
This guide explains how all components work together to create a complete subscription management system.

Architecture Overview

The application follows a modern serverless architecture with clear separation of concerns:

Core Components

Frontend Layer (Next.js 15)

The application uses Next.js 15 with the App Router for optimal performance and developer experience.

App Router

File-based routing with layouts
  • /app/page.tsx - Landing page with auth redirect
  • /app/login/page.tsx - Google OAuth sign-in
  • /app/dashboard/page.tsx - Subscription management
  • /app/layout.tsx - Root layout with theme provider

Server Components

Default server-side rendering
export default async function DashboardPage() {
  const userRes = await getUser();
  const productRes = await getProducts();
  const subscriptionRes = await getUserSubscription();
  
  return <Dashboard {...data} />;
}

Component Structure

components/
├── auth/
│   └── google-signin.tsx          # OAuth sign-in button
├── dashboard/
│   ├── dashboard.tsx              # Main dashboard container
│   ├── subscription-management.tsx # Plan details and actions
│   ├── invoice-history.tsx        # Payment records
│   ├── cancel-subscription-dialog.tsx
│   ├── update-plan-dialog.tsx
│   └── restore-subscription-dialog.tsx
├── layout/
│   └── header.tsx                 # Navigation header
├── ui/
│   └── ...                        # shadcn/ui components
└── theme-provider.tsx             # Dark mode support

Authentication Layer (Supabase Auth)

Two-client architecture for secure authentication:
// Browser client for client-side operations
import { createBrowserClient } from "@supabase/ssr";

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
The service role key has admin privileges and should never be exposed to the client. Only use it in server-side code.

Database Layer (PostgreSQL + Drizzle)

PostgreSQL database hosted on Supabase with type-safe ORM access:

Schema Relationships

// User → Subscription relationship (one-to-many)
export const usersRelations = relations(users, ({ one, many }) => ({
  currentSubscription: one(subscriptions, {
    fields: [users.currentSubscriptionId],
    references: [subscriptions.subscriptionId],
  }),
  subscriptions: many(subscriptions),
}));

// Subscription → User relationship (many-to-one)
export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
  user: one(users, {
    fields: [subscriptions.userId],
    references: [users.supabaseUserId],
  }),
}));

Data Flow

  1. User signs up → Supabase Auth creates user → Server action creates user record
  2. User subscribes → Dodo Payments creates subscription → Webhook updates database
  3. Payment succeeds → Webhook creates payment record → User tier updated
  4. User cancels → API call to Dodo → Webhook marks subscription as cancelled

Payment Layer (Dodo Payments)

Integration with Dodo Payments for subscription billing:
// lib/dodo-payments/client.ts
import DodoPayments from "dodopayments";

export const dodoClient = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
  environment: process.env.DODO_PAYMENTS_ENVIRONMENT!
});

Key Operations

// actions/change-plan.ts
const customer = await dodoClient.customers.create({
  email: user.email!,
  name: user.user_metadata.full_name,
});

await dodoClient.miscellaneous.createSubscription({
  customer_id: customer.customer_id,
  product_id: productId,
});

Webhook Handler (Supabase Edge Function)

Deno-based serverless function deployed to Supabase:
// supabase/functions/dodo-webhook/index.ts
Deno.serve(async (req) => {
  // Verify webhook signature
  const webhook = new Webhook(dodoWebhookSecret);
  await webhook.verify(rawBody, webhookHeaders);
  
  // Route events to handlers
  switch (event.type) {
    case "payment.succeeded":
      await managePayment(event);
      break;
    case "subscription.active":
      await manageSubscription(event);
      await updateUserTier(event.data);
      break;
    case "subscription.cancelled":
      await manageSubscription(event);
      await downgradeToHobbyPlan(event.data);
      break;
  }
});

Webhook Functions

Upserts payment records to the payments table:
async function managePayment(event: any) {
  const data = {
    payment_id: event.data.payment_id,
    status: event.data.status,
    total_amount: event.data.total_amount,
    currency: event.data.currency,
    customer_email: event.data.customer.email,
    webhook_data: event,
    // ... more fields
  };
  
  await supabase.from("payments").upsert(data, {
    onConflict: "payment_id",
  });
}
Upserts subscription records to the subscriptions table:
async function manageSubscription(event: any) {
  const data = {
    subscription_id: event.data.subscription_id,
    status: event.data.status,
    product_id: event.data.product_id,
    next_billing_date: event.data.next_billing_date,
    // ... more fields
  };
  
  await supabase.from("subscriptions").upsert(data, {
    onConflict: "subscription_id",
  });
}
Links active subscription to user:
async function updateUserTier(props) {
  await supabase
    .from("users")
    .update({ current_subscription_id: props.subscriptionId })
    .eq("dodo_customer_id", props.dodoCustomerId);
}
Removes subscription reference from user:
async function downgradeToHobbyPlan(props) {
  await supabase
    .from("users")
    .update({ current_subscription_id: null })
    .eq("dodo_customer_id", props.dodoCustomerId);
}

Data Flow Diagrams

User Registration Flow

Subscription Creation Flow

Payment Processing Flow

Server Actions

All data mutations go through Next.js server actions for security:
// actions/ directory structure
actions/
├── get-user.ts                    # Fetch authenticated user
├── get-products.ts                # List available plans
├── get-user-subscription.ts       # Get current subscription
├── get-invoices.ts                # Fetch payment history
├── create-user.ts                 # Initialize user record
├── create-dodo-customer.ts        # Register with Dodo
├── change-plan.ts                 # Upgrade/downgrade
├── cancel-subscription.ts         # Cancel at next billing
├── restore-subscription.ts        # Undo cancellation
└── delete-account.ts              # Remove user data

Type-Safe Action Pattern

"use server";

import { ServerActionRes } from "@/types/server-action";

export async function getUser(): ServerActionRes<User> {
  try {
    const supabase = await createClient();
    const { data: { user } } = await supabase.auth.getUser();
    
    if (!user) {
      return { success: false, error: "User not found" };
    }
    
    return { success: true, data: user };
  } catch (error) {
    return { success: false, error: "Failed to get user" };
  }
}
All server actions return a standardized response type with success boolean and either data or error fields.

Security Considerations

Environment Variables

Public Variables

Safe to expose to client (prefixed with NEXT_PUBLIC_):
  • NEXT_PUBLIC_SUPABASE_URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY

Secret Variables

Server-side only (never expose to client):
  • SUPABASE_SERVICE_ROLE_KEY
  • DODO_PAYMENTS_API_KEY
  • DODO_WEBHOOK_SECRET
  • DATABASE_URL

Webhook Security

// Verify webhook signature before processing
const webhook = new Webhook(dodoWebhookSecret);

try {
  await webhook.verify(rawBody, webhookHeaders);
} catch (error) {
  return new Response("Invalid webhook signature", { status: 400 });
}

Row Level Security (RLS)

This starter kit uses service role keys in server actions, which bypass RLS. For production applications, implement proper RLS policies on your Supabase tables.

Deployment Architecture

Deploy Next.js application to Vercel:
  1. Connect GitHub repository
  2. Configure environment variables
  3. Deploy with automatic CI/CD
Benefits:
  • Zero-config deployment
  • Edge network distribution
  • Automatic HTTPS
  • Preview deployments

Production Checklist

1

Environment Setup

Switch from test_mode to live_mode in Dodo Payments configuration
2

Database Migration

Run bun run db:push to apply schema to production database
3

Webhook Deployment

Deploy webhook function with production credentials:
bun run deploy:webhook --project-ref YOUR_PROD_PROJECT
4

Configure Webhooks

Update webhook URL in Dodo Payments dashboard to production endpoint
5

Test Payment Flow

Make a test subscription to verify end-to-end flow

Performance Optimizations

Next.js Features

  • Turbopack: Fast development builds
  • React Server Components: Reduced client JavaScript
  • Server Actions: Eliminates API route overhead
  • Automatic Code Splitting: Optimized bundle sizes

Database Optimizations

// Indexed columns for fast lookups
supabaseUserId: text("supabase_user_id").primaryKey()
dodoCustomerId: text("dodo_customer_id").notNull()
subscriptionId: text("subscription_id").primaryKey()

Caching Strategy

Consider implementing React Server Component caching for product lists and subscription data to reduce database queries.

Extending the Architecture

Adding New Features

Email Notifications

Add transactional emails using Resend or SendGrid triggered by webhook events

Usage Tracking

Implement metered billing by tracking usage and syncing with Dodo Payments

Team Management

Extend schema to support team subscriptions with multiple users

Admin Dashboard

Build admin panel using Supabase service role to manage users and subscriptions

Integration Points

The architecture is designed for extensibility:
  • Webhook handler: Add new event types in the switch statement
  • Server actions: Create new actions for additional business logic
  • Database schema: Extend with migrations using Drizzle Kit
  • UI components: Add new pages using the established patterns

Build docs developers (and LLMs) love