Skip to content

Philosophy

Make the correct waythe easy way.

Ottabase is opinionated about defaults, conventions and how the pieces fit, and progressively less opinionated the deeper you go. These are the rules the packages are held to, how each one is enforced, and where the framework still falls short of them.

Opinionated about the defaults.Progressively less, the deeper you go.

Ottabase exists to make the correct way to build a SaaS application the easiest way, without ever trapping the developer who needs something unusual. Those two aims pull against each other, and every rule on this page is a way of holding both.

The framework is strongly opinionated about defaults, conventions and how the pieces fit together. It becomes less opinionated at every layer beneath that, until at the bottom you are holding Cloudflare, React and SQL directly. The happy path should feel almost suspiciously easy. The advanced path should never feel impossible.

What follows is the full set: the rules the 60+ packages are held to, how each is enforced, and the places where the code is still behind the document.

Coherence

One product, not a pile of libraries

You do not choose Ottabase for any single package. Every piece has a good standalone alternative. You choose it because authentication, RBAC, row-level security, forms, UI, uploads, email, queues, cron, data access, theming and the Cloudflare bindings underneath them already know how to work together, and because the whole thing lives in a repository you clone and own rather than a dependency you install and hope about.

That coherence has a mechanism, not just a mood. Every package ships one manifest that declares everything it contributes to an app: tables, migrations, models, policies, routes, cron, queue handlers, events, admin navigation. One registry wires all of it in. Before the kernel, adding a package meant editing four app files plus the cron and queue registries, the admin nav and the client router, and a missed edit failed silently. Now a package is one entry in one list, and a clash between two of them throws at boot with both names in the message.

  • coherent
  • predictable
  • composable
  • customizable
  • performant
  • accessible
  • discoverable
  • agent-readable

Those eight words are the job description. Exposing a capability is the easy part. Making it behave like the rest of the framework is the work.

Data

The model is the truth

There is a ceremony in modern web development. You write a model with an id, a name and a createdAt. Then a service that does the actual work. Then a controller that orchestrates the service. Then a repository that wraps the ORM. Then a DTO to validate input, and a transformer to format output. Six files to save a record.

This is sold as separation of concerns. Concerns were never the problem. Scatter is the problem. When what a Todo does is spread across a service, a controller and a repository, you do not have separation. You have archaeology.

The model knows how to save itself, what it relates to, what makes it valid, and what it can do. You read the model, you understand the domain. Nothing is elsewhere.

// the ceremony: userService.ts → userController.ts → userRepository.ts
await userService.activate(userId);

// the model: logic where the data is
const user = await User.find(userId);
await user.activate(); // status, email, audit event, in one place

In OttaORM, invoice.markPaid() and shortlink.rotateSlug() are methods on the model. Relationships are declared where the model is defined. Validation is part of the schema. The model is the unit of testing. Routes orchestrate, authenticate and validate; they do not own behaviour.

None of this is new. Active Record proved it, Django proved it. The enterprise world traded it for abstraction theatre and paid for the trade. We are not repeating that.

Runtime

Cloudflare, on purpose

Ottabase was designed for Cloudflare Workers from the first commit, not ported onto them. Every layer is a first-class Cloudflare primitive rather than a wrapper hiding something slower. The reason is not fashion. The economics are hard to argue with and the primitives are genuinely good.

  • D1The database

    SQLite, replicated. No instance to provision, no pool to size, no bill for idle.

  • KVThe hot cache

    RBAC decisions and sessions read in under a millisecond from anywhere. No Redis.

  • R2Files

    S3-compatible storage with zero egress. At scale, egress is the AWS bill.

  • Durable ObjectsRealtime

    Real WebSockets with real state at the edge. No separate realtime vendor.

  • QueuesBackground work

    A binding and a handler. No SQS, no Bull, no worker fleet.

  • Analytics EngineEvents

    High-volume custom events, SQL-queryable, without standing up ClickHouse.

  • Workers AI · AI Gateway · VectorizeInference

    Models, routing, spend control and vectors behind one package, your keys or theirs.

  • Browser RenderingDocuments

    HTML to PDF, screenshots and scraping without a headless-Chrome box to babysit.

The free tier runs a meaningful early-stage product. The paid tier stays predictable as you grow, and everything deploys under your own account, in 275+ cities, with no cold starts and no servers to patch.

