SaaS Billing
Managing cache for pricing, subscriptions, and billing in a SaaS application
SaaS Billing Scenario
Learn how to implement caching for a SaaS product with public pricing pages, subscription management, and billing operations.
The Challenge
SaaS billing involves multiple caching concerns:
- Pricing pages: Public, should be highly cached but update instantly when prices change
- Subscription status: Affects feature access, needs to reflect changes quickly
- Invoice data: Sensitive, should only be visible to the right users
- Usage metrics: May be shown in real-time or with some delay
Schema Design
// lib/cache.ts
import { createCache } from 'next-cool-cache';
const schema = {
pricing: {
plans: {
list: {},
byId: { _params: ['planId'] as const },
byTier: { _params: ['tier'] as const },
},
features: {
list: {},
byPlanId: { _params: ['planId'] as const },
},
addons: {
list: {},
byId: { _params: ['addonId'] as const },
},
},
subscriptions: {
byOrgId: { _params: ['orgId'] as const },
byCustomerId: { _params: ['customerId'] as const },
},
invoices: {
list: {},
byId: { _params: ['invoiceId'] as const },
byOrgId: { _params: ['orgId'] as const },
upcoming: { _params: ['orgId'] as const },
},
usage: {
byOrgId: { _params: ['orgId'] as const },
byOrgAndPeriod: { _params: ['orgId', 'period'] as const },
current: { _params: ['orgId'] as const },
},
limits: {
byOrgId: { _params: ['orgId'] as const },
},
} as const;
const scopes = ['admin', 'public', 'customer'] as const;
export const cache = createCache(schema, scopes);Implementation Patterns
Pattern 1: Public Pricing Page
The pricing page should be heavily cached for performance:
// app/pricing/page.tsx
import { cache } from '@/lib/cache';
async function getPricingPlans() {
'use cache: remote';
cache.public.pricing.plans.list.cacheTag();
return db.plans.findMany({
where: { active: true },
orderBy: { price: 'asc' },
include: { features: true },
});
}
async function getAddons() {
'use cache: remote';
cache.public.pricing.addons.list.cacheTag();
return db.addons.findMany({
where: { active: true },
});
}
export default async function PricingPage() {
const [plans, addons] = await Promise.all([
getPricingPlans(),
getAddons(),
]);
return (
<PricingLayout>
<PricingTable plans={plans} />
<AddonsSection addons={addons} />
</PricingLayout>
);
}Pattern 2: Updating Pricing
When an admin updates pricing, public pages should update immediately:
// app/actions/pricing.ts
'use server';
import { cache } from '@/lib/cache';
export async function updatePlanPrice(planId: string, newPrice: number) {
const plan = await db.plans.update({
where: { id: planId },
data: { price: newPrice },
});
// Admin sees immediately
cache.admin.pricing.plans.byId.updateTag({ planId });
cache.admin.pricing.plans.list.updateTag();
// Public pricing page should update immediately too
// (pricing is important to keep consistent)
cache.public.pricing.plans.byId.updateTag({ planId });
cache.public.pricing.plans.list.updateTag();
return plan;
}
export async function createNewPlan(data: PlanData) {
const plan = await db.plans.create({ data });
// Invalidate plan lists
cache.admin.pricing.plans.list.updateTag();
cache.public.pricing.plans.list.updateTag();
return plan;
}Pattern 3: Subscription Status
Subscription status affects feature access and must be current:
// lib/subscription.ts
import { cache } from '@/lib/cache';
async function getSubscription(orgId: string) {
'use cache: private';
cache.customer.subscriptions.byOrgId.cacheTag({ orgId });
return db.subscriptions.findUnique({
where: { orgId },
include: { plan: true },
});
}
async function getUsageLimits(orgId: string) {
'use cache: private';
cache.customer.limits.byOrgId.cacheTag({ orgId });
const subscription = await db.subscriptions.findUnique({
where: { orgId },
include: { plan: { include: { limits: true } } },
});
return subscription?.plan.limits ?? null;
}
// Middleware or hook to check access
export async function canAccessFeature(orgId: string, feature: string) {
const subscription = await getSubscription(orgId);
const limits = await getUsageLimits(orgId);
if (!subscription || subscription.status !== 'active') {
return false;
}
return subscription.plan.features.includes(feature);
}Pattern 4: Subscription Changes
When a subscription changes, multiple caches need updating:
// app/actions/subscription.ts
'use server';
import { cache } from '@/lib/cache';
export async function upgradePlan(orgId: string, newPlanId: string) {
const subscription = await db.subscriptions.update({
where: { orgId },
data: { planId: newPlanId },
});
// Customer sees immediately (affects their feature access)
cache.customer.subscriptions.byOrgId.updateTag({ orgId });
cache.customer.limits.byOrgId.updateTag({ orgId });
// Admin dashboard updates
cache.admin.subscriptions.byOrgId.updateTag({ orgId });
// Upcoming invoice changes
cache.customer.invoices.upcoming.updateTag({ orgId });
return subscription;
}
export async function cancelSubscription(orgId: string) {
const subscription = await db.subscriptions.update({
where: { orgId },
data: {
status: 'canceling',
cancelAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
},
});
// Immediate update for customer
cache.customer.subscriptions.byOrgId.updateTag({ orgId });
cache.customer.limits.byOrgId.updateTag({ orgId });
// Admin sees immediately
cache.admin.subscriptions.byOrgId.updateTag({ orgId });
return subscription;
}Pattern 5: Invoice Access
Invoices are sensitive and should be carefully cached:
// app/billing/invoices/page.tsx
import { cache } from '@/lib/cache';
import { getCurrentOrg } from '@/lib/auth';
async function getInvoices(orgId: string) {
'use cache: private';
cache.customer.invoices.byOrgId.cacheTag({ orgId });
return db.invoices.findMany({
where: { orgId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}
async function getUpcomingInvoice(orgId: string) {
'use cache: private';
cache.customer.invoices.upcoming.cacheTag({ orgId });
// Calculate upcoming invoice from Stripe or your billing system
return calculateUpcomingInvoice(orgId);
}
export default async function InvoicesPage() {
const org = await getCurrentOrg();
if (!org) redirect('/login');
const [invoices, upcoming] = await Promise.all([
getInvoices(org.id),
getUpcomingInvoice(org.id),
]);
return (
<BillingLayout>
<UpcomingInvoice invoice={upcoming} />
<InvoiceHistory invoices={invoices} />
</BillingLayout>
);
}Pattern 6: Usage Tracking
Usage data may be shown with different freshness requirements:
// lib/usage.ts
import { cache } from '@/lib/cache';
// Current period usage - needs to be relatively fresh
async function getCurrentUsage(orgId: string) {
'use cache: private';
cache.customer.usage.current.cacheTag({ orgId });
return db.usage.aggregate({
where: {
orgId,
period: getCurrentBillingPeriod(),
},
_sum: { count: true },
});
}
// Historical usage - can be cached longer
async function getUsageHistory(orgId: string, period: string) {
'use cache: private';
cache.customer.usage.byOrgAndPeriod.cacheTag({ orgId, period });
return db.usage.findMany({
where: { orgId, period },
orderBy: { date: 'asc' },
});
}
// When usage is recorded
export async function recordUsage(orgId: string, amount: number) {
await db.usage.create({
data: {
orgId,
count: amount,
period: getCurrentBillingPeriod(),
date: new Date(),
},
});
// Update current usage cache
cache.customer.usage.current.revalidateTag({ orgId });
cache.customer.usage.byOrgId.revalidateTag({ orgId });
}Pattern 7: Stripe Webhook Handler
Handle Stripe webhooks and update caches:
// app/api/webhooks/stripe/route.ts
import { cache } from '@/lib/cache';
export async function POST(request: Request) {
const event = await constructStripeEvent(request);
switch (event.type) {
case 'invoice.paid': {
const invoice = event.data.object;
const orgId = await getOrgIdFromStripeCustomer(invoice.customer);
// New invoice created
cache.customer.invoices.byOrgId.revalidateTag({ orgId });
cache.admin.invoices.byOrgId.revalidateTag({ orgId });
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const orgId = await getOrgIdFromStripeCustomer(subscription.customer);
// Subscription changed
cache.customer.subscriptions.byOrgId.updateTag({ orgId });
cache.customer.limits.byOrgId.updateTag({ orgId });
cache.customer.invoices.upcoming.revalidateTag({ orgId });
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const orgId = await getOrgIdFromStripeCustomer(subscription.customer);
// Subscription ended
cache.customer.subscriptions.byOrgId.updateTag({ orgId });
cache.customer.limits.byOrgId.updateTag({ orgId });
break;
}
}
return new Response('OK');
}Admin Dashboard
// app/admin/billing/page.tsx
import { cache } from '@/lib/cache';
async function getBillingOverview() {
'use cache: remote';
cache.admin.subscriptions.list.cacheTag();
return {
activeSubscriptions: await db.subscriptions.count({ where: { status: 'active' } }),
mrr: await calculateMRR(),
churnRate: await calculateChurnRate(),
};
}
async function getRecentInvoices() {
'use cache: remote';
cache.admin.invoices.list.cacheTag();
return db.invoices.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
include: { organization: true },
});
}Frequently Asked Questions
How do I update pricing and have the public page reflect it immediately?
Use updateTag() on BOTH admin and public scopes - pricing must never be stale.
async function updatePlanPrice(planId: string, newPrice: number) {
await db.plans.update({ where: { id: planId }, data: { price: newPrice } });
// Admin sees immediately
cache.admin.pricing.plans.byId.updateTag({ planId });
// → updateTag('admin/pricing/plans/byId:plan-pro')
cache.admin.pricing.plans.list.updateTag();
// → updateTag('admin/pricing/plans/list')
// Public pricing page ALSO updates immediately (pricing is critical)
cache.public.pricing.plans.byId.updateTag({ planId });
// → updateTag('public/pricing/plans/byId:plan-pro')
cache.public.pricing.plans.list.updateTag();
// → updateTag('public/pricing/plans/list')
}UX: The pricing page shows correct prices immediately for everyone. A customer refreshing the page always sees accurate pricing. This may cause a brief loading state, but pricing accuracy is non-negotiable.
How do I update a subscription status with stale-while-revalidate?
Use revalidateTag() if the subscription status can tolerate brief staleness (rare cases only).
// Only use SWR for non-critical subscription data
async function updateSubscriptionMetadata(orgId: string, metadata: object) {
await db.subscriptions.update({ where: { orgId }, data: { metadata } });
// Metadata can use SWR - it's not access-critical
cache.customer.subscriptions.byOrgId.revalidateTag({ orgId });
// → revalidateTag('customer/subscriptions/byOrgId:org-123', 'max')
}UX: The subscription metadata updates in the background. Users see stale metadata briefly, then it refreshes. This is ONLY appropriate for non-critical fields. For subscription status changes (active/canceled), always use updateTag() instead.
When should subscription changes use updateTag vs revalidateTag?
Use updateTag() for anything affecting feature access - users should never get "access denied" for features they paid for.
async function upgradePlan(orgId: string, newPlanId: string) {
await db.subscriptions.update({ where: { orgId }, data: { planId: newPlanId } });
// Subscription status - MUST be immediate (affects feature access)
cache.customer.subscriptions.byOrgId.updateTag({ orgId });
// → updateTag('customer/subscriptions/byOrgId:org-123')
// Usage limits - MUST be immediate (affects what they can do)
cache.customer.limits.byOrgId.updateTag({ orgId });
// → updateTag('customer/limits/byOrgId:org-123')
// Upcoming invoice preview - can use SWR (informational only)
cache.customer.invoices.upcoming.revalidateTag({ orgId });
// → revalidateTag('customer/invoices/upcoming:org-123', 'max')
}UX: After upgrading, users immediately have access to new features with higher limits. They never see "upgrade required" errors for features they just paid for. The invoice preview updates in the background (less critical).
How do I handle Stripe webhook delays with proper cache invalidation?
Use updateTag() in webhook handlers for critical state changes.
// app/api/webhooks/stripe/route.ts
async function handleStripeWebhook(event: Stripe.Event) {
switch (event.type) {
case 'customer.subscription.updated': {
const subscription = event.data.object;
const orgId = await getOrgIdFromStripeCustomer(subscription.customer);
// Subscription changed - immediate update (critical)
cache.customer.subscriptions.byOrgId.updateTag({ orgId });
// → updateTag('customer/subscriptions/byOrgId:org-123')
cache.customer.limits.byOrgId.updateTag({ orgId });
// → updateTag('customer/limits/byOrgId:org-123')
// Invoice preview changes
cache.customer.invoices.upcoming.revalidateTag({ orgId });
// → revalidateTag('customer/invoices/upcoming:org-123', 'max')
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
const orgId = await getOrgIdFromStripeCustomer(invoice.customer);
// New invoice - update the list
cache.customer.invoices.byOrgId.revalidateTag({ orgId });
// → revalidateTag('customer/invoices/byOrgId:org-123', 'max')
break;
}
}
}UX: Even though Stripe webhooks may arrive seconds after the actual event, using updateTag() ensures the cache reflects the new state immediately when the webhook is processed. Users checking their subscription status see accurate information.
How do I cache usage metrics that change frequently?
Use revalidateTag() for current usage (acceptable staleness) and cache historical data longer.
// Current period usage - relatively fresh, but SWR is fine
async function getCurrentUsage(orgId: string) {
'use cache: private';
cache.customer.usage.current.cacheTag({ orgId });
// → cacheTag('customer/usage/current:org-123', ...)
return db.usage.sum({ where: { orgId, period: getCurrentPeriod() } });
}
// Historical usage - can be cached indefinitely
async function getUsageHistory(orgId: string, period: string) {
'use cache: private';
cache.customer.usage.byOrgAndPeriod.cacheTag({ orgId, period });
// → cacheTag('customer/usage/byOrgAndPeriod:org-123:2024-01', ...)
return db.usage.findMany({ where: { orgId, period } });
}
// When recording new usage
async function recordUsage(orgId: string, amount: number) {
await db.usage.create({ data: { orgId, count: amount, period: getCurrentPeriod() } });
// Only invalidate current period (historical is immutable)
cache.customer.usage.current.revalidateTag({ orgId });
// → revalidateTag('customer/usage/current:org-123', 'max')
}UX: The usage dashboard shows reasonably fresh data (within seconds) without loading spinners. Historical months load instantly from cache. Current usage refreshes in the background as new events are recorded.
How do I ensure invoice data is only visible to the right customer?
Use the customer scope with orgId - invoice data is never shared across organizations.
// Fetching invoices - always scoped to the customer's org
async function getInvoices(orgId: string) {
'use cache: private';
cache.customer.invoices.byOrgId.cacheTag({ orgId });
// → cacheTag('customer/invoices/byOrgId:org-123', 'customer/invoices', 'customer', 'invoices/byOrgId:org-123', 'invoices')
return db.invoices.findMany({ where: { orgId } });
}
// Invalidation is also org-specific
cache.customer.invoices.byOrgId.revalidateTag({ orgId: 'org-123' });
// → revalidateTag('customer/invoices/byOrgId:org-123', 'max')
// This ONLY affects org-123's invoice cacheUX: Each customer only ever sees their own invoices. The cache tags include the orgId, so org-123's invoices are completely separate from org-456's. There's no risk of cross-customer invoice leakage.
Key Takeaways
- Pricing should update instantly: Public pricing pages need immediate updates when prices change
- Subscription status is critical: It affects feature access, use
updateTag()for customers - Invoice data is sensitive: Only cache within customer scope, never cross-scope
- Webhook handlers update caches: Stripe events should trigger cache invalidation
- Usage data freshness varies: Current usage needs to be fresh, historical can be cached longer