AdvancedUpdated Jan 25, 2026
Building a Multi-Tenant AI SaaS with Abstrakt
Learn how to build a production-ready multi-tenant SaaS application with AI features, including user isolation, usage tracking, billing integration, and scaling strategies.
DP
David Park
Senior Engineer
25 min read
Introduction
Building a multi-tenant AI SaaS requires careful architecture to ensure tenant isolation, fair resource allocation, and accurate billing. This tutorial walks through the complete implementation.
What You'll BuildA multi-tenant AI image generation SaaS with:
- Tenant isolation and data separation
- Per-tenant usage tracking and limits
- Stripe billing integration
- API key management
- Admin dashboard
Architecture Overview
text
┌─────────────────────────────────────────────────────┐
│ Your SaaS │
├─────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Tenant A│ │ Tenant B│ │ Tenant C│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ┌────▼────────────▼────────────▼────┐ │
│ │ API Gateway │ │
│ │ (Auth, Rate Limiting) │ │
│ └────────────────┬──────────────────┘ │
│ │ │
│ ┌────────────────▼──────────────────┐ │
│ │ Usage Tracking │ │
│ │ (Credits, Quotas, Billing) │ │
│ └────────────────┬──────────────────┘ │
│ │ │
└───────────────────┼─────────────────────────────────┘
│
┌────────▼────────┐
│ Abstrakt API │
└─────────────────┘Database Schema
sql
-- Tenants (Organizations) CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, slug VARCHAR(100) UNIQUE NOT NULL, plan VARCHAR(50) DEFAULT 'free', stripe_customer_id VARCHAR(255), created_at TIMESTAMP DEFAULT NOW() ); -- Tenant API Keys CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID REFERENCES tenants(id), key_hash VARCHAR(64) NOT NULL, name VARCHAR(100), last_used_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); -- Usage Records CREATE TABLE usage_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID REFERENCES tenants(id), model VARCHAR(100) NOT NULL, credits_used INTEGER NOT NULL, metadata JSONB, created_at TIMESTAMP DEFAULT NOW() ); -- Monthly Usage Summaries CREATE TABLE usage_summaries ( tenant_id UUID REFERENCES tenants(id), month DATE NOT NULL, total_credits INTEGER DEFAULT 0, total_requests INTEGER DEFAULT 0, PRIMARY KEY (tenant_id, month) );
Tenant Management
Tenant Service
typescript
// lib/tenants.ts
import { db } from './db';
export interface Tenant {
id: string;
name: string;
slug: string;
plan: 'free' | 'pro' | 'enterprise';
stripeCustomerId?: string;
}
export const PLAN_LIMITS = {
free: { monthlyCredits: 100, rateLimit: 10 },
pro: { monthlyCredits: 5000, rateLimit: 100 },
enterprise: { monthlyCredits: 50000, rateLimit: 1000 },
};
export async function getTenant(tenantId: string): Promise<Tenant | null> {
const result = await db.query(
'SELECT * FROM tenants WHERE id = $1',
[tenantId]
);
return result.rows[0] || null;
}
export async function createTenant(data: Partial<Tenant>): Promise<Tenant> {
const result = await db.query(
`INSERT INTO tenants (name, slug, plan)
VALUES ($1, $2, $3)
RETURNING *`,
[data.name, data.slug, data.plan || 'free']
);
return result.rows[0];
}
export async function getTenantUsage(tenantId: string, month: Date) {
const result = await db.query(
`SELECT * FROM usage_summaries
WHERE tenant_id = $1 AND month = $2`,
[tenantId, month]
);
return result.rows[0] || { total_credits: 0, total_requests: 0 };
}API Key Authentication
Key Generation and Validation
typescript
// lib/api-keys.ts
import crypto from 'crypto';
import { db } from './db';
const KEY_PREFIX = 'sk_';
export function generateApiKey(): { key: string; hash: string } {
const randomBytes = crypto.randomBytes(24).toString('hex');
const key = KEY_PREFIX + randomBytes;
const hash = crypto.createHash('sha256').update(key).digest('hex');
return { key, hash };
}
export async function createApiKey(tenantId: string, name: string) {
const { key, hash } = generateApiKey();
await db.query(
`INSERT INTO api_keys (tenant_id, key_hash, name)
VALUES ($1, $2, $3)`,
[tenantId, hash, name]
);
// Return the actual key only once
return { key, name };
}
export async function validateApiKey(key: string) {
const hash = crypto.createHash('sha256').update(key).digest('hex');
const result = await db.query(
`SELECT ak.*, t.*
FROM api_keys ak
JOIN tenants t ON ak.tenant_id = t.id
WHERE ak.key_hash = $1`,
[hash]
);
if (result.rows.length === 0) {
return null;
}
// Update last used
await db.query(
'UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1',
[hash]
);
return result.rows[0];
}Usage Tracking
Usage Service
typescript
// lib/usage.ts
import { db } from './db';
import { PLAN_LIMITS } from './tenants';
export async function recordUsage(
tenantId: string,
model: string,
credits: number,
metadata?: object
) {
// Record individual usage
await db.query(
`INSERT INTO usage_records (tenant_id, model, credits_used, metadata)
VALUES ($1, $2, $3, $4)`,
[tenantId, model, credits, JSON.stringify(metadata || {})]
);
// Update monthly summary
const month = new Date().toISOString().slice(0, 7) + '-01';
await db.query(
`INSERT INTO usage_summaries (tenant_id, month, total_credits, total_requests)
VALUES ($1, $2, $3, 1)
ON CONFLICT (tenant_id, month)
DO UPDATE SET
total_credits = usage_summaries.total_credits + $3,
total_requests = usage_summaries.total_requests + 1`,
[tenantId, month, credits]
);
}
export async function checkQuota(tenantId: string, plan: string) {
const month = new Date().toISOString().slice(0, 7) + '-01';
const usage = await db.query(
'SELECT total_credits FROM usage_summaries WHERE tenant_id = $1 AND month = $2',
[tenantId, month]
);
const used = usage.rows[0]?.total_credits || 0;
const limit = PLAN_LIMITS[plan]?.monthlyCredits || 0;
return {
used,
limit,
remaining: Math.max(0, limit - used),
exceeded: used >= limit,
};
}API Gateway
Main API Handler
typescript
// app/api/v1/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { validateApiKey } from '@/lib/api-keys';
import { checkQuota, recordUsage } from '@/lib/usage';
import { checkRateLimit } from '@/lib/rate-limit';
const MODEL_COSTS = {
'flux-schnell': 1,
'flux-dev': 2,
'flux-pro': 5,
};
export async function POST(request: NextRequest) {
// 1. Authenticate
const apiKey = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!apiKey) {
return NextResponse.json({ error: 'Missing API key' }, { status: 401 });
}
const tenant = await validateApiKey(apiKey);
if (!tenant) {
return NextResponse.json({ error: 'Invalid API key' }, { status: 401 });
}
// 2. Rate limit check
const rateLimitResult = await checkRateLimit(tenant.id, tenant.plan);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded', retry_after: rateLimitResult.retryAfter },
{ status: 429 }
);
}
// 3. Quota check
const body = await request.json();
const model = body.model || 'flux-schnell';
const cost = MODEL_COSTS[model] || 1;
const quota = await checkQuota(tenant.id, tenant.plan);
if (quota.remaining < cost) {
return NextResponse.json(
{ error: 'Quota exceeded', usage: quota },
{ status: 402 }
);
}
// 4. Forward to Abstrakt
try {
const result = await generateWithAbstrakt(model, body.input);
// 5. Record usage
await recordUsage(tenant.id, model, cost, {
prompt: body.input.prompt?.slice(0, 100),
});
return NextResponse.json({
success: true,
result: result,
usage: {
credits_used: cost,
credits_remaining: quota.remaining - cost,
},
});
} catch (error) {
return NextResponse.json(
{ error: 'Generation failed', details: error.message },
{ status: 500 }
);
}
}
async function generateWithAbstrakt(model: string, input: object) {
const response = await fetch(
`https://api.abstrakt.one/v1/models/${model}/run`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ABSTRAKT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ input }),
}
);
return response.json();
}Stripe Billing Integration
Subscription Management
typescript
// lib/billing.ts
import Stripe from 'stripe';
import { db } from './db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PLAN_PRICES = {
pro: 'price_pro_monthly_xxxxx',
enterprise: 'price_enterprise_monthly_xxxxx',
};
export async function createCheckoutSession(
tenantId: string,
plan: 'pro' | 'enterprise'
) {
const tenant = await db.query(
'SELECT * FROM tenants WHERE id = $1',
[tenantId]
);
let customerId = tenant.rows[0]?.stripe_customer_id;
// Create Stripe customer if needed
if (!customerId) {
const customer = await stripe.customers.create({
metadata: { tenant_id: tenantId },
});
customerId = customer.id;
await db.query(
'UPDATE tenants SET stripe_customer_id = $1 WHERE id = $2',
[customerId, tenantId]
);
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: PLAN_PRICES[plan], quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { tenant_id: tenantId, plan },
});
return session.url;
}
// Webhook handler
export async function handleStripeWebhook(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await db.query(
'UPDATE tenants SET plan = $1 WHERE id = $2',
[session.metadata?.plan, session.metadata?.tenant_id]
);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string
);
await db.query(
'UPDATE tenants SET plan = $1 WHERE stripe_customer_id = $2',
['free', customer.id]
);
break;
}
}
}Admin Dashboard
Usage Analytics API
typescript
// app/api/admin/analytics/route.ts
export async function GET(request: NextRequest) {
const tenantId = request.headers.get('x-tenant-id');
// Get usage by day for the last 30 days
const dailyUsage = await db.query(
`SELECT
DATE(created_at) as date,
SUM(credits_used) as credits,
COUNT(*) as requests
FROM usage_records
WHERE tenant_id = $1
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date`,
[tenantId]
);
// Get usage by model
const byModel = await db.query(
`SELECT
model,
SUM(credits_used) as credits,
COUNT(*) as requests
FROM usage_records
WHERE tenant_id = $1
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY model`,
[tenantId]
);
return NextResponse.json({
daily: dailyUsage.rows,
byModel: byModel.rows,
});
}Deployment Considerations
Environment Variables
# .env.production DATABASE_URL=postgres://... ABSTRAKT_API_KEY=abs_... STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_...
Scaling Strategies
- Connection Pooling: Use PgBouncer for database connections
- Caching: Redis for rate limiting and frequently accessed data
- Queue: Use BullMQ for async job processing
- CDN: Cache generated images at the edge
Monitoring
typescript
// lib/monitoring.ts
export function trackMetric(name: string, value: number, tags: object) {
// Send to your monitoring service (Datadog, etc.)
console.log(`METRIC: ${name} = ${value}`, tags);
}
// Usage in API handler
trackMetric('api.request', 1, {
tenant: tenant.id,
model,
status: 'success'
});Complete Project Structure
text
├── app/ │ ├── api/ │ │ ├── v1/ │ │ │ └── generate/route.ts │ │ ├── admin/ │ │ │ └── analytics/route.ts │ │ └── webhooks/ │ │ └── stripe/route.ts │ ├── dashboard/ │ │ ├── page.tsx │ │ └── settings/page.tsx │ └── pricing/page.tsx ├── lib/ │ ├── db.ts │ ├── tenants.ts │ ├── api-keys.ts │ ├── usage.ts │ ├── billing.ts │ └── rate-limit.ts └── middleware.ts
Next Steps
- Add team member management
- Implement usage alerts
- Build customer-facing analytics
- Add audit logging
#saas#multi-tenant#billing#architecture#production