Two tradeoffs, stated plainly. There are no Node.js APIs in Workers. No fs, no child_process, no native modules; every package in the tree is edge-compatible and one that is not is a bug. And the coupling is real. The framework hides Cloudflare behind its own contracts (chapter 07) so an implementation can change, but we chose this platform deliberately and we are not pretending the choice is free.

Security

Every gate fails closed

The classic SaaS mistake is to build for one tenant and add multi-tenancy later. You cannot, not cleanly. You end up with WHERE organizationId = ? in two hundred queries, a permission system that leaks across organisations, and an audit log you pray nobody subpoenas.

Ottabase enforces tenancy inside the ORM. Every model carries a row-level security policy, the security context comes from the verified session, and every query is scoped before it runs. Authorization is a permission plus a scope, cached in KV and checked on every request; a role name is a label, never a gate. The WHERE clause is invisible and inescapable.

Tenant leaks rarely come from the code you wrote. They come from the check you forgot. Here, a forgotten check is a denial, not a leak.

  1. A model has no policy that matches the callerDenied. Unresolved is never unscoped.
  2. The tenant header disagrees with the session’s membershipsDenied. The verified session wins, never a client header.
  3. A route is not on the CRUD allowlistDenied. Models opt in to the generic API; nothing is exposed by accident.
  4. A guard asks for a role nameIgnored. Authorization is permission plus scope. Names are labels.
  5. An OAuth provider hands back tokensDropped after the exchange. They are never written to the database.
  6. A handler throwsThe client gets an opaque error. Stack, policy and context go to the log, redacted.
  7. Editor HTML, URLs or CSS reach a templateSanitized through one helper each. Raw dangerouslySetInnerHTML is a review failure.

Every mutation lands in the audit log with who, what, when and for which tenant. A fresh install opens a bootstrap wizard that runs migrations, seeds roles and creates the platform owner before anything else is reachable. None of this is a feature you switch on. It is the reason the framework exists.

Abstraction

Low floor, high ceiling

Cloudflare is complicated. RBAC is complicated. Row-level security, uploads, email delivery, queues and forms are all complicated. A good framework absorbs that complexity. A great one still lets you reach the layer underneath when the application genuinely needs it. Hide complexity. Never hide capability.

So every substantial subsystem is built as a ladder. You start at the top and move down only when a requirement forces you to, and at no rung do you have to leave the framework.

  1. ConventionWorks automatically.
    <ModelCrud config={createModelConfig(Customer)} />
  2. ConfigurationChange the common behaviour.
    <ModelCrud config={cfg} searchable columns={[…]} />
  3. SlotsReplace one piece.
    <ModelCrud renderRow={…} emptyState={…} />
  4. PrimitivesCompose the building blocks yourself.
    <DataTable>…</DataTable> <Form.Field>…</Form.Field>
  5. HeadlessKeep the behaviour, bring your own UI.
    const table = useDataTable(…); const upload = useFileUpload(…)
  6. PlatformCloudflare, React, SQL, directly.
    dispatcher.getAdapter() · env.DB.prepare(…)

The top rung has to be remarkably short. <ModelCrud> is three lines of application code. <Require permission="invoice.refund">, upload(file), mail.send('welcome', data), dispatch(queue, job, payload). A framework earns its abstraction cost by deleting application code; if the Ottabase way takes as many lines as the library underneath, the abstraction is wrong and gets redesigned.

Short must not mean shallow. The same mail.send accepts a template, headers and provider options when you ask, and hands you the provider when you insist. The queue and the uploads packages run the full ladder today, from convention to raw adapter. Mail and RBAC are missing a rung each, and chapter 12 says which.

Convention

One vocabulary, one way

The APIs should feel like one person designed them. If you understand one Ottabase package, that knowledge should transfer to the next. Equivalent concepts get equivalent names everywhere, and no subsystem invents its own slightly different word for a thing that already has one.

  • size
  • variant
  • status
  • disabled
  • loading
  • required
  • readOnly
  • value
  • defaultValue
  • onChange
  • onSuccess
  • onError
  • tenant
  • permission
  • resource
  • action

Consistency is not visual sameness. Consistency is predictable mental models.

