Skip to main content
← Back to Blog
Drizzle ORM and Postgres architecture diagram

Why We Use Drizzle on Top of Postgres Instead of Just Calling Supabase

When people see Drizzle + Postgres in the stack behind Focus Copilot, the question comes up fast. Supabase already gives you a Postgres database, an auto-generated REST API, row-level security, and a JS client, so why add an ORM and a migration tool on top of it?

Different Layers, Not Competing Tools

Supabase and Drizzle aren't really competing for the same job. Supabase is a hosting and access-layer product: Postgres plus an auto-generated API, auth, and storage, bundled together. Drizzle is a schema and query tool that happens to talk to Postgres, including one hosted on Supabase.

You can use both together, with Drizzle talking directly to a Supabase Postgres instance over its connection string. That combination is close to the sweet spot for a lot of teams. But the question we actually get is narrower: raw Supabase client calls versus Drizzle queries. Here's what changed for us when we picked the latter.

The Schema Is the Source of Truth, Not a Side Effect of API Calls

In src/lib/db/schema.js, every table (users, tasks, intentions, workSessions, sessionEvents, agentCalls) is defined once, in code, with real types:

export const tasks = pgTable('tasks', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), estimateMinutes: integer('estimate_minutes').notNull(), blockers: jsonb('blockers').default([]), state: text('state').notNull().default('pending'), // ... });

That file gets reviewed in pull requests like any other code. When we change it, drizzle-kit diffs it against the database and generates a migration in drizzle/. We read that SQL before it runs, including a project rule that flags destructive operations before they ship.

With Supabase's dashboard-first workflow, the schema lives in the hosted Postgres instance and the “diff” is whatever you remember to write down. Supabase's own CLI supports migrations too, so you can check schema files into git there as well, but the default experience nudges you toward clicking “Add column” in the studio UI. For a project where the schema is a contract between a planner agent, a session agent, and the UI, we wanted that contract versioned and reviewable, not implicit in a hosted dashboard.

Cascades and Constraints Are Explicit, Not Assumed

Every child table cascades on user deletion:

userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' })

sessionEvents cascades off workSessions the same way — plain Postgres underneath, but writing it in Drizzle means the constraint sits right next to the column it protects, in the same file you're editing when you add a feature. You can't add agentCalls.sessionId without seeing, ten lines up, exactly how workSessions gets cleaned up. Supabase's table editor lets you set this too, but it's a UI action disconnected from the code that depends on it.

Queries Are Typed End to End Without Writing TypeScript

