A production-ready Node.js/Express API template with TypeScript, authentication, and multi-tenant workspace system.
This template provides a robust foundation for building scalable APIs with:
- Authentication - JWT-based auth with Supabase integration
- Multi-tenancy - Workspace-based system with role-based permissions
- Database - PostgreSQL with Drizzle ORM and migrations
- Security - Helmet, CORS, input validation, and error handling
- Developer Experience - ESM support, hot reload, testing, and Docker
- 🔐 Authentication & Authorization - JWT bearer tokens with Supabase
- 🏢 Workspace Management - Multi-tenant workspace system
- 👥 User Management - Account creation, profiles, and memberships
- 🛡️ Role-based Permissions - SuperAdmin, Admin, User, and Owner roles
- 📊 Database Management - Migrations, seeding, and Drizzle ORM
- ⚡ Real-time Development - Hot reload with tsx and pkgroll
- 📚 API Documentation - Interactive Swagger UI with OpenAPI 3.0 specification
POST /login
- User authenticationPOST /signup
- User registration
GET /me
- Current user profile and workspaces
GET /accounts
- List all accountsPOST /accounts
- Create new accountGET /accounts/:id
- Get account detailsPATCH /accounts/:id
- Update account
GET /workspaces
- List user workspacesPOST /workspaces
- Create new workspaceGET /workspaces/:id
- Get workspace details with membersPATCH /workspaces/:id
- Update workspace (Admin only)PATCH /workspaces/:id/profile
- Update your profile in workspaceDELETE /workspaces/:id
- Delete workspace (Admin only)
GET /workspaces/:id/members
- List workspace membersPOST /workspaces/:id/members
- Add member (Admin only)PUT /workspaces/:id/members/:memberId/role
- Update member role (Admin only)DELETE /workspaces/:id/members/:memberId
- Remove member (Admin only)
GET /admin/accounts
- List all accounts with paginationPUT /admin/accounts/:id/role
- Update SuperAdmin statusPUT /admin/accounts/:id/status
- Update account statusGET /admin/workspaces
- List all workspaces with paginationGET /admin/memberships
- List all memberships with filteringGET /admin/audit-logs
- List audit logs with filteringGET /admin/audit-logs/stats
- Get audit log statistics
- Runtime: Node.js 22+ with ESM modules
- Framework: Express.js with TypeScript
- Database: PostgreSQL with Drizzle ORM
- Authentication: Supabase with JWT
- Validation: Zod schemas
- Testing: Vitest with coverage
- Tooling: ESLint, Prettier, tsx, pkgroll
- Monitoring: Sentry integration
- Security: Helmet, CORS, input sanitization
- Node.js 22+ - This project uses Volta for Node version management
- PostgreSQL - Either local, cloud, or Supabase
- Supabase Account - For authentication (free tier available)
-
Install Node.js with Volta:
curl https://get.volta.sh | bash
-
Install pnpm:
npm install --global corepack@latest corepack enable pnpm
-
Clone and setup:
git clone <repository-url> cd supabase-express-api cp .env.example .env pnpm install
-
Configure environment (edit
.env
):# Database - Option 1: Use DATABASE_URL (recommended for production) DATABASE_URL=postgresql://postgres:your-password@localhost:5432/your-database # Database - Option 2: Use individual parameters POSTGRES_HOST=localhost POSTGRES_USER=postgres POSTGRES_PASSWORD=your-password POSTGRES_DB=your-database # Supabase SUPABASE_URL=https://your-project.supabase.co SUPABASE_PK=your-public-key
-
Initialize database:
pnpm run migrate pnpm run seed
-
Start development server:
pnpm dev
-
View API Documentation:
Once the server is running, visit:
- Swagger UI: http://localhost:4000/docs
- OpenAPI JSON: http://localhost:4000/openapi.json
The interactive documentation allows you to explore and test all API endpoints.
This project uses ESM (ECMAScript Modules) for modern JavaScript imports:
- Development: tsx for hot reloading
- Production: pkgroll for bundling
- Import aliases:
@/
maps tosrc/
directory
Multi-tenant workspace system with:
- accounts - User accounts with super admin support
- workspaces - Tenant containers owned by accounts
- profiles - User presence within workspaces
- workspace_memberships - Role-based access control
pnpm dev
- Start development server with hot reloadpnpm build
- Build for productionpnpm test
- Run tests with coveragepnpm lint
- Lint code with ESLintpnpm format
- Format code with Prettierpnpm tsc:check
- Type check without compilation
Uses Vitest for unit testing. Install the Vitest VS Code extension for the best experience.
This project includes VS Code debugging configurations for TypeScript development.
- Set breakpoints in your TypeScript files
- Open Debug panel (Cmd+Shift+D)
- Select "Debug API with tsx" from the dropdown
- Press F5 to start debugging
Uses tsx to run TypeScript directly without building:
# VS Code will run this automatically when you press F5
pnpm tsx src/server.ts
For debugging an already running server:
# Terminal 1: Start server with inspect flag
pnpm tsx --inspect src/server.ts
# Terminal 2: Or use the provided task
# Cmd+Shift+P -> "Tasks: Run Task" -> "pnpm: dev with debugging"
Then in VS Code:
- Select "Attach to Running Server" from debug dropdown
- Press F5 to attach
- Debugger connects to port 9229
For debugging without VS Code:
# Start with Node.js inspector
node --inspect-brk ./node_modules/.bin/tsx src/server.ts
# Chrome DevTools debugging
# 1. Open chrome://inspect
# 2. Click "Open dedicated DevTools for Node"
# 3. Server will pause at first line
- Breakpoints: Click left of line numbers in VS Code
- Conditional Breakpoints: Right-click breakpoint -> "Edit Breakpoint"
- Logpoints: Right-click line -> "Add Logpoint" (logs without stopping)
- Debug Console: Evaluate expressions while paused
- Call Stack: See function call hierarchy
- Variables: Inspect local and closure variables
-
Profile Name Issue: Set breakpoints at:
workspaces.handlers.ts:17
- Check if profileName is extractedworkspaces.handlers.ts:39
- See profile creation
-
Request Debugging:
// Add logpoint or breakpoint here console.log("Request body:", req.body); console.log("Headers:", req.headers);
-
Database Queries:
// Enable query logging const result = await db.select().from(accounts); console.log("SQL:", result.toSQL()); // If using query builder
-
Hot Reload: Keep debugger attached while making changes - tsx will restart automatically
Located in .vscode/launch.json
:
- Debug API with tsx: Direct TypeScript debugging
- Attach to Running Server: Attach to existing process on port 9229
Tasks in .vscode/tasks.json
:
- pnpm: dev with debugging: Start server with inspect flag
- pnpm: build/test/lint: Other development tasks
- View Database:
pnpm run studio
- Opens Drizzle Kit studio - Migrations:
pnpm run migrate
- Apply pending migrations - Seeding:
pnpm run seed
- Populate with sample data
# Install Supabase CLI
pnpm add -D supabase
# Start local Supabase
supabase start
# Use connection string in .env
# postgresql://postgres:postgres@localhost:54322/postgres
Access dashboard at http://localhost:54323
# Simple setup
docker compose up -d
# Or with custom network
docker network create mynetwork
docker run --network mynetwork --name postgres \
-e POSTGRES_PASSWORD=example \
-p 5432:5432 -d postgres:15
When running migrations for the first time on a new database:
pnpm run migrate
When you modify the schema/models in src/schema.ts
:
1. Generate a new migration:
pnpm run migrate:create
2. Apply the migration:
pnpm run migrate
For rapid development, you can push schema changes directly (skips migration files):
pnpm run migrate:push
migrate:push
is for development only - it can cause data loss in production.
The seed script creates a comprehensive multi-tenant test environment with realistic business scenarios.
# Create local accounts only (recommended for development)
pnpm run seed
# Create both local accounts AND Supabase auth users
pnpm run seed --supabase=true
8 Test Accounts:
admin@example.com
- Super Admin (can access admin endpoints)alice@acmecorp.com
- ACME Corp owner with 2 workspacesbob@techstartup.com
- TechStartup owner with 2 workspacescarol@designstudio.com
- Design Studio ownerdavid@acmecorp.com
- ACME employee (user role)emma@techstartup.com
- TechStartup employee (admin/user roles)frank@suspended.com
- Suspended account (testing)grace@inactive.com
- Inactive account (testing)
5 Realistic Workspaces:
- "ACME Corp - Main" - Primary business workspace
- "ACME Corp - R&D" - Research & development
- "TechStartup - Development" - Software development
- "TechStartup - Marketing" - Marketing campaigns
- "Design Studio Pro" - Creative workspace
Multi-tenant Scenarios:
- Cross-workspace memberships (Alice, Bob, Emma in multiple workspaces)
- Different roles within organizations (admin/user)
- Cross-company collaboration (Emma consulting for ACME)
- Account status variations (active/suspended/inactive)
For database/API testing: Use default pnpm run seed
(local accounts only)
For authentication testing: Use pnpm run seed --supabase=true
and either:
- Disable email confirmation in Supabase Auth settings, OR
- Manually confirm users in Supabase dashboard after seeding
- Replace test emails with working emails in
src/services/db/seeds/accounts.ts
After seeding the database, you can create development workspaces for testing:
# Create a single workspace
pnpm dev:workspace --email=alice@acmecorp.com --name="Test Workspace"
# Create a workspace with specific profile name and role
pnpm dev:workspace --email=david@acmecorp.com --name="Client Project" --profile="David Chen" --role=user
# Create multiple test workspaces
pnpm dev:workspaces --email=bob@techstartup.com
Note: The account email must exist in the database (created during seeding) before creating workspaces.
Test and generate JWT tokens for API development and debugging:
# Generate a test token for development (use actual account ID from seeded data)
pnpm token-test --generate --account-id=<account-uuid> --email=alice@acmecorp.com
# Verify a token with full payload information
pnpm token-test --token=<jwt-token> --show-payload --check-expiry
# Test if your JWT secret works with a token
pnpm token-test --token=<jwt-token> --test-secret
# Decode token without verification (debugging)
pnpm token-test --token=<jwt-token> --decode-only
Note: Use actual account IDs from your seeded database when generating tokens.
Be sure to update the seeds as new migrations are added.
# build the app
pnpm build
# build with docker
docker build . --tag node-express
# or to build with a specific platform
docker build . --tag node-express --platform linux/amd64
# or build a specific stage eg dev
docker build . --target dev --tag node-express
# start the docker container
docker run -d -p 4000:4000 node-express
# view it running on localhost
curl localhost:4000
Aliases can be configured in the import map, defined in package.json#imports.
see: https://github.com/privatenumber/pkgroll#aliases
This project uses JWT bearer token for authentication. The claims, id and sub must be set on the token and the token can be verified and decoded using the configured auth provider.
How permissions work.
A resource will have a permission level for each route method based on users role within the workspace. Workspace permissions can be defined in ./src/helpers/permissions.ts
.
Workspace level permissions: Admin: Highest level of access to all resources within the workspace. User: Regular user with limited permissions.
Resource level permissions: Owner: Has access to their own resources
Account level permissions: SuperAdmin: Has access to all super only resources.
This API uses a consistent header-based authorization pattern for all workspace-scoped operations.
All requests that operate within a workspace context must include the x-workspace-id
header, even if the workspace ID is already present in the URL path.
Why use headers instead of just URL parameters?
- Consistency: Single authorization pattern across all endpoints
- Flexibility: Supports future endpoints that don't naturally include workspace ID in the URL
- Security: Explicit workspace context prevents accidental cross-workspace access
- Scalability: Easy to add additional context headers in the future (e.g.,
x-project-id
)
Example:
# Even though the workspace ID is in the URL, the header is still required
curl -X GET http://localhost:4000/workspaces/123e4567-e89b-12d3-a456-426614174000/members \
-H "Authorization: Bearer your-jwt-token" \
-H "x-workspace-id: 123e4567-e89b-12d3-a456-426614174000"
The authorization middleware will:
- Verify the user is authenticated (via JWT)
- Check the user is a member of the specified workspace (via header)
- Validate the user has the required role (User/Admin) for the operation
A role/claim is defined when the account is added to the workspace as a member.
- User - Can access all resources with user permissions.
- Admin - Can access all resources within the workspace.
Profile endpoints (/profiles
and /profiles/:id
) have been removed to enforce proper workspace-scoped security. Profile data is now accessible only through workspace context:
GET /me
- Returns the current user's account and all their profiles across workspacesGET /workspaces/:id
- Returns workspace details including all members with their profilesGET /workspaces/:id/members
- Returns all workspace members with profile information
This ensures profile data is always accessed with proper workspace authorization.
see the documentation for more information on how to use Supabase Auth with this project.
The project includes automated database migrations that run on:
- Development: When merging to
main
branch - Production: When creating a GitHub release
-
Go to your repository's Settings > Secrets and variables > Actions
-
Add the following secrets:
DEV_DATABASE_URL
: Development database connection stringPROD_DATABASE_URL
: Production database connection string
Format:
postgresql://user:password@host:5432/database?sslmode=require
- On merge to main: The
migrate-dev
job automatically runs pending migrations against your development database - On release: The
migrate-prod
job runs migrations against production - Migrations must succeed before any deployment steps run
For better schema management, use Supabase branching:
- Each branch gets its own DATABASE_URL
- Test migrations safely on preview branches
- Production database remains isolated
A docker image can be built and deployed to a container registry. We can configure DigitalOcean to deploy the image once the registry updates using their App Platform
The following secrets will need to be added to Github Actions for a successful deployment to DigitalOcean.
DIGITALOCEAN_ACCESS_TOKEN
https://docs.digitalocean.com/reference/api/create-personal-access-token/REGISTRY_NAME
eg registry.digitalocean.com/my-container-registryIMAGE_NAME
the name of the image we are pushing to the repository egexpress-api
it will be tagged with the latest version and a github sha.
For information on confguring the app level environment variables see How to use environment variables in DigitalOcean App Platform
NODE_ENV
:production
APP_URL
:https://api.example.com
DATABASE_URL
:postgresql://postgres.<supabase-id>:password@<region>.pooler.supabase.com:5432/postgres
SUPABASE_URL
:https://<supabase-id>.supabase.co
SUPABASE_PK
:abcdefghijklm
Alternatively, you can use individual database parameters:
POSTGRES_HOST
:<region>.pooler.supabase.com
POSTGRES_USER
:postgres.<supabase-id>
POSTGRES_PASSWORD
:example
POSTGRES_DB
:postgres