Escape hatches matter, but freedom without guidance fragments a codebase. So for every subsystem the repository names one recommended path, the supported deviations, and what is merely possible. A few rows of that table:

SubsystemRecommendedSupportedPossible
DataFat BaseModel, logic as methodsDrizzle for a measured hot pathRaw SQL
FetchingcreateModelHooks, useApiQueryqueryClient.fetchQueryraw fetch()
ErrorserrorResponse(), jsonResponse()—new Response(JSON.stringify())
EnvgetOttabaseConfig(env)—process.env
AuthzhasPermission as the boundaryProtectedRoute as a UX hintgating on a role name
Styling@ottabase/ui on design tokensui-mantine where it helpsone-off CSS on repeated UI

Struck-through cells are not advice. They are ESLint errors.

That last line is the point. Writing errorResponse() into the guidelines was not enough; hand-rolled JSON errors kept shipping for months. So the rules that matter are enforced by one root lint config that no package may shadow, and the list of pre-existing violations may shrink but never grow.

  • no-restricted-globalsblocks raw fetch() in app codeuse the one API client
  • no-restricted-syntaxblocks new Response(JSON.stringify())use errorResponse()
  • no-restricted-syntaxblocks process.env in edge codeuse getOttabaseConfig(env)
  • ottabase/no-raw-error-logblocks console.error(thrown)use redactErrorForLog()
API shape

Contracts, not implementations

A public API describes a concept, not the thing currently implementing it. A theme exposes theme.colors and theme.radius, not CSS selectors. Authorization exposes can(user, 'project.update', project), not database joins. Uploads expose upload(file), not R2 multipart internals. Queues expose a send and a handler, not binding plumbing. The implementation may evolve. The contract stays recognisable.

The same rule covers third parties. Using a proven library inside is better than rebuilding mature functionality, but application code should not be welded to whichever library the framework currently prefers. Drizzle, Radix, the editor, the chart library and the Cloudflare bindings themselves all sit behind Ottabase contracts where owning the contract buys something.

Adopting Ottabase must not be an irreversible styling decision.

Which is why behaviour is kept separate from presentation wherever the behaviour has value on its own. Packages split into a pure core, a /react entry and a /server entry, and the boundary is tested. useDataTable, useFileUpload and useOttaEditor give you the state machine without the markup. You can keep RBAC, forms, uploads, validation, queues, models, notifications, auth and tenant context, and replace every pixel of UI.

Interface

Tokens are infrastructure

Colour, type, spacing, radius, shadow, motion and z-index come from semantic tokens, never from literals scattered through components. The brand engine expands a token set into CSS variables once, into a single static style tag, so dark mode, tenant branding and a white-label redesign are a change of values rather than a hunt through the tree. One design system governs the admin, the server-rendered pages, the CLI, the docs, and this site.

Nobody should reverse-engineer internal DOM to restyle a component. Every substantial one exposes named parts through data-slot attributes that form part of its contract, and represents its state as DOM state: data-active, data-invalid, data-variant="danger". Component state becomes DOM state becomes CSS, with no JavaScript gymnastics in between.

Correctness that is hard to remember lives inside the primitive. Dialogs trap and restore focus, menus and selects handle the keyboard, Escape does what Escape does, ARIA relationships are wired, reduced motion is honoured globally. Links stay links and buttons stay buttons; native form submission and FormDatawork; a component's root element can change. Both controlled and uncontrolled modes are supported, and the performant one is the default, because a large admin form should not re-render the world on every keystroke.

And the hot path wins over theoretical elegance: static CSS and reusable classes for anything repeated, inline styles for a local nudge, and no architecture that does runtime CSS work for a thousand table rows because the API looked nice.

TypeScript

The compiler is documentation

“TypeScript end to end” is a phrase people use loosely. Here it is meant precisely. The schema is TypeScript. API types are inferred from it, not generated from it, so there is no drift to detect. Client hooks are typed to the same models; rename a field and the build breaks exactly where it should. Zod validates at the API boundary, and if a payload passes Zod, TypeScript and the database agree about it.

The deeper rule is that wrong states should be hard to write. A destructive action has an explicit destructive variant. A route knows whether it needs a tenant. An upload knows which file categories it accepts. Permissions should name known actions, queues should name registered jobs, email should name known templates, so that a typo is a compiler error and not a production incident.

