Headless CMS — REST + GraphQL API, React admin UI, workflows, RBAC, and extensions.
10K+
Headless CMS — Fastify REST + GraphQL API, React admin UI, TypeScript SDK, and a single-image Docker release.
Website · SDK on npm · Docker Hub
api/extensions/, no restart needed| Layer | Choice |
|---|---|
| API | Fastify v5 (TypeScript) |
| Database | Knex + MSSQL (tedious) |
| Auth | openid-client PKCE — Microsoft OIDC |
| Sessions | Redis (ioredis) + @fastify/session |
| Real-time | Socket.io + @socket.io/redis-adapter |
| Jobs | Inngest self-hosted (Postgres + Redis) |
| Admin UI | React 19 + Vite 6 + shadcn/ui (Tailwind v3) |
| SDK | @nivaro/sdk — ESM, fully typed |
| Linter | Biome v2 |
| Packages | pnpm v11 workspaces |
cp .env.example .env
# fill in DB_*, OIDC_*, SESSION_SECRET
pnpm install
pnpm migrate
pnpm dev
# API → http://localhost:3055
# Admin → http://localhost:3056
pnpm dev starts Redis (Docker), Inngest dev server, API, and admin concurrently.
pnpm dev:docker # build + start all containers
pnpm dev:docker:down # stop and remove
| Command | Description |
|---|---|
pnpm dev | Redis + Inngest + API (:3055) + admin (:3056) |
pnpm dev:api | API only |
pnpm dev:admin | Admin UI only |
pnpm dev:redis | Redis in Docker on :6379 |
pnpm dev:inngest | Inngest dev server on :8288 |
pnpm dev:docker | Full stack via Docker Compose |
pnpm dev:www | Static www site via browser-sync on :3057 |
pnpm build | Compile API (tsc) + build admin (vite) |
pnpm migrate | Run pending DB migrations |
pnpm migrate:rollback | Roll back last migration batch |
pnpm check | Biome lint + format check |
pnpm check:fix | Auto-fix formatting and lint |
All three release flows use git tags to trigger GitHub Actions.
@app-* → Docker Hub)pnpm release patch # bumps package.json, tags @app-x.x.x, pushes
pnpm release minor
pnpm release 2.3.0 # exact version
GitHub Actions builds Dockerfile.release and pushes to Docker Hub:
nodeworks/nivaro:2.3.0
nodeworks/nivaro:latest
@sdk-* → npm)pnpm sdk:release patch
pnpm sdk:release minor
pnpm sdk:release 1.7.0
Tags @sdk-x.x.x → GitHub Actions publishes @nivaro/sdk to npm.
@www-* → Vercel)pnpm www:release patch
Tags @www-x.x.x → GitHub Actions deploys www/ to Vercel.
docker pull nodeworks/nivaro:latest
Minimal docker-compose.yml for a consumer project:
services:
nivaro:
image: nodeworks/nivaro:latest
ports:
- "3055:3055"
env_file: .env
environment:
REDIS_URL: redis://redis:6379
INNGEST_BASE_URL: http://inngest:8288
INNGEST_EVENT_KEY: ${INNGEST_SIGNING_KEY_DOCKER:-deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe}
INNGEST_SIGNING_KEY: ${INNGEST_SIGNING_KEY_DOCKER:-deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe}
volumes:
- ./extensions:/app/api/extensions
- uploads:/app/uploads
depends_on:
redis:
condition: service_healthy
inngest:
condition: service_healthy
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: inngest
POSTGRES_PASSWORD: inngest
POSTGRES_DB: inngest
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inngest -d inngest"]
interval: 5s
timeout: 3s
retries: 10
inngest:
image: inngest/inngest:latest
command: inngest start --host 0.0.0.0
ports:
- "8288:8288"
environment:
INNGEST_EVENT_KEY: ${INNGEST_SIGNING_KEY_DOCKER:-deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe}
INNGEST_SIGNING_KEY: ${INNGEST_SIGNING_KEY_DOCKER:-deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe}
INNGEST_POSTGRES_URI: postgres://inngest:inngest@postgres:5432/inngest
INNGEST_REDIS_URI: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
uploads:
redis_data:
postgres_data:
Set INNGEST_SIGNING_KEY_DOCKER to a real 64-character hex string in production.
See examples/my-project/ for a full working example with a custom extension.
npm install @nivaro/sdk
import { createNivaro, readItems, createItem, _eq, desc } from '@nivaro/sdk'
const nivaro = createNivaro('https://your-nivaro-host', { token: 'your-static-token' })
// List items with filter + sort
const { data, total } = await nivaro.request(
readItems('projects', {
filter: { status: _eq('active') },
sort: [desc('created_at')],
limit: 25,
})
)
// GraphQL
const result = await nivaro.graphql(`
query { projects(filter: { status: { _eq: "active" } }) { data { id name } total } }
`)
// Realtime
import { createRealtime } from '@nivaro/sdk'
const rt = createRealtime()
rt.connect('https://your-nivaro-host', 'your-token')
const unsub = rt.subscribe('projects', { event: 'update' }, (data) => console.log(data))
Full SDK docs: npmjs.com/package/@nivaro/sdk
Drop a folder into api/extensions/<name>/ with a compiled index.js. The loader auto-discovers it on startup — no restart needed for new extensions in dev.
api/extensions/
└── my-extension/
├── src/index.ts
├── index.js ← compiled output (loaded by Nivaro)
├── package.json
└── tsconfig.json
// src/index.ts
import type { FastifyInstance } from 'fastify'
import type { Knex } from 'knex'
interface ExtensionContext {
app: FastifyInstance
database: Knex
logger: FastifyInstance['log']
hooks: {
before(collection: string | '*', action: string | '*', fn: (...args: unknown[]) => unknown): void
after(collection: string | '*', action: string | '*', fn: (...args: unknown[]) => unknown): void
}
cron: {
schedule(id: string, expression: string, fn: () => void | Promise<void>): void
}
callExternalApi(nameOrId: string | number, options?: Record<string, unknown>): Promise<unknown>
}
export default {
id: 'my-extension',
async register({ app, database, logger, hooks, cron, callExternalApi }: ExtensionContext) {
// Custom route
app.register(async (f) => {
f.get('/my-route', async () => ({ ok: true }))
}, { prefix: '/api' })
// Hook into mutations
hooks.after('projects', 'create', async ({ item }) => {
logger.info({ item }, 'project created')
})
// Scheduled job
cron.schedule('daily-cleanup', '0 2 * * *', async () => {
await database('my_temp_table').where('created_at', '<', new Date()).delete()
})
},
}
See api/extensions/example-inngest/ and api/extensions/example-socketio/ for more examples.
Key variables — see .env.example for the full list.
| Variable | Description |
|---|---|
DB_HOST / DB_DATABASE | MSSQL connection |
DB_ENCRYPT=true | Required for Azure SQL / most cloud MSSQL |
NODE_TLS_REJECT_UNAUTHORIZED=0 | Required for self-signed or corporate-CA certs with the tedious MSSQL driver (it doesn't use the system CA store). Omit in production if your SQL Server has a publicly-trusted cert. |
REDIS_URL | e.g. redis://localhost:6379 |
OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET | Microsoft OIDC |
OIDC_REDIRECT_URI | e.g. http://localhost:3055/api/auth/callback |
SESSION_SECRET | 32+ character random string |
COOKIE_SECURE=false | Must be false for plain HTTP (Docker default) |
INNGEST_EVENT_KEY / INNGEST_SIGNING_KEY | local for dev; real hex for production |
INNGEST_SIGNING_KEY_DOCKER | Production override — prevents local leaking into Docker |
PUBLIC_URL | e.g. http://localhost:3055 |
ANTHROPIC_API_KEY | Optional — enables AI features |
MIT — see LICENSE.
Content type
Image
Digest
sha256:7a9c1dd8d…
Size
540 MB
Last updated
12 minutes ago
docker pull nodeworks/nivaro