Skip to content
thinkdjPublic

About

Full-stack SaaS monorepo for Cloudflare Workers. ORM, auth, RBAC, multi-tenancy, queues, realtime, blog. Edge-native, all included, for Solo Founders

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

35 stars

Watchers

2 watching

Forks

Latest commit

 

History

1,127 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ottabase Monorepo

License: MIT TypeScript Node.js pnpm CI - Build & Test

Get started in five minutes → - Clone, configure, bootstrap, and ship.

A Cloudflare-native monorepo for SaaS apps and content sites, built with pnpm workspaces, Turborepo, and Cloudflare Workers.

Why Ottabase?

  • Cloudflare-first: Workers, D1, KV, R2, Queues, and Durable Objects are built in.
  • SaaS foundations: auth, RBAC, OttaORM, forms, uploads, realtime, queues, and a blog/CMS.
  • Fat models keep domain logic close to data instead of scattering it across controllers and services.
  • One monorepo for integrated app and package changes.

Ottabase demo overview Ottabase admin media library Ottabase content management

Info

▲ You own the code.

Ottabase is a monorepo you clone and modify, not an npm package. When you fork it, you own:

  • Upstream changes - merge or rebase updates and resolve any conflicts yourself.
  • Schema and data migrations - reconcile upstream changes with your production data; back up first.
  • Infrastructure, security, and costs - deploy to and operate your own Cloudflare account.
  • Breaking changes - adapt your customizations when an upstream API or tool changes.

Recommended: Read ARCHITECTURE.md, CONTRIBUTING.md, and SECURITY.md before making structural changes.

Quick Links

Comparison

Capability Ottabase DIY Stack (Next.js + Supabase + Stripe + etc)
Setup complexity ✓ Minimal ▲ High
Integration effort ✓ None ▲ High
Time to MVP / App Launch » Hours to days ◔ Weeks to months

Positioning

Capability Ottabase Supabase Railway Convex
Primary model Edge-native full-stack framework (monorepo) Backend-as-a-service App hosting platform Realtime backend platform
Best for Solo founders shipping SaaS - super fast Backend-heavy apps / Firebase alt Simple deployments Realtime-first apps
Opinionation level ✓ Strong (batteries-included) ◑ Medium ✗ Low ◑ Medium
Build vs Configure Build product directly Configure backend + build app Build everything yourself Build within platform constraints

Developer Speed

Capability Ottabase Supabase Railway Convex
Time to MVP » Hours to days ◔ Days–weeks ◔ Weeks ◔ Days
Full-stack starter in your repo ✓ Yes ✗ No ✗ No ◑ Partial
Model-driven CRUD primitives ✓ Built in ◑ DIY ✗ No ◑ Partial
RBAC + multi-tenant SaaS primitives ✓ Included ◑ DIY ✗ No ◑ DIY
UI/component packages included ✓ Yes ✗ No ✗ No ✗ No
Blog/CMS package included ✓ Yes ✗ No ✗ No ✗ No
Type safety (TS-first) ✓ Deeply integrated ◑ Partial ◑ Depends on stack ✓ Strong
Local dev experience ✓ Unified ◑ Mixed ◑ Basic ✓ Good

Infrastructure & Scale

Capability Ottabase Supabase Railway Convex
Edge-native (global) ✓ Cloudflare Workers-first ✗ No ✗ No ✗ No
AI Gateway (global) ✓ Cloudflare Workers-first ✗ No ✗ No ✗ No
Global latency ✓ Low (edge execution) ◑ Region-based ◑ Region-based ◑ Region-based
Cold starts ✓ Minimal ◑ Possible ◑ Possible ◑ Possible
Built-in CDN / caching ✓ Native (Cloudflare) ✗ No ✗ No ✗ No
Queues / cron support ✓ Native (CF bindings) ◑ Limited / external ◑ External ◑ Limited
Cost predictability ✓ High ◑ Can scale unpredictably ◑ Infra-based ◑ Usage-based
Vendor lock-in ◑ High (Framework + infra coupling) ◑ Medium (infra coupling) ◑ Low ▲ High (proprietary model)

Structure