Autocomplete and compiler errors are documentation.

This is the chapter where the code is furthest behind the document. Permissions, job names and template names are still plain strings today. It is listed in chapter 12 as the first thing to fix, and it is treated as a bug rather than polish.

AI-native

Built for agents too

Most code in an Ottabase repository will be written with an agent in the loop. The failure mode is not that the agent writes bad code. It is that it confidently invents a second architecture inside the first: a service layer beside the fat models, a hand-rolled fetch beside the API client, a role check beside the permission system. So the conventions are written down for machines as carefully as for people.

The repository carries an agent guide with the always-on rules. Documentation is generated in human and machine form, llms.txt and llms-full.txt, from the same source, regenerated each release so an agent knows which version it is reasoning about. And fourteen skills describe the Ottabase way of doing the multi-step things, including the conventions and the common ways to get them wrong.

  • $ottabase-create-model
  • $ottabase-crud
  • $ottabase-form
  • $ottabase-rbac
  • $ottabase-upload
  • $ottabase-email
  • $ottabase-queue
  • $ottabase-cron
  • $ottabase-theme
  • $ottabase-component
  • $ottabase-package
  • $ottabase-migration
  • $ottabase-testing
  • $ottabase-cloudflare

Conventions are also introspectable. Because every package declares its models, routes, policies, cron, queue handlers, events and navigation in one manifest, the shape of an app is data, not tribal knowledge. Documentation is treated as part of the API: each capability owes its simplest usage, its defaults, its customization points, its accessibility and performance notes, and realistic examples. If a capability cannot be explained cleanly, the API is probably unclear.

Packaging

Batteries, not a monolith

Batteries included does not mean every battery loads on every request. The core stays compact. Rich text, charts, OCR, analytics, AI, billing and the other optional capabilities are separate packages that integrate seamlessly and leave cleanly, and a core package never carries an optional dependency. Integrated does not have to mean monolithic.

The monorepo is the leverage that makes this cheap. One set of dependency versions. Atomic changes across package boundaries with no publish cycle. Incremental builds. Two app shapes that share every package: a client-rendered app with an admin, and a single server-rendered Worker with an API and no SPA. The polyrepo alternative, auth in one repository and UI in another and versions published between twelve of them, is a cost most products never earn back.

Pro follows the same rule. The eleven paid packages live in a separate private repository that buyers clone into the same monorepo, built to the same manifest contract, with nothing gated and no license keys. Repository access is the license. Public code never moves into Pro; it is what keeps the free part funded and the free part honest.

Honesty

The test, and where we fail it

Every new API in the framework is asked nine questions before it ships.

  1. Can the beginner succeed without understanding it?
  2. Can the expert customize it without fighting it?
  3. Does it behave like the rest of Ottabase?
  4. Is the correct behaviour the default?
  5. Can it be bypassed without abandoning the framework?
  6. Is important state explicit and inspectable?
  7. Can TypeScript understand it?
  8. Can an AI agent understand it?
  9. Does it remove meaningful application code?

If several answers are no, the abstraction gets another design pass. Applied to the framework itself, the runtime passes: beginners succeed, experts customize, state is inspectable, the framework can be bypassed without being abandoned. The two it still fails are the TypeScript question and parts of the agent question. Specifically:

  • open
    Permissions, job names and email templates are plain strings

    A typo compiles and ships. The document says these should be literal unions; register() should give dispatch() a typed job name. This is the clearest open violation and is treated as a bug.

  • open
    No MCP documentation server

    Agents read llms.txt and the skills, but cannot query the docs programmatically yet. Comes after the docs site exists to point at.

  • open
    No right-to-left support

    Locale and timezone are understood; directionality is not. The i18n package and the providers need it.

  • open
    Vocabulary drift in a few corners

    organizationId, orgId and tenantId all appear for one concept. A sweep, not a redesign.

  • open
    Examples are thinner than the rule demands

    Storybook is configured with a handful of stories; the richer examples live as pages inside the demo app.

None of these are regressions. They are the backlog of a framework built by someone who was shipping products on it at the same time, and this list shrinks as they close. The rest of this page is not a set of opinions. It is working code across the whole tree.

Read the rules in the code, not the prose.