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
| Event | When | Properties |
|---|---|---|
user_signed_up | After registration | source, referrer |
subscription_started | After payment | plan, amount, trial |
subscription_cancelled | After cancellation | plan, reason, duration |
feature_used | User interacts with feature | feature_name, plan |
upgrade_clicked | Clicks upgrade CTA | current_plan, target_plan |
api_call_made | Uses API (if applicable) | endpoint, response_time |
onboarding_step | Completes onboarding step | step_number, step_name |
payment_failed | Payment declined | retry_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
| Service | Free Tier | Growth Cost (1K users) | Scale Cost (10K users) |
|---|---|---|---|
| Clerk | 10K MAU | $25/month | $100/month |
| Stripe | No monthly fee | ~$30 processing fees | ~$300 processing fees |
| PostHog | 1M events | Free | $45/month |
| Resend | 3K emails/month | $20/month | $50/month |
| Vercel | Hobby free | $20/month | $20+/month |
| Neon/Supabase | Free tier | $25/month | $50/month |
| Total | $0 | ~$120/month | ~$565/month |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Not syncing auth users to DB | Can't associate data with users | Clerk webhook → DB on user.created |
| Trusting client-side plan checks | Users bypass restrictions | Always verify plan server-side |
| Missing webhook event types | Missed state changes (cancellations, failures) | Handle all subscription lifecycle events |
| No idempotency in webhooks | Duplicate emails, double upgrades | Check event ID before processing |
| Tracking everything client-side | Missing server events, ad blockers | Server-side tracking for critical events |
| No graceful degradation | One API down = app down | Each service fails independently |
Compare SaaS backend APIs on APIScout — auth providers, payment platforms, analytics tools, and email services side by side.