ottabase/
├── apps/
│   ├── otta-web/       # TanStack Router + Vite + Workers (primary)
│   ├── otta-landing/   # Next.js + OpenNext public landing site (content edited in otta-web admin)
│   ├── otta-worker/    # Single Worker: server-rendered + API, same package contract, no SPA
│   └── otta-cache/     # Self-hosted Turborepo remote cache on Workers + R2
├── packages/
│   ├── ottaorm/          # Fat models, auto-migrations, CRUD, RLS
│   ├── ottarouter/        # Cloudflare Workers router (order-free precedence, middleware)
│   ├── db/               # Drizzle D1 driver
│   ├── cf/               # Cloudflare bindings (D1, KV, R2, Queues, Vectorize, Workers AI, Browser, Workflows, DO alarms)
│   ├── cf-realtime/      # WebSocket pub/sub (Durable Objects)
│   ├── queue/            # Job queue (dispatch, handlers, priority)
│   ├── kernel/           # The package contract: one manifest, one registry, one list
│   ├── events/           # Typed in-process event bus with queue fan-out
│   ├── flags/            # Entitlements: plans, features, limits, 402 guards
│   ├── meter/            # Usage credits: atomic balances, periods, ledger
│   ├── apikeys/          # Hashed, scoped, revocable API keys for your customers
│   ├── postmaster/       # Inbound mail, suppression list, one-click unsubscribe, bounce webhooks
│   ├── vault/            # D1 backups to R2 as NDJSON, restore, per-tenant export
│   ├── fleet/            # The beacon: GET /api/fleet/report for whatever watches your apps
│   ├── auth/             # Lightweight custom auth (sessions, OAuth, magic links) with D1
│   ├── rbac/             # Role-based access control with KV caching
│   ├── audit/            # Audit logging with change tracking
│   ├── analytics/        # Cloudflare Analytics Engine (WAE)
│   ├── notifications/    # Multi-channel notifications (email, WebSocket)
│   ├── shortlinks/       # URL shortener with interstitial + WAE tracking
│   ├── ottalanding/      # DB-backed landing sites: typed sections + 3 themes
│   ├── referrals/        # Referral tracking (first-touch, WAE)
│   ├── brand-engine/     # Design tokens, preset expansion, CSS injection
│   ├── brand-engine-react/ # BrandProvider, LayoutResolver, useBrand()
│   ├── ottalayout/       # Layout types, presets, path resolver, React slots
│   ├── ottablog/         # Blog/CMS (articles, blurbs, photo journals, taxonomy, series, Studio)
│   ├── email/            # Email sending (Resend, SES, MailChannels, SMTP)
│   ├── cron/             # Cron handlers (static + DB scheduler)
│   ├── logger/           # Structured logging (multi-transport)
│   ├── config/           # App config, env vars, storage keys
│   ├── scripts/          # CLI: cf:setup, cf:validate, cf:login, clean:*
│   ├── state/            # Jotai atoms (theme, user, sidebar)
│   ├── ui/               # The UI kit: Radix primitives, DS v1 pieces, ConfirmDialog, SplitPane, tokens
│   ├── ui-mantine/       # Mantine provider + themes (optional adapter)
│   ├── ui-code-highlight/ # Code syntax highlighting
│   ├── ottaeditor/       # EditorJS wrapper with 15+ plugins
│   ├── ottaupload/       # File uploads (R2, CF Images)
│   ├── ottarenderer/     # EditorJS block renderer
│   ├── ottaselect/       # Headless select/combobox
│   ├── ui-cropper/       # Vanilla JS image cropper (~3-4 KB)
│   ├── spotlight/        # Command palette
│   ├── docs/             # Markdown doc viewer
│   ├── forms/            # Auto-generated CRUD forms from OttaORM models
│   ├── i18n/             # i18next wrapper (en, es, fr, de)
│   ├── api/              # Type-safe fetch wrapper
│   └── utils/            # Timezone, string, file, URL utilities
└── turbo.json

