@withwiz/blog-system
v0.3.0
Published
Blog SaaS framework — single/multi-tenant, auth, billing, domain management
Downloads
873
Readme
@withwiz/blog-system
A complete Blog/News SaaS framework supporting single-tenant and multi-tenant configurations.
@withwiz/blog-system extends @withwiz/blog-core with authentication + tenants + billing (Stripe) + custom domains + onboarding + super admin, forming a production-ready SaaS platform.
A single call to createBlogSystem({ mode, ... }) assembles all services and API routes.
Table of Contents
- Dependencies
- When to Use blog-system vs blog-core
- Installation
- Quick Start — Single Mode
- Quick Start — Multi-Tenant Mode
- Next.js App Router Integration
- Feature Toggles
- BlogSystem Return Type
- Authentication
- Tenant Management (Multi Mode)
- Row-Level Isolation — Tenant Proxy
- Custom Domains
- Billing (Stripe)
- Onboarding
- Super Admin
- Environment Variables
- Database Setup
- Deployment
- Security Checklist
- Documentation Guide
- Feature Checklist
- Related Packages
Dependencies
| Package | Role |
|---|---|
| @withwiz/blog-core | Blog CRUD, tags, comments, search, scheduler, SEO (dependency) |
| @withwiz/toolkit | JWT, OAuth, PasswordHasher, Prisma repositories (peer) |
| @prisma/client | Database access (peer) |
| stripe | Billing (dependency) |
| next | Next.js framework (peer, >=15) |
| react | React (peer, >=18) |
| zod | Schema validation (peer, >=3) |
When to Use blog-system vs blog-core
| Scenario | Recommendation |
|---|---|
| Single site (1 blog/news) + simple auth | blog-system mode: 'single' — auth & routes assembled for you |
| Single site + fully custom auth | blog-core standalone |
| Multi-tenant SaaS (subdomains, custom domains, Stripe billing) | blog-system mode: 'multi' (required) |
| Need only a subset of features | blog-core standalone or selectively import sub-paths |
Installation
Add to your host project's package.json:
{
"dependencies": {
"@withwiz/blog-core": "file:./packages/blog-core",
"@withwiz/blog-system": "file:./packages/blog-system"
}
}Install peer dependencies:
npm install @prisma/client next react stripe zodQuick Start — Single Mode
mode: 'single' is for single-site use. Tenant/Billing/Domain services are not activated.
// src/lib/blog-system.ts
import { createBlogSystem } from '@withwiz/blog-system';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const system = createBlogSystem({
mode: 'single',
prisma,
auth: {
jwtSecret: process.env.JWT_SECRET!,
accessTokenExpiry: '15m',
refreshTokenExpiry: '7d',
},
blog: {
modelName: 'news',
basePath: '/blog',
adminBasePath: '/admin/blog',
pageSize: 12,
},
features: {
tags: true,
comments: { enabled: true, maxDepth: 2 },
search: true,
scheduler: { enabled: true, cronSecret: process.env.CRON_SECRET! },
},
});
// Convenience exports
export const { blogService, authService, tagService, commentService, searchService } = system;Quick Start — Multi-Tenant Mode
import { createBlogSystem } from '@withwiz/blog-system';
export const system = createBlogSystem({
mode: 'multi',
prisma,
auth: { jwtSecret: process.env.JWT_SECRET! },
blog: { modelName: 'post' },
domain: {
baseDomain: 'blog.example.com',
vercelTeamId: process.env.VERCEL_TEAM_ID,
vercelProjectId: process.env.VERCEL_PROJECT_ID,
vercelApiToken: process.env.VERCEL_API_TOKEN,
},
billing: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
plans: [
{
id: 'free',
name: 'Free',
stripePriceId: null,
limits: { posts: 10, storage: 100_000_000 },
},
{
id: 'pro',
name: 'Pro',
stripePriceId: 'price_xxx',
limits: { posts: -1, storage: 5_000_000_000, custom_domains: 3 },
},
],
},
});domain.baseDomain is the reference for subdomain resolution:
my-blog.blog.example.com maps to tenant slug='my-blog'.
Next.js App Router Integration
Auth Routes
// app/api/auth/register/route.ts
export const POST = system.routes.auth.register;
// app/api/auth/login/route.ts
export const POST = system.routes.auth.login;
// app/api/auth/refresh/route.ts
export const POST = system.routes.auth.refresh;
// app/api/auth/oauth/[provider]/callback/route.ts
export const GET = system.routes.auth.oauthCallback;Blog Routes
// app/api/blog/route.ts
export const GET = system.routes.blog.list;
// app/api/blog/[slug]/route.ts
export const GET = system.routes.blog.detail;
// app/api/admin/blog/route.ts
export const GET = system.routes.blog.adminList;
export const POST = system.routes.blog.adminCreate;Tags, Comments, Search, Scheduler
// app/api/blog/tags/route.ts
export const GET = system.routes.tag?.list;
// app/api/blog/comments/route.ts
export const POST = system.routes.comment?.create;
// app/api/blog/search/route.ts
export const GET = system.routes.search?.query;
// app/api/cron/blog-publish/route.ts
export const GET = system.routes.scheduler?.publishScheduled.GET;
export const POST = system.routes.scheduler?.publishScheduled.POST;Multi-Tenant Routes (multi mode only)
// app/api/tenants/route.ts
export const GET = system.routes.tenant?.list;
export const POST = system.routes.tenant?.create;
// app/api/billing/checkout/route.ts
export const POST = system.routes.billing?.createCheckout;
// app/api/webhooks/stripe/route.ts
export const POST = system.routes.billing?.webhook;
// app/api/admin/system/route.ts
export const GET = system.routes.admin?.dashboard;Auth Middleware (Next.js)
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { system } from '@/lib/blog-system';
export async function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith('/admin')) {
const token = req.cookies.get('access_token')?.value;
if (!token) return NextResponse.redirect(new URL('/login', req.url));
const user = await system.authService!.getCurrentUser(token);
if (!user) return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}
export const config = { matcher: ['/admin/:path*'] };Feature Toggles
Shared between single and multi modes:
features: {
tags: true, // default: true
comments: {
enabled: true,
autoApprove: false,
requireLogin: false,
maxDepth: 2,
rateLimit: { maxPerHour: 5 },
},
search: true, // default: true (requires Postgres FTS)
scheduler: {
enabled: true,
cronSecret: process.env.CRON_SECRET!,
},
}BlogSystem Return Type
interface BlogSystem {
blogService: BlogService | null; // instantiated in single mode only
authService: AuthService | null;
tenantService: TenantService | null; // multi mode only
tenantUserService: TenantUserService | null;
onboardingService: OnboardingService | null;
billingService: BillingService | null;
planService: PlanService | null;
domainService: DomainService | null;
tagService, commentService, searchService, schedulerService;
routes: {
blog, auth, tenant?, admin?, billing?, domain?,
tag?, comment?, search?, scheduler?,
};
middleware: {
resolveTenantFromRequest, requireTenantRole, ...
};
blogConfig: BlogConfig;
createScopedBlogService: ((tenantId: string) => BlogService) | null; // multi mode
}Note: In multi mode,
blogServiceisnull. UsecreateScopedBlogService(tenantId)to obtain a tenant-scoped service.
Authentication
Overview
- JWT access/refresh tokens with httpOnly secure cookies
- OAuth support: Google, GitHub
- Password hashing with constant-time comparison
- Refresh Token Rotation (previous token invalidated on reissue)
Token Configuration
| Token | Default Expiry | Storage |
|---|---|---|
| Access Token | 15 minutes | httpOnly; Secure; SameSite=Strict cookie |
| Refresh Token | 7 days | httpOnly; Secure; SameSite=Strict cookie |
Important: Never store tokens in
localStorage. Always use httpOnly cookies.
OAuth Setup
auth: {
jwtSecret: process.env.JWT_SECRET!,
oauthProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: process.env.GITHUB_REDIRECT_URI!,
},
},
},Auth API Endpoints
| Endpoint | Method | Description |
|---|---|---|
| /api/auth/register | POST | Register new user (email, password, name) |
| /api/auth/login | POST | Login (email, password) → access + refresh tokens |
| /api/auth/refresh | POST | Refresh access token |
| /api/auth/oauth/[provider]/callback | GET | OAuth callback (Google/GitHub) |
Tenant Management (Multi Mode)
Core Concepts
| Concept | Description |
|---|---|
| Tenant | Customer/subscription unit with slug and optional custom domain |
| TenantUser | User-tenant mapping with role |
| Role Hierarchy | OWNER > ADMIN > EDITOR > VIEWER |
| Super Admin | SystemRole.SUPER_ADMIN — system-wide management authority |
RBAC (Role-Based Access Control)
| Role | Permissions |
|---|---|
| OWNER | Full control: delete tenant, manage billing, all lower permissions |
| ADMIN | Manage users, settings, all lower permissions |
| EDITOR | Create/edit/delete posts, manage tags |
| VIEWER | Read-only access |
Protecting Routes with Role Middleware
import { createTenantRoleMiddleware } from '@withwiz/blog-system';
import { TenantRole } from '@withwiz/blog-system';
const requireEditor = createTenantRoleMiddleware(TenantRole.EDITOR);
export async function POST(req: Request) {
const authResult = await requireEditor(req);
if (authResult instanceof Response) return authResult; // 403
// proceed with authorized logic
}Tenant Resolution
Request hostname is used to identify the tenant:
| Request Host | Match |
|---|---|
| my-blog.blog.example.com | Subdomain → tenant.slug = 'my-blog' |
| www.customsite.com | Custom domain lookup |
| blog.example.com | No match (landing/marketing page) |
Tenant Resolution Middleware
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { system, TENANT_ID_HEADER } from '@/lib/blog-system';
export async function middleware(req: NextRequest) {
const result = await system.middleware.resolveTenantFromRequest!(req);
if (!result) {
return NextResponse.rewrite(new URL('/tenant-not-found', req.url));
}
const headers = new Headers(req.headers);
headers.set(TENANT_ID_HEADER, result.tenantId);
return NextResponse.next({ request: { headers } });
}Accessing Tenant ID in Server Components
import { headers } from 'next/headers';
import { TENANT_ID_HEADER } from '@withwiz/blog-system';
export async function getCurrentTenantId() {
return headers().get(TENANT_ID_HEADER);
}Row-Level Isolation — Tenant Proxy
createTenantProxy wraps the Prisma client with a Proxy that automatically injects tenantId conditions into all queries.
| Method | Injection |
|---|---|
| findMany, findFirst, findUnique, count, groupBy | where.tenantId = X |
| create | data.tenantId = X |
| update, updateMany, delete, deleteMany | where.tenantId = X |
| $transaction | Inner tx object is also proxied |
Direct Usage
import { createTenantProxy } from '@withwiz/blog-system';
const scopedPrisma = createTenantProxy(prisma, 'tenant-id-123');
const posts = await scopedPrisma.post.findMany();
// Actual query: SELECT * FROM post WHERE tenantId = 'tenant-id-123'Via createScopedBlogService
export async function GET(req: Request) {
const tenant = await system.middleware.resolveTenantFromRequest!(req);
if (!tenant) return new Response('Not found', { status: 404 });
const blog = system.createScopedBlogService!(tenant.tenantId);
const result = await blog.listPublished({ page: 1, limit: 12 });
return Response.json(result);
}Warning: Tenant resolution must be performed on every API call. Missing it risks leaking data across tenants.
Custom Domains
DNS TXT Verification
Custom domains are verified via DNS TXT records:
- Tenant adds a custom domain
- System generates a verification token
- Tenant creates DNS TXT record:
_withwiz-verify.{domain}→ token value - System verifies the TXT record
- Domain is activated
Optional Vercel Integration
When deploying on Vercel, SSL is automatically provisioned:
domain: {
baseDomain: 'blog.example.com',
vercelTeamId: process.env.VERCEL_TEAM_ID,
vercelProjectId: process.env.VERCEL_PROJECT_ID,
vercelApiToken: process.env.VERCEL_API_TOKEN,
},For self-hosted environments, configure nginx or Traefik with Host header routing.
Billing (Stripe)
Plan Definition
billing: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
plans: [
{
id: 'free',
name: 'Free',
stripePriceId: null,
limits: { posts: 10, storage: 100_000_000 },
},
{
id: 'pro',
name: 'Pro',
stripePriceId: 'price_xxx',
limits: { posts: -1, storage: 5_000_000_000, custom_domains: 3 },
},
],
},Checkout Flow
billingService.createCheckoutSession(tenantId, planId)→ Stripe Checkout URL- User completes payment on Stripe
- Stripe sends
checkout.session.completedwebhook - System updates subscription status
Usage Tracking
const check = await billingService.checkUsage(tenantId, 'posts');
if (!check.allowed) {
return new Response('Plan limit reached', { status: 402 });
}Stripe Webhook Setup
- Stripe Dashboard → Webhooks → Add endpoint
- URL:
https://your-domain.com/api/webhooks/stripe - Events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_succeeded,invoice.payment_failed - Set the signing secret as
STRIPE_WEBHOOK_SECRET
Local Testing
stripe listen --forward-to localhost:3000/api/webhooks/stripeOnboarding
The OnboardingService atomically creates a new tenant setup:
- Tenant — name, slug, settings
- Owner — first user with
OWNERrole - Default categories — general, notice, tech
- Sample post (optional)
- Plan assignment (optional)
Slug Rules
- 3–63 characters
- Lowercase letters, digits, hyphens only
- Reserved words blocked:
www,api,admin,app,mail,blog,static
const result = await system.onboardingService!.onboard({
tenantName: 'My Blog',
tenantSlug: 'my-blog',
ownerEmail: '[email protected]',
ownerPassword: 'securePassword123',
ownerName: 'Blog Owner',
planId: 'free',
});
// result.tenant, result.owner, result.tenantUserSuper Admin
Users with SystemRole.SUPER_ADMIN have system-wide administrative access.
Capabilities
- View and manage all tenants
- Activate/deactivate tenants
- Manage user roles across tenants
- Monitor system metrics (total tenants, users, posts, storage)
Middleware
// Super admin routes are protected automatically
export const GET = system.routes.admin?.dashboard;
export const POST = system.routes.admin?.deactivateTenant;Management routes (routes.admin, routes.tenant, routes.billing, routes.domain, except the public billing plans and webhook) are wrapped with toolkit withAuthApi, which only authenticates the request. Each handler then checks blog-system roles:
- Super admin routes and platform-level operations (tenant list/create/deactivate, billing
adminPlans, domainlist) require the JWT roleSUPER_ADMIN. - Tenant-level operations require membership in the target tenant with
ADMINor higher. A system role (includingSUPER_ADMIN) does not replace membership. - The toolkit
'ADMIN'role required bywithAdminApiis not needed. Missing token → 401, insufficient permission → 403. - These routes use the toolkit
apirate limit type.
routes.auth logout, me and changePassword are available to any signed-in user.
Initial Setup
The first super admin is created via database migration/seed:
UPDATE users SET system_role = 'SUPER_ADMIN' WHERE email = '[email protected]';Warning:
SUPER_ADMINhas unrestricted access. All actions must be audit-logged.
Environment Variables
Required (All Modes)
DATABASE_URL=postgresql://user:pass@host:5432/db?schema=public
JWT_SECRET=<openssl rand -base64 48> # minimum 32 characters
NEXT_PUBLIC_SITE_URL=https://example.comOAuth (Optional)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=https://example.com/api/auth/oauth/google/callback
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GITHUB_REDIRECT_URI=https://example.com/api/auth/oauth/github/callbackScheduled Publishing (Optional)
CRON_SECRET=<openssl rand -hex 32>Stripe — Multi-Tenant Billing (Optional)
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxxCustom Domains — Vercel (Optional)
VERCEL_TEAM_ID=team_xxx
VERCEL_PROJECT_ID=prj_xxx
VERCEL_API_TOKEN=xxxR2 Storage (Optional)
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET_NAME=...
R2_PUBLIC_URL=https://cdn.example.comDatabase Setup
# 1. Apply schema migrations
npx prisma migrate deploy
# 2. (Optional) Full-text search migration
psql "$DATABASE_URL" -f packages/blog-core/prisma/migrations/fulltext-search.sql
# 3. (Multi mode) Create initial super admin
psql "$DATABASE_URL" -c "UPDATE users SET system_role='SUPER_ADMIN' WHERE email='[email protected]';"Prisma Schema (Multi Mode)
Your host Prisma schema must include tenant models:
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
customDomain String? @unique @map("custom_domain")
isActive Boolean @default(true) @map("is_active")
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users TenantUser[]
posts Post[]
@@map("tenants")
}
model TenantUser {
tenantId String
userId String
role TenantRole
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tenantId, userId])
@@map("tenant_users")
}
enum TenantRole {
OWNER
ADMIN
EDITOR
VIEWER
}
model Post {
id String @id @default(cuid())
tenantId String
// ... blog-core standard fields
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([tenantId, published, publishedAt(sort: Desc)])
@@map("posts")
}Deployment
Build & Run
# Production build
npm run build
# Production server
npm start
# Docker
docker build -t my-blog .
docker run -p 3000:3000 --env-file .env.production my-blogHealth Check
// app/api/health/route.ts
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({ status: 'ok', time: new Date().toISOString() });
} catch {
return Response.json({ status: 'error' }, { status: 503 });
}
}Cron Setup
| Hosting | Recommended Cron |
|---|---|
| Vercel | Vercel Cron (vercel.json) |
| AWS ECS/Fargate | EventBridge → Lambda → HTTP |
| GCP Cloud Run | Cloud Scheduler → HTTP |
| Self-hosted | crontab + curl |
| GitHub Actions | Scheduled workflow |
Monitoring
- APM: Sentry, New Relic, Datadog
- Logs: Loki + Grafana, CloudWatch, BetterStack
- Uptime: UptimeRobot, Pingdom
- Alerts: Sentry → Slack/Discord
Security Checklist
- [ ]
JWT_SECRETminimum 32 chars random (openssl rand -base64 48) - [ ]
CRON_SECRETminimum 32 chars random - [ ]
.env*files in.gitignore - [ ] Production secrets in secure storage (GitHub Secrets / AWS SSM / Vercel Env)
- [ ] All auth cookies:
HttpOnly; Secure; SameSite=Strict - [ ] Access Token expiry ≤ 15 minutes
- [ ] Refresh Token Rotation implemented
- [ ] No tokens in
localStorage - [ ] CSP, HSTS, X-Content-Type-Options headers set
- [ ] All input validated with Zod schemas
- [ ] HTML content sanitized via
sanitizeContent - [ ] Admin routes protected with
requireAdminmiddleware - [ ] Multi mode: tenant resolution on every API call
- [ ] Tenant data accessed only through
tenantProxy - [ ] Stripe webhook
stripe-signatureverification - [ ] Cron routes require
Authorization: Bearerverification - [ ] Super admin actions audit-logged
- [ ] Login endpoint rate-limited
- [ ] No stack traces in production responses
Documentation Guide
| Document | Content | |---|---| | 01-getting-started.md | Installation, single/multi mode selection | | 02-single-tenant.md | Single mode full configuration | | 03-multi-tenant.md | Multi mode, tenant resolution, isolation | | 04-authentication.md | JWT/OAuth/password | | 05-tenant-management.md | Tenant/TenantUser, RBAC | | 06-billing-stripe.md | Stripe subscriptions/webhooks/usage | | 07-custom-domains.md | DNS TXT verification, Vercel API | | 08-onboarding.md | Tenant onboarding flow | | 09-super-admin.md | Super admin console | | 10-deployment.md | Environment variables, cron, security checklist |
Feature Checklist
- [x] Single / Multi mode (
createBlogSystem) - [x] Tenant Proxy (
createTenantProxy) — row-level isolation - [x] JWT access/refresh + OAuth (Google, GitHub)
- [x] Tenant / TenantUser services + role hierarchy (OWNER > ADMIN > EDITOR > VIEWER)
- [x] Stripe subscriptions/checkout/portal/webhooks/usage tracking
- [x] Custom domain DNS TXT verification + Vercel Domain API (optional)
- [x] Onboarding service (tenant + owner + default categories + sample post)
- [x] Super Admin UI (Tenant/User/System dashboard)
- [x] All blog-core features (tags/comments/search/scheduler) exposed as routes
Host Project Structure
src/
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ ├── login/route.ts
│ │ │ ├── refresh/route.ts
│ │ │ └── oauth/[provider]/callback/route.ts
│ │ ├── blog/
│ │ │ ├── route.ts # Public list
│ │ │ ├── [slug]/route.ts # Public detail
│ │ │ ├── search/route.ts
│ │ │ ├── tags/route.ts
│ │ │ └── comments/route.ts
│ │ ├── admin/blog/route.ts # Admin CRUD
│ │ └── cron/blog-publish/route.ts
│ ├── blog/
│ │ ├── page.tsx # Public list UI
│ │ └── [slug]/page.tsx # Public detail UI
│ └── admin/blog/
│ ├── page.tsx # BlogManagerClient
│ └── [id]/page.tsx # BlogEditForm
├── lib/
│ ├── prisma.ts
│ ├── blog-system.ts
│ └── blog-config.ts
└── middleware.tsRelated Packages
@withwiz/blog-core— Core blog engine@withwiz/toolkit— Shared utilities
License
MIT
