Skip to main content

Building a SaaS Backend: Auth, Stripe, PostHog 2026

·APIScout Team
Share:

Building a SaaS Backend: Auth + Stripe + PostHog + Resend

Every SaaS app needs the same four things: authentication, payments, analytics, and transactional email. Here's how to wire them together into a production backend using the best API for each job.

The stack in this guide — Clerk, Stripe, PostHog, Resend — represents the highest-velocity path to a production-ready SaaS in 2026. Each component handles a complex domain (identity management, payment compliance, event analytics, email deliverability) that would take months to build correctly in-house. Together, they can be wired up in a week, leaving your team focused on the product logic that actually differentiates your SaaS.

The most important architectural decision in this guide isn't which provider to use — it's how they connect. Clerk fires webhooks when users are created or modified. Stripe fires webhooks when subscriptions change. PostHog receives events from both. Every user lifecycle event flows through all four systems. Getting those connections right from day one prevents a class of bugs (users with active subscriptions but no database record, users who upgraded but didn't receive a confirmation email) that are painful to debug and worse to explain to customers.

The SaaS API Stack

┌─────────────────────────────────────────┐
│  Frontend (Next.js / React)             │
├─────────────────────────────────────────┤
│  Authentication        │  Clerk          │  Sign-up, sign-in, user management
│  (identity, sessions)  │  (or Auth.js)   │
├────────────────────────┼────────────────┤
│  Payments & Billing    │  Stripe         │  Subscriptions, invoices, metering
│  (subscribe, charge)   │                 │
├────────────────────────┼────────────────┤
│  Analytics & Events    │  PostHog        │  Product analytics, feature flags
│  (track, identify)     │  (or Mixpanel)  │
├────────────────────────┼────────────────┤
│  Transactional Email   │  Resend         │  Welcome, receipts, notifications
│  (send, templates)     │  (or SendGrid)  │
├────────────────────────┼────────────────┤
│  Database & Storage    │  Postgres       │  Application data
│  (queries, files)      │  + S3/R2        │
└────────────────────────┴────────────────┘

Layer 1: Authentication with Clerk

Setup

// app/layout.tsx — Wrap your app with Clerk
import { ClerkProvider } from '@clerk/nextjs';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html>
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Protecting Routes

// middleware.ts — Protect routes at the edge
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isPublicRoute = createRouteMatcher([
  '/',
  '/pricing',
  '/blog(.*)',
  '/api/webhooks(.*)',
]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};

Getting User Data

// Server component
import { currentUser } from '@clerk/nextjs/server';

export default async function Dashboard() {
  const user = await currentUser();
  if (!user) return null;

  return <h1>Welcome, {user.firstName}</h1>;
}

// API route
import { auth } from '@clerk/nextjs/server';

export async function GET() {
  const { userId } = await auth();
  if (!userId) {
    return new Response('Unauthorized', { status: 401 });
  }

  const data = await db.projects.findMany({
    where: { userId },
  });

  return Response.json(data);
}

Syncing Users to Your Database

// app/api/webhooks/clerk/route.ts
import { WebhookEvent } from '@clerk/nextjs/server';
import { headers } from 'next/headers';
import { Webhook } from 'svix';

export async function POST(req: Request) {
  const body = await req.text();
  const headerPayload = await headers();

  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
  const event = wh.verify(body, {
    'svix-id': headerPayload.get('svix-id')!,
    'svix-timestamp': headerPayload.get('svix-timestamp')!,
    'svix-signature': headerPayload.get('svix-signature')!,
  }) as WebhookEvent;

  switch (event.type) {
    case 'user.created': {
      await db.users.create({
        id: event.data.id,
        email: event.data.email_addresses[0]?.email_address,
        name: `${event.data.first_name} ${event.data.last_name}`.trim(),
        plan: 'free',
        createdAt: new Date(),
      });

      // Send welcome email
      await sendWelcomeEmail(event.data.email_addresses[0]?.email_address);

      // Track in analytics
      posthog.capture({
        distinctId: event.data.id,
        event: 'user_signed_up',
        properties: {
          source: event.data.unsafe_metadata?.source || 'direct',
        },
      });
      break;
    }

    case 'user.updated': {
      await db.users.update(event.data.id, {
        email: event.data.email_addresses[0]?.email_address,
        name: `${event.data.first_name} ${event.data.last_name}`.trim(),
      });
      break;
    }

    case 'user.deleted': {
      await db.users.softDelete(event.data.id!);
      break;
    }
  }

  return new Response('OK', { status: 200 });
}

Layer 2: Payments with Stripe

Creating a Customer (Tied to Auth)

// When a user signs up, create a Stripe customer
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function createStripeCustomer(userId: string, email: string, name: string) {
  const customer = await stripe.customers.create({
    email,
    name,
    metadata: { userId }, // Link Stripe customer to your user
  });

  // Store the Stripe customer ID
  await db.users.update(userId, {
    stripeCustomerId: customer.id,
  });

  return customer;
}

Checkout and Subscription

// Create a checkout session for subscription
export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return new Response('Unauthorized', { status: 401 });

  const user = await db.users.findById(userId);
  const { priceId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    customer: user.stripeCustomerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    subscription_data: {
      metadata: { userId },
    },
    automatic_tax: { enabled: true },
  });

  return Response.json({ url: session.url });
}

Stripe Webhooks

// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature')!;

  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      const userId = session.metadata?.userId || session.subscription_data?.metadata?.userId;

      await db.users.update(userId, { plan: 'pro' });

      // Track conversion
      posthog.capture({
        distinctId: userId,
        event: 'subscription_started',
        properties: {
          plan: 'pro',
          amount: session.amount_total,
        },
      });

      // Send confirmation email
      const user = await db.users.findById(userId);
      await sendUpgradeEmail(user.email, 'pro');
      break;
    }

    case 'invoice.paid': {
      const invoice = event.data.object;
      const subscription = await stripe.subscriptions.retrieve(
        invoice.subscription as string
      );
      const userId = subscription.metadata.userId;

      await db.users.update(userId, {
        plan: 'pro',
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      });
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object;
      const subscription = await stripe.subscriptions.retrieve(
        invoice.subscription as string
      );
      const userId = subscription.metadata.userId;
      const user = await db.users.findById(userId);

      // Send payment failed email
      await sendPaymentFailedEmail(user.email);

      posthog.capture({
        distinctId: userId,
        event: 'payment_failed',
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object;
      const userId = subscription.metadata.userId;

      await db.users.update(userId, { plan: 'free' });

      posthog.capture({
        distinctId: userId,
        event: 'subscription_cancelled',
      });
      break;
    }
  }

  return new Response('OK', { status: 200 });
}

Checking Subscription Status

// Middleware or utility to check plan
async function requirePlan(userId: string, requiredPlan: 'pro' | 'enterprise') {
  const user = await db.users.findById(userId);

  if (!user.stripeCustomerId) {
    throw new Error('No billing account');
  }

  const planHierarchy = { free: 0, pro: 1, enterprise: 2 };
  if (planHierarchy[user.plan] < planHierarchy[requiredPlan]) {
    throw new Error(`Requires ${requiredPlan} plan`);
  }

  // Verify subscription is still active (belt + suspenders)
  if (user.currentPeriodEnd && user.currentPeriodEnd < new Date()) {
    // Subscription expired — webhook may have been missed
    const subscriptions = await stripe.subscriptions.list({
      customer: user.stripeCustomerId,
      status: 'active',
    });

    if (subscriptions.data.length === 0) {
      await db.users.update(userId, { plan: 'free' });
      throw new Error('Subscription expired');
    }
  }

  return user;
}

Layer 3: Analytics with PostHog

Server-Side Setup

// lib/posthog.ts
import { PostHog } from 'posthog-node';

const posthog = new PostHog(process.env.POSTHOG_API_KEY!, {
  host: process.env.POSTHOG_HOST || 'https://us.i.posthog.com',
});

export default posthog;

// Identify user (call after sign-up or sign-in)
export function identifyUser(userId: string, properties: Record<string, any>) {
  posthog.identify({
    distinctId: userId,
    properties: {
      email: properties.email,
      name: properties.name,
      plan: properties.plan,
      created_at: properties.createdAt,
    },
  });
}

// Track events
export function trackEvent(
  userId: string,
  event: string,
  properties?: Record<string, any>
) {
  posthog.capture({
    distinctId: userId,
    event,
    properties,
  });
}

Client-Side Setup

// app/providers.tsx
'use client';

import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react';
import { useUser } from '@clerk/nextjs';
import { useEffect } from 'react';

export function PHProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
      capture_pageview: true,
      capture_pageleave: true,
    });
  }, []);

  return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}

// Identify logged-in users
export function PostHogIdentify() {
  const { user } = useUser();

  useEffect(() => {
    if (user) {
      posthog.identify(user.id, {
        email: user.primaryEmailAddress?.emailAddress,
        name: user.fullName,
      });
    }
  }, [user]);

  return null;
}

Feature Flags

// Server-side feature flag check
import posthog from '@/lib/posthog';

export async function GET(req: Request) {
  const { userId } = await auth();
  if (!userId) return new Response('Unauthorized', { status: 401 });

  const isEnabled = await posthog.isFeatureEnabled('new-dashboard', userId);

  if (!isEnabled) {
    return Response.json({ dashboard: 'classic' });
  }

  return Response.json({ dashboard: 'new' });
}

// Client-side feature flag
import { useFeatureFlagEnabled } from 'posthog-js/react';

function Dashboard() {
  const showNewUI = useFeatureFlagEnabled('new-dashboard');

  return showNewUI ? <NewDashboard /> : <ClassicDashboard />;
}

Key Events to Track

EventWhenProperties
user_signed_upAfter registrationsource, referrer
subscription_startedAfter paymentplan, amount, trial
subscription_cancelledAfter cancellationplan, reason, duration
feature_usedUser interacts with featurefeature_name, plan
upgrade_clickedClicks upgrade CTAcurrent_plan, target_plan
api_call_madeUses API (if applicable)endpoint, response_time
onboarding_stepCompletes onboarding stepstep_number, step_name
payment_failedPayment declinedretry_count

Layer 4: Transactional Email with Resend

Setup

// lib/email.ts
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendEmail({
  to,
  subject,
  html,
  from = 'YourApp <hello@yourapp.com>',
}: {
  to: string;
  subject: string;
  html: string;
  from?: string;
}) {
  return resend.emails.send({ from, to, subject, html });
}

Email Templates

// emails/welcome.tsx — React Email template
import { Html, Head, Body, Container, Text, Link, Button } from '@react-email/components';

export function WelcomeEmail({ name, loginUrl }: { name: string; loginUrl: string }) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f6f6f6' }}>
        <Container style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
          <Text style={{ fontSize: '24px', fontWeight: 'bold' }}>
            Welcome to YourApp, {name}!
          </Text>
          <Text>Your account is ready. Here's what you can do:</Text>
          <Text>✅ Create your first project</Text>
          <Text>✅ Invite team members</Text>
          <Text>✅ Connect your tools</Text>
          <Button
            href={loginUrl}
            style={{
              backgroundColor: '#000',
              color: '#fff',
              padding: '12px 24px',
              borderRadius: '6px',
              textDecoration: 'none',
            }}
          >
            Get Started →
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

// Send with React Email
import { WelcomeEmail } from '@/emails/welcome';

export async function sendWelcomeEmail(email: string, name: string) {
  await resend.emails.send({
    from: 'YourApp <hello@yourapp.com>',
    to: email,
    subject: `Welcome to YourApp, ${name}!`,
    react: WelcomeEmail({ name, loginUrl: `${process.env.APP_URL}/dashboard` }),
  });
}

Triggered Emails

// All the emails a SaaS needs
const emailTriggers = {
  // Auth events (from Clerk webhook)
  'user.created': async (user: User) => {
    await sendWelcomeEmail(user.email, user.name);
  },

  // Payment events (from Stripe webhook)
  'subscription_started': async (user: User, plan: string) => {
    await sendEmail({
      to: user.email,
      subject: `You're now on the ${plan} plan!`,
      html: renderUpgradeEmail(user.name, plan),
    });
  },

  'payment_failed': async (user: User) => {
    await sendEmail({
      to: user.email,
      subject: 'Action needed: Payment failed',
      html: renderPaymentFailedEmail(user.name, `${process.env.APP_URL}/settings/billing`),
    });
  },

  'subscription_cancelled': async (user: User) => {
    await sendEmail({
      to: user.email,
      subject: 'We hate to see you go',
      html: renderCancellationEmail(user.name),
    });
  },

  // Product events
  'trial_ending': async (user: User, daysLeft: number) => {
    await sendEmail({
      to: user.email,
      subject: `Your trial ends in ${daysLeft} days`,
      html: renderTrialEndingEmail(user.name, daysLeft),
    });
  },

  'weekly_digest': async (user: User, stats: Stats) => {
    await sendEmail({
      to: user.email,
      subject: `Your weekly report: ${stats.summary}`,
      html: renderDigestEmail(user.name, stats),
    });
  },
};

