A production-ready monorepo for building multi-tenant SaaS applications.
This boilerplate gives you a fully working foundation:
- Multi-tenancy – Organizations with role-based access control (owner/admin/member)
- Authentication – Email/password + Google OAuth via Better Auth
- Billing – Stripe subscriptions, trials, referral credits, and usage limits
- Notifications – Email via Resend
- Analytics – Event tracking via PostHog
- Error tracking – Sentry for frontend and backend
- Background jobs – Scheduled tasks via node-cron
- File storage – S3-compatible blob storage with presigned URLs
- Testing – Unit and integration tests with Vitest + Testcontainers
- CI/CD – GitHub Actions with lint, typecheck, and test jobs
| Layer | Technology |
|---|---|
| Frontend | Next.js, React, Tailwind CSS |
| Backend | Express.js, TypeScript |
| Database | PostgreSQL, Prisma |
| Storage | S3-compatible (Railway/MinIO) |
| Auth | Better Auth (with Organization plugin) |
| Billing | Stripe |
| Resend | |
| Analytics | PostHog |
| Errors | Sentry |
| Testing | Vitest, Testcontainers |
| Build | Turborepo, pnpm |
├── apps/
│ ├── web/ # Next.js frontend (Vercel)
│ │ └── src/
│ │ ├── app/ # Pages (App Router)
│ │ ├── components/ # Feature components (stateful)
│ │ │ └── ui/ # UI primitives (stateless, reusable)
│ │ └── lib/
│ │ ├── api.ts # Typed API client (fetch wrapper)
│ │ ├── api-errors.ts # Error handling utilities
│ │ ├── auth-client.ts # Better Auth React hooks
│ │ └── session.ts # Server-side session utilities
│ │
│ └── api/ # Express backend (Railway)
│ └── src/
│ ├── app.ts # Express config + route registration
│ ├── routes/ # HTTP handlers (one file per resource)
│ ├── services/ # Business logic (one file per domain)
│ ├── jobs/ # Scheduled tasks (cron)
│ ├── middleware/ # Auth, error handling, logging
│ │ └── auth.ts # requireAuth, requireOrgContext, requireRole, requireFeature
│ └── lib/ # Utilities (logger, plan-limits)
│
├── packages/
│ ├── db/ # @project/db – Prisma schema + client
│ │ └── prisma/schema.prisma # Database models
│ ├── auth/ # @project/auth – Better Auth server config
│ ├── billing/ # @project/billing – Stripe helpers
│ ├── notifications/ # @project/notifications – Resend email
│ ├── metrics/ # @project/metrics – PostHog analytics
│ ├── blob/ # @project/blob – S3 storage utilities
│ └── shared/ # @project/shared – Shared types + Zod schemas
│ └── src/
│ ├── types.ts # API response types (ApiResponse<T>, error types)
│ └── validation.ts # Zod schemas for all API contracts
│
├── cursive.json # App configuration (plans, features, limits, trial)
├── spec.md # Product specification (what you're building)
└── .env.example # Environment variables template
- Node.js 20+
- pnpm 9+
- Docker Desktop
- Clone the repository
pnpm install- Copy
.env.exampleto.env - Set
APP_SLUGin.envto matchcursive.jsonslug docker compose up -d(starts PostgreSQL + MinIO)pnpm db:migrate:deploypnpm dev
| Service | URL |
|---|---|
| Frontend | http://localhost:3000 |
| API | http://localhost:4000 |
| MinIO Console | http://localhost:9001 (minioadmin/minioadmin) |
- Routes (
apps/api/src/routes/) handle HTTP: parse requests, send responses, call services - Services (
apps/api/src/services/) handle business logic: database queries, external APIs, data transformation - Routes call services, never the reverse
Every protected route uses a middleware chain. Apply them in order:
// Just authentication
router.get('/profile', requireAuth, handler);
// Auth + organization context (sets req.organizationId, req.member, req.organization)
router.get('/data', requireAuth, requireOrgContext, handler);
// Auth + org + role check
router.delete('/org', requireAuth, requireOrgContext, requireRole('owner'), handler);
// Auth + org + feature gate (checks plan in cursive.json)
router.get('/reports', requireAuth, requireOrgContext, requireFeature('advancedReports'), handler);- Define validation in
packages/shared/src/validation.ts(Zod schema) - Define response type in
packages/shared/src/types.ts - Create service in
apps/api/src/services/{feature}.service.ts - Create route in
apps/api/src/routes/{feature}.tswith middleware chain - Register route in
apps/api/src/app.ts - Add client method in
apps/web/src/lib/api.ts
apps/api/src/routes/example-resources.ts is a complete working example of a resource with usage limits. Study it. The pattern is:
// 1. Check usage limit BEFORE creating
const { allowed, current, limit } = await billingService.checkUsageLimit(orgId, 'exampleResource');
if (!allowed) {
return res.status(402).json({ error: 'Usage limit reached', upgradeRequired: true });
}
// 2. Create resource (your business logic)
// 3. Track usage AFTER successful creation
await billingService.trackUsage(orgId, 'exampleResource', 1);The metric name (exampleResource) must match a key in cursive.json under plans.*.limits.
- Add model to
packages/db/prisma/schema.prisma - Add relation to
Organizationmodel (for multi-tenant scoping) - Run
pnpm db:migrate
Always scope queries by organizationId. Never trust client-supplied org IDs — use req.organizationId from middleware.
The frontend is client-side only — no Next.js API routes, no direct database access. All data comes from the Express API via the typed client in apps/web/src/lib/api.ts:
import { api } from '@/lib/api';
const { data } = await api.users.me();
const { data: plans } = await api.billing.getPlans();import { useSession, useActiveOrganization, useActiveMember } from '@/lib/auth-client';
const { data: session } = useSession(); // Current user
const { data: org } = useActiveOrganization(); // Current organization
const { data: member } = useActiveMember(); // Current membership + roleimport { FeatureGate, RoleGate } from '@/components/gates';
// Show component only if plan has this feature
<FeatureGate feature="advancedReports" fallback={<UpgradePrompt feature="Advanced Reports" />}>
<ReportsPanel />
</FeatureGate>
// Show component only if user has required role
<RoleGate roles={['owner', 'admin']} fallback={<AccessDenied />}>
<AdminSettings />
</RoleGate>When an API call returns a 402 with upgradeRequired: true:
import { useUpgradeModal } from '@/components/upgrade-required-modal';
import { isUpgradeRequiredError } from '@/lib/api-errors';
const { showUpgradeModal } = useUpgradeModal();
try {
await api.someAction();
} catch (error) {
if (isUpgradeRequiredError(error)) {
showUpgradeModal({ feature: 'members', ...error });
}
}| Location | Purpose |
|---|---|
src/components/ui/ |
Stateless primitives (Button, Input, Card) — always reuse these |
src/components/ |
Feature components — stateful, handle API calls |
src/components/gates.tsx |
FeatureGate, RoleGate, UpgradePrompt, AccessDenied |
All plan definitions live in cursive.json:
{
"plans": {
"starter": {
"features": { "advancedReports": false, "apiAccess": true },
"limits": { "exampleResource": 100 }
},
"pro": {
"features": { "advancedReports": true, "apiAccess": true },
"limits": { "exampleResource": 1000 }
},
"enterprise": {
"limits": { "exampleResource": -1 } // -1 = unlimited
}
}
}To add a new limited resource, add its key to limits in each plan, then use checkUsageLimit / trackUsage in your route handler.
To add a new gated feature, add its key to features in each plan, then use requireFeature() on the backend and <FeatureGate> on the frontend.
| Command | Description |
|---|---|
pnpm dev |
Start all services |
pnpm build |
Build for production |
pnpm test:unit |
Run unit tests |
pnpm test:integration |
Run integration tests (Docker req.) |
pnpm typecheck |
TypeScript type checking |
pnpm lint |
Run ESLint |
pnpm format |
Format code with Prettier |
pnpm db:migrate |
Create new migration |
pnpm db:migrate:deploy |
Run migrations |
pnpm db:studio |
Open Prisma Studio |
pnpm db:generate |
Regenerate Prisma client |
Copy .env.example to .env. See the file for detailed setup instructions for each variable.
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
NEXT_PUBLIC_API_URL |
Express API URL |
FRONTEND_URL |
Frontend URL (CORS, emails) |
BETTER_AUTH_SECRET |
Session signing secret |
| Variable | Purpose |
|---|---|
GOOGLE_CLIENT_ID |
Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Google OAuth client secret |
STRIPE_SECRET_KEY |
Stripe API key |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret |
RESEND_API_KEY |
Resend email API key |
POSTHOG_API_KEY |
PostHog analytics API key |
SENTRY_DSN_WEB |
Sentry DSN for frontend |
SENTRY_DSN_API |
Sentry DSN for backend |
| Issue | Solution |
|---|---|
| Prisma client not found | Run pnpm db:generate |
| Type errors after schema changes | Run pnpm db:generate then pnpm typecheck |
| Auth not working | Check CORS settings and NEXT_PUBLIC_API_URL |
| Google OAuth failing | Check GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET |
| Stripe webhooks failing | Check STRIPE_WEBHOOK_SECRET, use Stripe CLI for local dev |
| Emails not sending | Check RESEND_API_KEY and verified domain |
| Blob storage failing | Ensure Docker is running, check MinIO at localhost:9001 |