Note: The structure list is curated for readability; the source of truth for all publishable modules is packages/*/package.json.

Prerequisites

Quick Start

# Install
pnpm install

# Build packages (required first time)
pnpm build:pkg

# Start the default app (otta-web: Vite + Wrangler)
pnpm dev

# Initialize database
curl -X POST http://localhost:3004/api/ottaorm/init

Otta CLI

pnpm otta is the single app-scoped interface. It discovers apps from apps/*, uses the root default when the app is omitted, and accepts either an app directory name or package name.

# Start apps
pnpm otta start                         # Default app, development
pnpm otta start otta-landing            # A specific app
pnpm otta start otta-web --env stage    # Built Worker locally with env.staging
pnpm otta start otta-web --env preview  # Built Worker locally with env.preview
pnpm otta start otta-web --env prod     # Built Worker locally with env.production
pnpm otta dev otta-web --process worker # One process from the dev topology

# Scaffold a new app
pnpm otta new web my-app                # Vite + TanStack Router + Workers
pnpm otta new landing my-site           # Next.js landing page
pnpm otta new worker my-tool            # Single Worker, server-rendered + API, no SPA

# Install a package into an app (dependency, composition, list entry, client key, tables)
pnpm otta add postmaster my-app
pnpm otta add vault my-tool --dry-run

# App-scoped quality commands
pnpm otta build otta-web
pnpm otta test otta-web
pnpm otta lint otta-web
pnpm otta list

Named environment starts are always local, require the environment to exist in wrangler.jsonc, and pass --local to Wrangler; deployment remains a separate CI/manual operation. Supervised output is labeled by process, and the entire process tree is stopped when one member fails. The workspace CLI rebuilds itself only when its source is newer than its compiled output. See pnpm otta --help or the CLI README for the lifecycle contract and options.

OttaORM: Fat Models

Central to the codebase. Each model contains schema, validation, relationships, and methods.

Define Model

// ottabase/models/Todo.ts
import { BaseModel } from '@ottabase/ottaorm';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const todosTable = sqliteTable('todos', {
    id: text('id')
        .primaryKey()
        .$defaultFn(() => crypto.randomUUID()),
    title: text('title').notNull(),
    completed: integer('completed', { mode: 'boolean' }).default(false).notNull(),
    userId: text('user_id'),
    createdAt: integer('created_at').$defaultFn(() => Date.now()),
});

export class Todo extends BaseModel {
    static entity = 'todos';
    static table = todosTable;
    static primaryKey = 'id';

    static casts = {
        completed: 'boolean' as const,
        createdAt: 'date' as const,
    };

    // Relationship
    async user() {
        const { User } = await import('@ottabase/ottaorm');
        return this.belongsTo(User, 'userId');
    }

    // Custom methods
    static async incomplete() {
        return this.where({ completed: false });
    }

    async toggle() {
        this.set('completed', !this.get('completed'));
        return this.save();
    }
}

Export in Schema

// ottabase/db/schema.ts
export { usersTable, postsTable } from '@ottabase/ottaorm'; // Core
export { todosTable } from '../models/Todo'; // App

Use Model

import { registerConnection } from '@ottabase/ottaorm';
import { createD1Driver } from '@ottabase/db/drizzle-d1';
import { Todo } from './ottabase/models/Todo';

// In worker
registerConnection('default', createD1Driver(env.OBCF_D1));

// CRUD
const todo = await Todo.create({ title: 'Buy groceries' });
const all = await Todo.all();
const one = await Todo.find('id');
await todo.toggle();
await todo.delete();

Auto-Migrations

Tables created automatically from schema:

curl -X POST http://localhost:3004/api/ottaorm/init

Add columns by updating schema and re-running init.

Client Hooks (TanStack Query)

// ottabase/hooks/useTodo.ts
import { createModelHooks } from '@ottabase/ottaorm/client';
import type { TodoType } from '@/ottabase/models/Todo';

export const {
    useList: useTodos,
    useDetail: useTodo,
    useCreate: useCreateTodo,
    useUpdate: useUpdateTodo,
    useDelete: useDeleteTodo,
} = createModelHooks<TodoType>({ entityName: 'todos' });
// Usage in component
const { data: todos } = useTodos();
const createTodo = useCreateTodo();
createTodo.mutate({ title: 'New Todo' });

Packages

Database, Auth & Infrastructure

Package Purpose
@ottabase/ottaorm Fat models, CRUD, relationships, RLS, auto-migrations
@ottabase/ottarouter Cloudflare Workers router - order-free precedence, middleware, mounts
@ottabase/db Drizzle D1 driver (createD1Driver)
@ottabase/cf D1, KV, R2, Queues, Rate Limiting, Vectorize, Workers AI, Browser Rendering, Workflows, DO alarms, Cache Keys
@ottabase/cf-pdf Cloudflare HTML-to-PDF export with Browser Rendering and safe downloads
@ottabase/screenshot Automated screenshots (png/jpg/webp, quality, optimization pass): Node runner + Browser Rendering route
@ottabase/queue Job queue (dispatch, handlers, deduplication, chaining, priority)
@ottabase/auth Lightweight custom auth: signed sessions, OAuth, Credentials, Magic Link
@ottabase/rbac Role-based access control with per-org KV caching
@ottabase/audit Audit logging with change tracking and RBAC context
@ottabase/logger Structured logging (Console, HTTP, Sentry, Memory, Buffer transports)
@ottabase/analytics Cloudflare Analytics Engine (WAE) - write events, query, funnel, top-K
@ottabase/config App config, env vars, storage key utilities
@ottabase/cron Cron handlers - static code-defined and DB scheduler (Laravel-style)
@ottabase/scripts CLI tools: cf:login, cf:setup, cf:validate, clean:*

Platform

The contract every package is built on, and the primitives a SaaS needs on day one. Each is one entry in ottabase/packages.ts (or otta add <name> <app>).

Package Purpose
@ottabase/kernel The package contract: definePackage, the registry, mountPackages, health, lifecycle
@ottabase/events Typed in-process event bus; queued subscribers fan out through OBCF_QUEUE
@ottabase/flags Entitlements: plans, features, limits, operator overrides, 402 guards, client PlanGate
@ottabase/legal Terms, privacy (GDPR/CCPA), cookies, acceptable use, refunds and sub-processors from one config: clause toggles, overrides, Worker routes and React
@ottabase/meter Usage credits: atomic per-tenant balances, periods, top-ups, a pruned ledger, 402 guard
@ottabase/apikeys API keys for your customers: hashed, scoped, expiring, revocable, shown once
@ottabase/fleet The beacon: GET /api/fleet/report behind FLEET_TOKEN (packages, licenses, health, cron, backup)
@ottabase/discover AI-readable by default: robots.txt naming the AI search crawlers, sitemap, /.well-known/otta.json, JSON-LD, the canonical paragraph
@ottabase/postmaster Inbound mail (Email Routing), suppression list, one-click unsubscribe, Resend/SES bounces

Premium Packages

Sell add-ons for your app, or install someone else's. A Premium Package is an ordinary Ottabase package manifest (@ottabase/kernel) plus a commercial layer, so installing it is the same one entry in ottabase/packages.ts as any free package. Rendered pages and Drizzle's static schema export are explicit build-time adapters, kept in sync by the app's packages-registration test. Licenses are signed tokens verified offline (Web Crypto), so nothing phones home. An app with no Premium Packages is completely unaffected: nothing mounts and nothing is fetched.

Package Purpose
@ottabase/premium Manifests, license verification, entitlements, lifecycle, gated routes, admin UI
@ottabase/webhooks Worked example: HMAC-signed outbound webhooks, free tier + licensed delivery log
@ottabase/vault D1 backups to R2 with restore free (5 kept); nightly schedule, retention, tenant export licensed

Ottabase Pro is the commercial bundle built this way: Tower (every app you run, on one board), PayPort (sell plans to your app's users), Blackbox (error tracking), Beacon (a status page), Workbench (the database inside the admin), Helpdesk (a support inbox with the customer's plan and errors beside it) and Privacy (GDPR and CCPA export and deletion requests, each with its deadline), sold as source with access to a private repository as the license. Three yearly licenses, priced by how many people commit (Solo, Team, Company); cancel and the code you pulled stays yours. See ottabase.com/pro.

See docs/PREMIUM_PACKAGES.md.

Brand, Layout & Content

Package Purpose
@ottabase/brand-engine Design tokens, preset expansion, CSS injection, email branding
@ottabase/brand-engine-react BrandProvider, LayoutResolver, useBrand() React bindings
@ottabase/ottalayout Layout types, 10 presets, path resolver, React slots, LayoutMeta
@ottabase/ottablog Blog/CMS models (Post, Category, Tag, Series, Version) + Blog Studio

UI Components

Package Purpose
@ottabase/ui The UI kit: Radix primitives, Design System v1 pieces, ConfirmDialog, MessageBox, DarkModeToggle, SplitPane, JsonEditor, UIProviders, tokens and base styles
@ottabase/ui-mantine Mantine provider, pre-built themes (optional adapter)
@ottabase/ui-code-highlight Code syntax highlighting (highlight.js)
@ottabase/ottaeditor EditorJS wrapper with 15+ plugins (Spoiler, CTA, Review)
@ottabase/ottaupload File upload component (R2, Cloudflare Images)
@ottabase/ottarenderer EditorJS block renderer for React
@ottabase/ottaselect Headless select/combobox component
@ottabase/ui-cropper Vanilla JS image cropper (~3-4 KB, zero deps)
@ottabase/spotlight Spotlight/command palette component
@ottabase/docs Markdown doc viewer with layout themes
@ottabase/forms Auto-generated CRUD forms from OttaORM models

Features & Realtime

Package Purpose
@ottabase/cf-realtime WebSocket pub/sub via Durable Objects (Pusher alternative)
@ottabase/shortlinks URL shortener: short codes, interstitial, expiry, WAE clicks
@ottabase/ottalanding Landing sites in D1: typed sections, admin editor, 3 themes
@ottabase/referrals Referral tracking - first-touch attribution, WAE clicks
@ottabase/notifications Multi-channel notifications (email, WebSocket, system)

Utilities & Integrations

Package Purpose
@ottabase/state Jotai atoms (theme, user, sidebar, org)
@ottabase/utils Timezone, string, file, URL, git utilities
@ottabase/api Type-safe fetch wrapper with deduping and error types
@ottabase/email Email sending (Resend, SES, MailChannels, Nodemailer)
@ottabase/i18n i18next wrapper (en, es, fr, de)

Multi-App Database Sharing

Multiple apps can share a single database using the optional appId column.

How It Works

Mode scopeByAppId appId column Behavior
Default false null Single app, no filtering
Multi-app true "my-app" Auto-inject/filter by appId

Configuration

import { createAppConfig } from '@ottabase/config';

const config = createAppConfig({
    appId: 'my-unique-app-id',
    defaults: {
        features: { scopeByAppId: true }, // Enable appId scoping
    },
});

Environment Variables

Variable Default Description
APP_ID "otta-web" Unique app identifier
SCOPE_BY_APP_ID "false" Enable appId scoping for DB

Schema Support

All models include a nullable appId column:

  • @ottabase/ottaorm core models (User, Session, Account, Post, Tag, etc.)
  • @ottabase/shortlinks fat model
  • @ottabase/referrals fat model

Creating New Apps

Full-stack SPA (TanStack Router, OttaORM, Auth, RBAC, all CF bindings):

# Unix/macOS: cp -r apps/otta-web apps/my-new-app
# Windows:    xcopy /E /I apps\otta-web apps\my-new-app
cd apps/my-new-app
# Update package.json name
# Delete src/pages/demo/  (optional - remove demo pages)

Marketing site (Next.js, OpenNext): apps/otta-landing renders the landing content you edit at /admin/content/landing in otta-web, from the shared D1 database. Pick a theme and edit pages there, with no code changes. See apps/otta-landing/README.md.

Single Worker (otta new worker my-tool): apps/otta-worker is the same package contract without the SPA. One Worker serves a server-rendered page and an API, runs the scheduler and the queue over the manifests, creates its tables from POST /api/admin/init, and uses one operator token until you add @ottabase/auth. For internal tools, webhook receivers, cron-driven jobs and first versions. See apps/otta-worker/README.md.

Package Fat Model Pattern

When a package owns its tables (like @ottabase/shortlinks), the model and schema live together in the package.

1. Package exports fat model

// packages/shortlinks/src/Shortlink.ts
export const shortlinksTable = sqliteTable("shortlinks", { ... });

export class Shortlink extends BaseModel {
  static entity = "shortlinks";
  static table = shortlinksTable;
}

2. App registers model

import { Shortlink } from '@ottabase/shortlinks';
registerModels([Shortlink]);

3. Export table in schema

// ottabase/db/schema.ts
export { shortlinksTable } from '@ottabase/shortlinks';

4. Create hooks

// ottabase/hooks/useShortlink.ts
import { createModelHooks } from "@ottabase/ottaorm/client";
export const { useList, useCreate, ... } = createModelHooks({ entityName: "shortlinks" });

Commands

pnpm commands         # Every script in one annotated table — start here
pnpm commands clean   # Filter to a topic

The table is generated from package.json, so it always matches the scripts that actually exist. The essentials:

pnpm dev              # Start the default app (alias for pnpm otta start)
pnpm otta --help       # Parameterized app/environment lifecycle
pnpm build            # Build everything
pnpm build:pkg        # Build packages only
pnpm test             # Run tests
pnpm lint             # Lint
pnpm type-check       # TypeScript check
pnpm storybook        # Component docs

Cloudflare Deployment

cd apps/otta-web
pnpm wrangler login
pnpm deploy

# Run migrations
curl -X POST https://your-app.workers.dev/api/ottaorm/init \
  -H "Authorization: Bearer ${MIGRATION_SECRET}"

OSS vs Pro

One rule decides where a package lives: OSS helps you build, Pro helps you operate.

  • OSS (MIT, this repo): everything needed to build and ship an app. The framework, ORM, auth, RBAC, UI, content and utilities. Nothing published here ever moves behind a paywall.
  • Pro (paid, sold as source): the operations layer for a running business. Billing, fleet health, error tracking, status pages, support, compliance. See ottabase.com/pro.

Litmus test: needed on day one of building, OSS. Needed once paying customers arrive, Pro.

Docs


Built on Cloudflare

~ by @thinkdj. Licensed under MIT.

About

Full-stack SaaS monorepo for Cloudflare Workers. ORM, auth, RBAC, multi-tenancy, queues, realtime, blog. Edge-native, all included, for Solo Founders

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

35 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages