Skip to main content

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

·APIScout Team
saasstripeauthenticationanalyticsapi stack

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 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

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

Comments