Wiring It All Together

The User Lifecycle

1. User signs up (Clerk)
   → Clerk webhook fires → create DB record
   → Create Stripe customer
   → PostHog: identify + track 'user_signed_up'
   → Resend: send welcome email

2. User upgrades (Stripe Checkout)
   → Stripe webhook fires → update DB plan
   → PostHog: track 'subscription_started'
   → Resend: send upgrade confirmation

3. User uses features (your app)
   → PostHog: track 'feature_used' events
   → Check feature flags for gated features
   → Enforce plan limits

4. Payment fails (Stripe)
   → Stripe webhook fires → flag account
   → PostHog: track 'payment_failed'
   → Resend: send payment failed email

5. User cancels (Stripe)
   → Stripe webhook fires → downgrade to free
   → PostHog: track 'subscription_cancelled'
   → Resend: send cancellation email

Environment Variables

# .env.local

# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SECRET=whsec_...

# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# PostHog
POSTHOG_API_KEY=phc_...
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

# Resend
RESEND_API_KEY=re_...

# Database
DATABASE_URL=postgresql://...

# App
APP_URL=http://localhost:3000

Pricing Page Integration

// Pricing plans with Stripe price IDs
const PLANS = {
  free: {
    name: 'Free',
    price: 0,
    features: ['3 projects', '1,000 events/month', 'Community support'],
    limits: { projects: 3, events: 1000 },
  },
  pro: {
    name: 'Pro',
    price: 29,
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    features: ['Unlimited projects', '100,000 events/month', 'Email support', 'API access'],
    limits: { projects: Infinity, events: 100000 },
  },
  enterprise: {
    name: 'Enterprise',
    price: 99,
    priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
    features: ['Everything in Pro', '1M events/month', 'Priority support', 'SSO', 'Audit logs'],
    limits: { projects: Infinity, events: 1000000 },
  },
};

// Enforce plan limits
async function checkLimit(userId: string, resource: 'projects' | 'events') {
  const user = await db.users.findById(userId);
  const plan = PLANS[user.plan as keyof typeof PLANS];
  const currentUsage = await db.usage.count(userId, resource);

  if (currentUsage >= plan.limits[resource]) {
    throw new Error(
      `You've reached the ${resource} limit on the ${plan.name} plan. ` +
      `Upgrade to get more.`
    );
  }
}

API Costs for a Typical SaaS

ServiceFree TierGrowth Cost (1K users)Scale Cost (10K users)
Clerk10K MAU$25/month$100/month
StripeNo monthly fee~$30 processing fees~$300 processing fees
PostHog1M eventsFree$45/month
Resend3K emails/month$20/month$50/month
VercelHobby free$20/month$20+/month
Neon/SupabaseFree tier$25/month$50/month
Total$0~$120/month~$565/month

Common Mistakes

MistakeImpactFix
Not syncing auth users to DBCan't associate data with usersClerk webhook → DB on user.created
Trusting client-side plan checksUsers bypass restrictionsAlways verify plan server-side
Missing webhook event typesMissed state changes (cancellations, failures)Handle all subscription lifecycle events
No idempotency in webhooksDuplicate emails, double upgradesCheck event ID before processing
Tracking everything client-sideMissing server events, ad blockersServer-side tracking for critical events
No graceful degradationOne API down = app downEach service fails independently

Testing the Integration End-to-End

The most common testing mistake for this stack is testing each service in isolation. Unit tests for your Stripe webhook handler, mocked PostHog calls, and seeded database users miss the category of bugs that only emerge when services interact.

