@stratum-hq/create
@stratum-hq/create generates a complete project directory with everything you need to start building with Stratum: package.json, Docker Compose, environment files, and framework-specific starter code.
npx @stratum-hq/create my-appThis creates a my-app/ directory with:
package.jsonwith@stratum-hq/lib,pg,jose, and your chosen frameworkdocker-compose.ymlwith PostgreSQL 16 and pre-loaded extensions (ltree, uuid-ossp).env.examplewithDATABASE_URLand other defaults- A starter server file with tenant middleware that takes the tenant from a verified JWT
README.mdwith getting-started instructions
Options
Section titled “Options”npx @stratum-hq/create my-app [options]
Options: --template <name> Framework template: express (default), fastify, or nextjs --preset <preset> Full stack preset: {database}-{strategy}-{orm}-{framework} --skip-install Skip npm install after scaffolding --force Overwrite existing directory--template and --preset cannot be used together.
Templates
Section titled “Templates”Express (default)
Section titled “Express (default)”npx @stratum-hq/create my-app --template expressGenerates an Express server in src/index.ts with tenant middleware that resolves the tenant from a verified JWT, a tenant-aware /tenants route, and TypeScript configuration.
Fastify
Section titled “Fastify”npx @stratum-hq/create my-app --template fastifyGenerates a Fastify server in src/index.ts with an onRequest hook that resolves the tenant from a verified JWT, a tenant-aware /tenants route, and TypeScript configuration.
Next.js
Section titled “Next.js”npx @stratum-hq/create my-app --template nextjsGenerates a Next.js 16 project with a proxy in src/proxy.ts that resolves the tenant from a verified JWT. Next.js 16 runs the proxy on the Node.js runtime, and needs Node.js 20.9 or later.
Presets
Section titled “Presets”A preset picks the database, isolation strategy, ORM, and framework in one string, {database}-{strategy}-{orm}-{framework}:
npx @stratum-hq/create my-app --preset postgres-rls-prisma-expressnpx @stratum-hq/create my-app --preset postgres-schema-prisma-fastifynpx @stratum-hq/create my-app --preset mongodb-database-mongoose-hononpx @stratum-hq/create my-app --preset mysql-table-prefix-pg-nestjsnpx @stratum-hq/create my-app --preset mysql-shared-knex-express| Database | Strategies | ORMs |
|---|---|---|
postgres |
rls |
prisma, drizzle, sequelize, knex, pg |
postgres |
schema, database |
prisma, pg |
mongodb |
database, collection |
mongoose |
mysql |
database, table-prefix |
pg (the mysql2 driver) |
mysql |
shared |
pg (the mysql2 driver), knex, sequelize |
Every combination in the table works with every framework: express, fastify, nextjs, hono, nestjs, or none (database setup only).
A preset generates the ORM setup with Stratum’s adapter for that database, a Docker Compose file for the database, and the framework’s server with the same verified-JWT tenant resolution as the templates. The Drizzle presets write their table definitions to src/schema.ts, which drizzle.config.ts points at.
The Prisma presets use Prisma 7, which needs Node.js 20.19, 22.12, or 24 and later:
prisma/schema.prismauses theprisma-clientgenerator.npx prisma generatewrites the client tosrc/generated/prisma, and the app imports it from there.prisma.config.tsholds the connection URL, which it reads fromDATABASE_URL. The Prisma CLI does not read.env. The generateddb:pushandtenant:provisionscripts setDATABASE_URLto the superuser inDATABASE_SUPERUSER_URLbefore they runprisma db push.- Each Prisma client connects through
PrismaPgfrom@prisma/adapter-pg. The schema and database presets giveSchemaPrismaAdapterandDatabasePrismaAdapterthe option{ driverAdapter: PrismaPg }, because Prisma 7 ignores aschemaparameter in the connection URL.
A project that an earlier release generated keeps its Prisma version. To move it to Prisma 7, follow the Prisma 7 upgrade guide.
An invalid preset, or a combination the table does not allow, exits with an error before anything is written, so no project directory is left behind.
Tenant isolation on PostgreSQL
Section titled “Tenant isolation on PostgreSQL”- rls: all tenants share the tables. Every tenant-scoped table has a
tenant_idcolumn and atenant_isolationrow-level security policy, and the generated helper setsapp.current_tenant_idfor each tenant query. A table without a policy is not filtered by tenant. The preset creates an example table,notes, with its policy: ininit.sql(pg, Knex, Sequelize), insrc/schema.ts(Drizzle), or inprisma/rls.sql, whichnpm run db:pushapplies afterprisma db push(Prisma). The Prisma models are in their own schema,app, soprisma db pushnever drops or alters Stratum’s tables inpublic. - schema and database: each tenant’s tables are in its own schema,
tenant_{slug}, or its own database,stratum_tenant_{slug}, and the generated helper sends each query there with the@stratum-hq/db-adaptersadapter for the strategy:SchemaPrismaAdapter,SchemaRawAdapter,DatabasePrismaAdapter, orDatabaseRawAdapter. These presets use no row-level security. Create each tenant with Stratum, then runnpm run tenant:provision -- <tenant-id> [slug], which creates the tenant’s schema or database and its tables and records the tenant inprovisioned_tenants, a table the app role can only read. The helper takes the tenant ID from the verified token and looks up the slug recorded for it there, and refuses a tenant that is not provisioned; it never takes the slug from the hostname or a header. The database presets give the pool managerDATABASE_URL, so its settings, such assslmode, apply to every tenant database. Drizzle, Sequelize, and Knex have no schema or database adapter, so the generator offers them only withrls. - The name of a tenant’s schema or database is fixed when the tenant is provisioned and does not follow a later change of its Stratum slug. Provisioning refuses a slug that names a provisioned schema or database, also when another tenant now has that slug in Stratum; pass another slug for the new tenant. A failed run removes what it created, so it can run again.
Tables are created by the superuser in DATABASE_SUPERUSER_URL, never by the app role in DATABASE_URL, and Stratum’s own tables by Stratum’s login in STRATUM_ADMIN_DATABASE_URL.
Tenant isolation on MySQL
Section titled “Tenant isolation on MySQL”- database: each tenant’s tables are in its own database,
stratum_tenant_{slug}, and the generated helper sends each query there withMysqlDatabaseAdapterfrom@stratum-hq/mysql. - table-prefix: each tenant has its own copy of each table,
{table}_{slug}, and the generated helper names the tenant’s tables withMysqlTableAdapter. - shared: all tenants share each table, and the
tenant_idcolumn of a row names its tenant. See below.
For the database and table-prefix strategies, run npm run tenant:provision -- <tenant-id> <slug> for each tenant. It runs as the admin user in DATABASE_SUPERUSER_URL, creates the tenant’s database or tables from sql/tenant.sql, gives the app user read and write access to them, and records the slug in _stratum_tenants. A failed run removes what it created. The app user in DATABASE_URL creates, alters and drops nothing, and only reads _stratum_tenants. The helper takes the tenant ID from the verified token and looks up the slug there; it never takes the slug from the hostname or a header. The names are fixed at provisioning, so do not change a slug or give a tenant a slug that another tenant had. @stratum-hq/mysql routes a tenant’s own database or tables only for the mysql2 driver, so these two strategies have no Knex or Sequelize preset.
The shared presets use the shared-table helper of @stratum-hq/mysql for their ORM: tenantDb(tenantId) wraps MysqlSharedAdapter (mysql2), tenantKnex(tenantId) wraps withTenantScope (Knex), and withTenantScope(tenantId, fn) wraps withMysqlTenantScope (Sequelize). The helper takes the tenant ID from the verified token and refuses an ID that is not 1 to 36 printable ASCII characters without spaces. init.sql creates an example tenant table, notes, with a tenant_id column that compares letter case exactly and an index that starts with tenant_id. The app user in DATABASE_URL keeps only SELECT, INSERT, UPDATE and DELETE, so tables are created and changed by the admin user in DATABASE_SUPERUSER_URL. There is nothing to provision. MySQL has no row-level security, so a query that does not go through the helper is not filtered by tenant. The helpers also refuse what they cannot scope: Knex joins, unions and upserts, and Sequelize upsert() and truncate(). The generated README lists each helper’s limits.
Tenant isolation on MongoDB
Section titled “Tenant isolation on MongoDB”- database: each tenant’s data is in its own database,
stratum_tenant_{slug}, andgetTenantConnection(tenantId)returns a connection to it. - collection: each tenant has its own copy of each collection,
{collection}_{slug}, andgetTenantModel(baseCollection, schema, tenantId)returns the tenant’s model.
Run npm run db:init once, then npm run tenant:provision -- <tenant-id> <slug> for each tenant. Both run as the root user in MONGODB_ADMIN_URI. db:init creates the app user in MONGODB_URI, which the app connects as: it reads and writes tenant data, only reads the routing records, and cannot drop anything or manage users. Provisioning records the slug in the tenants collection of the {database}_routing database, and refuses a tenant ID or slug that is already provisioned. The helpers take the tenant ID from the verified token and look up the slug there; they never take the slug from the hostname or a header.
After scaffolding
Section titled “After scaffolding”cd my-app
# Start the databasedocker compose up -d
# Create the env file that npm run dev readscp .env.example .env
# Run the appnpm run devThe generated starter code does not create Stratum’s tables. To create them, construct Stratum with autoMigrate: true and call initialize() once at startup. The generated README.md lists the remaining setup steps of the preset, such as npm run tenant:provision for the schema and database presets.
Tenant resolution
Section titled “Tenant resolution”Generated servers (the Express and Fastify templates, and the Express, Fastify, Hono and NestJS presets) and the Next.js proxy take the tenant ID only from the tenant_id claim of a bearer token that verifies with JWT_SECRET (HS256, using jose, which the generated package.json lists). A token that does not verify, or has no tenant_id claim, is rejected with 401. The tenant is never taken from the hostname or from a client-supplied header such as x-tenant-id. In the Next.js proxy the subdomain is forwarded as x-tenant-slug, a display hint that does not identify the caller’s tenant.
What gets generated
Section titled “What gets generated”my-app/ package.json # Dependencies + scripts docker-compose.yml # PostgreSQL 16 with extensions init.sql # CREATE EXTENSION uuid-ossp, ltree .env.example # DATABASE_URL, JWT_SECRET .gitignore # Ignores node_modules, .env and .env.*, build output and logs; keeps .env.example tsconfig.json # Compiles src/ to dist/ (express and fastify) src/ index.ts # Server with verified-JWT tenant middleware (express and fastify) proxy.ts # Verified-JWT tenant proxy (nextjs) README.md # Getting started instructions