Focus Copilot is JavaScript-only, a deliberate constraint on the project, but every export still carries JSDoc and @ts-check. Drizzle infers row types from the schema automatically, so queries.js gets real autocomplete and a type error on a typo like task.estimatedMinutes (it's actually estimateMinutes) without anyone hand-writing a single interface.

Supabase supports this too, through supabase gen types, and plenty of teams wire it into CI so types stay current automatically. The difference is where the types come from: Drizzle derives them directly from the schema file you're already editing, while Supabase generates them from a separate introspection step that has to be re-run after every schema change. Skip that step, or edit a column in the studio without regenerating, and .from('tasks').select('*') goes back to returning any until someone notices.

One Place for Database Access, in Plain JavaScript

The project rule is that all database access goes through src/lib/db/queries.js. That rule is enforceable because Drizzle queries are just JavaScript functions composing real SQL: joins, aggregates, jsonb filters, with no intermediate query language to learn. Deriving per-session cost from agentCalls, for example, is a join against workSessions, written the same way you'd write any other function:

db.select(...).from(agentCalls).innerJoin(workSessions, eq(agentCalls.sessionId, workSessions.id))

To be fair to Supabase, this isn't a case of PostgREST being unable to join. Its embedded-resource syntax handles foreign-table joins and nested selects fine (.from('tasks').select('*, work_sessions(*)') is a real join), and recent versions added more aggregate support on top of that. The gap is elsewhere: arbitrary SQL is easier to compose in Drizzle than in PostgREST's filter syntax, complex queries stay in application code instead of a Postgres function or RPC, and Drizzle's query builder gives you type inference that PostgREST's string-based filters don't.

A Direct Connection Is an Option, Not a Free Win

Drizzle talks straight to DATABASE_URL from a Next.js server. That works because the server is a trusted environment that can safely hold a database credential. Supabase's API layer exists for the opposite case: browsers, mobile apps, and edge clients that can't hold a direct Postgres connection at all, where PostgREST plus row-level security is the only sane option.

Focus Copilot doesn't have that constraint — the database is only ever touched from the server — so a direct connection is available to us, and we'd rather use it: no separate Supabase project to provision, no API gateway in front of Postgres, no separate dashboard with its own auth and its own outage surface. The project already runs its own Auth.js sessions and its own Postgres instance, on Neon, RDS, or plain Postgres; adding Supabase here would mean a second auth system and a second API layer we don't need, just to get a Postgres box we already have.

When Supabase Is the Better Tool for the Job

Drizzle and Supabase sit at different layers of the stack. If Focus Copilot needed any of the following, Supabase would be the better call:

  • Realtime subscriptions. Live task updates pushed to multiple clients. Supabase Realtime is genuinely hard to replicate by hand.
  • Storage for file uploads. Supabase Storage is a bundled feature you'd otherwise have to wire up yourself.
  • A managed Postgres instance with an auto-API, fast. A team that wants to skip writing a query layer entirely gets there faster with Supabase's client.
  • Bundled auth out of the box. A project that doesn't already have its own auth system can lean on Supabase Auth instead of standing one up.

And you can use both. Plenty of teams run Drizzle against a Supabase-hosted Postgres connection string, getting Supabase's managed hosting, backups, auth, and storage while keeping Drizzle's schema-as-code and typed queries on top. That combination makes sense for most projects starting fresh. Focus Copilot already owns its auth through Auth.js and its own Postgres connection, though, so the only Supabase feature we'd actually use is the managed Postgres database itself — and Drizzle already covers what we need for schema management and queries on top of any Postgres provider, in a way that fits a code-review-driven, JavaScript-only, schema-versioned workflow.

That last point is also why Drizzle has been an easy default for new projects generally: the schema and query layer don't change when the hosting provider does. Moving a Drizzle-backed app from Neon to RDS, Railway, or a self-hosted Postgres instance is mostly a connection-string swap. Moving an app built on Supabase Auth, Realtime, and RPC functions is a real migration, because those features don't exist outside Supabase. For a project that wants to keep its options open on infrastructure, that portability is worth weighing on its own.

Frequently Asked Questions

Is Drizzle a replacement for Supabase?

No, they solve different problems. Supabase is a hosting and access-layer product: Postgres plus an auto-generated API, auth, and storage. Drizzle is a schema and query tool that talks to any Postgres database, including one hosted on Supabase. Plenty of teams run Drizzle against a Supabase connection string to get Supabase's hosting while keeping Drizzle's schema-as-code and typed queries.

Why not just use the Supabase JS client directly?

The Supabase client is built on PostgREST, which is great for simple CRUD from a browser but gets awkward for relational work. Multi-table joins, conditional aggregates, or jsonb containment queries either need PostgREST's limited filter syntax or a hand-written Postgres function that moves logic out of code review and into the database.

Does Drizzle give you TypeScript-style type safety in a JavaScript project?

Yes. Drizzle infers row types from the schema definition automatically, so a JavaScript project with JSDoc and @ts-check gets real autocomplete and type errors on column-name typos without anyone hand-writing an interface. Supabase's client returns untyped data unless you separately generate and maintain types, which drifts the moment someone edits a column outside that workflow.

When would Supabase still be the right choice over raw Drizzle and Postgres?

When you need realtime subscriptions pushed to multiple clients, bundled file storage, or a managed Postgres instance with an auto-generated API you can start using immediately without writing a query layer. Those are hosting and infrastructure features that Drizzle doesn't provide on its own.