How to Document a Supabase Backend (Schema-First Walkthrough)
You have a Supabase project. Tables, row-level security policies, a couple of edge functions, maybe storage buckets. The code runs. The next developer who joins has no idea what projects.owner_id is supposed to reference, which RLS policies are load-bearing, or what that notify-slack edge function does at 2am.
This walkthrough documents a Supabase backend end-to-end using what I call schema-first docs: the database schema and policies are the source of truth, and every page you write anchors back to a real SQL object. By the end, you will have a live documentation site covering tables, policies, functions, and usage examples, published under your own domain. The whole thing takes about an hour if your project is small, longer if you have thirty tables.
What you will build
A documentation site for a hypothetical SaaS called Taskly, with three tables (users, projects, tasks), RLS policies on each, one edge function (send-invite), and two storage buckets. The same pattern applies to any Supabase project. Substitute your own schema.
Prerequisites
Before starting, confirm you have the following:
- Node.js 20 or newer (
node --version) - The Supabase CLI installed:
npm install -g supabase - Access to your Supabase project dashboard, or the project reference ID
- A local clone of the repo where your migrations live, or at least the ability to dump the schema
- Either a Docsio account (free tier works) or a local Docusaurus setup if you prefer to host yourself
Optional: a personal access token from your Supabase dashboard if you want to script metadata lookups, and a GitHub repo for the docs site so it can rebuild on push.
Step 1: Dump the current schema
Before you write a word of prose, get the schema out of Postgres and into a file you can read next to your docs. Run this from your project root:
supabase db dump --schema public --data-only=false -f schema.sql
If you are not using the CLI locally, pull it from the dashboard: Database → Backups → Schema-only dump. Either way, you end up with a schema.sql file you can read. Open it and confirm the tables you expect are there.
For the Taskly example, the relevant section looks like this:
create table public.users (
id uuid primary key default auth.uid(),
email text unique not null,
display_name text,
created_at timestamptz default now()
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
owner_id uuid references public.users(id) on delete cascade,
name text not null,
archived boolean default false,
created_at timestamptz default now()
);
create table public.tasks (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
title text not null,
status text check (status in ('todo','doing','done')) default 'todo',
assignee_id uuid references public.users(id),
due_date date,
created_at timestamptz default now()
);
This file is now the source of truth for everything in your docs. If the schema changes, this file changes, and the docs change with it. That is the whole idea behind schema-first.
Step 2: Dump policies and functions
Schema is not enough. RLS policies are the real API of a Supabase backend, because they decide who can see what. Run:
supabase db dump --role-only=false --schema public -f schema-full.sql
Or query them directly in the SQL editor:
select schemaname, tablename, policyname, permissive, roles, cmd, qual
from pg_policies
where schemaname = 'public'
order by tablename, policyname;
For Taskly, you should see policies like projects_select_own (auth.uid() = owner_id) and tasks_select_in_owned_project (a join against projects where the owner matches). Save the output to a file called policies.md in your docs repo. Do this before you forget which policy does what.
Do the same for edge functions:
supabase functions list
Note the function name, the runtime, and what it does. Taskly has one: send-invite, which takes a project ID and an email, writes a row to an invites table, and triggers a Resend email.
Step 3: Pick a structure
Now you know what exists. Before writing, lock the structure so the docs do not sprawl. I use this layout for every Supabase project:
- Getting started: auth flow, client setup, how to connect from the app
- Data model: one page per table, with columns, relationships, and a diagram if it helps
- Security: one page per table listing every RLS policy in plain English
- Functions: one page per edge function or Postgres function worth calling from the client
- Storage: buckets, their policies, and the upload flow
- Examples: a handful of real queries and mutations from the app
Six top-level sections, no more. If you find yourself wanting a seventh, it probably belongs inside one of these. Keep the top nav tight.
Step 4: Generate the docs site
You have two realistic paths here. Either you let a tool read your Supabase project and stub out every page, or you scaffold a docs site yourself and fill it in.
Path A: generate from your Supabase project
If you want the scaffold done for you, point Docsio at your Supabase project URL or upload the schema.sql file:
npx docsio new --from-supabase https://<project>.supabase.co
Docsio reads the public schema, creates a page per table with the columns and foreign keys, stubs out a security page listing policies, and gives you a structure you can edit in the live preview. It does not invent descriptions, so every page starts with TODO markers where you need to explain what something means. That is the point. Prose is your job; structure is not.
If you prefer to upload files instead of hitting the project URL, drag schema.sql, policies.md, and any README you have on the /new page and generate from those. Same result.
Path B: scaffold Docusaurus yourself
If you would rather self-host, run:
npx create-docusaurus@latest taskly-docs classic
cd taskly-docs
Create the folder structure to match the six sections above:
docs/
getting-started.md
data-model/
users.md
projects.md
tasks.md
security/
users.md
projects.md
tasks.md
functions/
send-invite.md
storage/
avatars.md
attachments.md
examples/
queries.md
mutations.md
Update sidebars.ts to enforce that order. The rest of this walkthrough applies to both paths. The files are just markdown.
Step 5: Write the table pages
Each table page follows the same template. Here is data-model/projects.md for Taskly:
# projects
A project groups tasks under a single owner. Every task belongs to exactly one project. Deleting a project deletes its tasks via cascade.
## Columns
| Column | Type | Notes |
|------------|-------------|--------------------------------------------|
| id | uuid | Primary key, `gen_random_uuid()` |
| owner_id | uuid | FK to `users.id`, cascade on delete |
| name | text | Required, no length limit at DB level |
| archived | boolean | Default `false`, hides project in UI |
| created_at | timestamptz | Default `now()` |
## Relationships
- `owner_id` references `users(id)`. One user, many projects.
- `tasks.project_id` references `projects(id)`. One project, many tasks.
## Typical queries
```ts
// List active projects for the signed-in user
const { data } = await supabase
.from('projects')
.select('id, name, created_at')
.eq('archived', false)
.order('created_at', { ascending: false });
```
See the full RLS policies on the [security → projects](/security/projects) page.
That template (intro sentence, columns table, relationships list, one real query, link to the policy page) works for every table. Do not freestyle. Consistency is what makes the docs scannable.
Step 6: Write the policy pages in plain English
This is the part most Supabase projects skip and the part new developers need most. For every RLS policy, translate the SQL into one sentence a new hire would understand.
security/projects.md for Taskly:
# Policies on projects
Row-level security is enabled. Four policies control access.
**Select your own projects**
A signed-in user can read rows where `owner_id` equals their `auth.uid()`. Anonymous users see nothing.
**Insert with yourself as owner**
A signed-in user can insert a row only if `owner_id` equals their own `auth.uid()`. This stops users from creating projects owned by other people.
**Update your own projects**
A signed-in user can update rows they own. The `owner_id` column cannot be changed to someone else because of a `check (owner_id = auth.uid())` clause on update.
**Delete your own projects**
A signed-in user can delete rows they own. Deletion cascades to tasks.
Four paragraphs per table, one per policy. If a policy cannot be explained in a sentence, it is probably too complicated and deserves a refactor before it gets documented.
Step 7: Document edge functions like an API reference
Edge functions are the client-facing API on top of your database. Treat each one like an API endpoint. For send-invite:
# send-invite
Invites a new user to a project by email. Creates a row in `invites`, sends an email via Resend, returns the invite ID.
## Request
```http
POST /functions/v1/send-invite
Authorization: Bearer <supabase-anon-or-user-jwt>
Content-Type: application/json
{
"project_id": "uuid",
"email": "string"
}
```
## Response
```json
{ "invite_id": "uuid", "sent_at": "2026-04-20T12:00:00Z" }
```
## Errors
- `401` if no auth header
- `403` if caller is not the project owner
- `409` if the email already has a pending invite for that project
If you have more edge functions, use the same template. For a deeper pattern on endpoint docs, see our notes on API reference documentation and the broader shape of a good API portal.
Step 8: Add working examples
One examples page beats ten conceptual ones. Pick three real queries from your app. Paste the TypeScript. Explain what it does in two sentences. That is the page. Developers scan examples looking for the one closest to what they need, then copy and adapt.
For Taskly, good candidates are: list tasks in a project sorted by due date, mark a task done with an optimistic update, and upload an avatar to the avatars storage bucket. Show the query, show the expected shape of the response, link back to the relevant table and policy pages.
Step 9: Publish
If you used Docsio, click Publish. Docs live at taskly.docsio.app or your own domain if you attach one. If you scaffolded Docusaurus yourself, run npm run build and deploy the build/ directory to Vercel, Netlify, or Cloudflare Pages. Both free tiers work fine for docs traffic.
Either way, add the docs link to your app's main nav and to the README of the backend repo. Docs that nobody can find do not count.
Step 10: Keep it in sync
This is where most Supabase docs rot. The trick: tie doc updates to migrations, not to a calendar. Add this to your migration workflow, whether you use the CLI or a CI script:
# After applying a migration
supabase db dump --schema public -f schema.sql
git diff schema.sql
If schema.sql changed, the docs need a review before the migration is merged. Make that a PR rule. Treat the schema dump as a doc artifact, not a build output. This is the same principle as docs-as-code applied to the database.
Set a 15-minute calendar event once a quarter to walk the docs and check for drift. That is usually enough to catch stale policy descriptions and dead columns.
What to do next
You have a documented Supabase backend. Three follow-ups worth doing:
- Add a diagram. A single entity-relationship diagram on the data-model index page cuts onboarding time in half. dbdiagram.io exports from a Postgres dump for free.
- Wire up versioning when you ship a breaking migration. If you change the shape of a table everyone queries, publish a v1 and v2 of the relevant pages instead of overwriting. The documentation versioning pattern applies directly.
- Open the docs to your team and collect the first round of "this is confusing" feedback. Fix the confusing parts. Ignore the rest.
If you want the scaffolding done in a few minutes instead of an afternoon, generate a docs site from your Supabase project and edit the pages in the live preview. If you want to self-host, the same template works in Docusaurus, MkDocs, or any flat-file generator. The schema-first discipline is the part that matters. The tool is interchangeable.