Build a full-stack integration test that:

  1. Creates a test user via Clerk's test mode
  2. Triggers a Stripe checkout (use Stripe's 4242 4242 4242 4242 test card)
  3. Verifies the Stripe webhook fires and updates your database
  4. Confirms PostHog received the subscription_started event (check PostHog's event ingestion API)
  5. Confirms Resend received the upgrade email (check Resend's email log API)

This test will be slow (10-30 seconds) and requires real API keys in a test environment, but it catches real bugs. Run it in CI on every PR that touches any of the four integrations.

For the webhook handlers specifically, use Stripe CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe) and Clerk's Svix webhook tool locally to test the full event flow without deploying. Test the idempotency of your handlers by replaying the same event twice and verifying your database state is correct on the second delivery.

Local development with all four services requires 4 API keys, 2-3 webhook listeners, and careful environment variable management. Use a .env.local file (gitignored) with separate test-mode keys for each provider. Never use production Stripe keys locally — Stripe test mode events don't charge real money and don't affect production data.

Handling Service Outages Gracefully

Each provider in this stack can have incidents. When Clerk has a partial outage, users can't sign in. When Stripe's webhook delivery is delayed, subscription upgrades don't apply. When PostHog is down, your analytics go dark. Design each integration to fail gracefully rather than cascading.

Auth (Clerk): Authentication outages are the most severe — users can't access your app at all. Mitigate by caching session state on your side. After verifying a Clerk session, store the user ID and plan in a short-lived cookie (15 minutes) so that brief Clerk outages don't cause immediate session failures. This is a tradeoff: you might serve stale plan data for up to 15 minutes, but users can continue working. Check Clerk's status page (status.clerk.com) and set up alert webhooks to page your on-call when Clerk has incidents.

Payments (Stripe): Stripe has excellent uptime (99.999% historically), but webhook delivery can be delayed during incidents. The requirePlan() function in this guide has a fallback: if the local database says a user is on pro and the Stripe subscription shows active, trust the database. Only re-verify against Stripe when the local data is ambiguous or stale. For critical permission checks (high-value API calls, data exports), verify against Stripe directly; for low-stakes checks (feature visibility), trust the database.

Analytics (PostHog): PostHog outages shouldn't affect your app's core functionality. All PostHog calls are fire-and-forget — never await them in a request-response path. Wrap PostHog calls in try/catch blocks that silently log failures rather than surfacing errors to users. Lost analytics events during an outage are acceptable; application errors are not.

Email (Resend): Email delivery failures are also low-severity from a UX perspective, but transactional emails (payment receipts, password resets, magic links) have implicit urgency. Implement a queue for critical emails so they're retried if Resend has a temporary error. For welcome emails and marketing emails, a single failed delivery is acceptable loss.

Alternative Stack Choices

The Clerk + Stripe + PostHog + Resend stack is a popular default in 2026, but each component has strong alternatives worth knowing.

Auth alternatives: Auth0 is more mature and has SAML SSO included at lower price points for enterprise customers. Supabase Auth is worth considering if you're already using Supabase as your database — the user table is native and there are no webhook sync delays. Auth.js (NextAuth.js) is the open-source option that keeps user data fully in your own database, with trade-offs in managed features.

Payment alternatives: Lemon Squeezy handles global taxes and acts as the merchant of record, which significantly reduces VAT/GST compliance complexity for international sales. Paddle serves a similar role. For B2B with invoicing and flexible contracts, Recurly or Chargebee offer more sophisticated billing management than Stripe Billing.

Analytics alternatives: Mixpanel is a strong alternative to PostHog with better funnel and cohort analysis. Amplitude is the enterprise choice. Both are significantly more expensive than PostHog at scale. Plausible and Fathom are privacy-first options if you need GDPR compliance without complex configuration.

Email alternatives: Postmark has the best deliverability reputation for transactional email. SendGrid has the most features (marketing + transactional) but is more complex. Mailgun has the best developer API experience for custom workflows.

Methodology

Pricing data in the cost table is sourced from each provider's public pricing pages as of early 2026. Processing fee estimates assume $10 average order value for Stripe calculations. PostHog's free tier (1M events) resets monthly; events beyond 1M are billed at approximately $0.0000225/event. Clerk's free tier (10K MAU) counts users who sign in at least once per month. The webhook handling pattern (verify signature → acknowledge immediately → process async) follows each provider's official documentation and is consistent with patterns discussed in the API reliability literature. Svix (used for Clerk webhook verification) v1.x is the current SDK version.


Compare SaaS backend APIs on APIScout — auth providers, payment platforms, analytics tools, and email services side by side.

Compare Stripe and Resend on APIScout.

Related: Build a Payment System: Stripe + Plaid 2026, Building a Communication Platform, How to Add Product Analytics with PostHog

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.