{
  "slug": "supabase",
  "name": "Supabase",
  "summary": "Postgres database, sign-in and row-level security in one hosted service. The default for Lovable, Bolt and v0 projects, and what Lovable Cloud runs on.",
  "audience": [
    "builders",
    "developers"
  ],
  "bestFor": "Most sites: bookings, orders, enquiries and an editable menu, with a generous free tier.",
  "cost": "Free tier for small sites; paid from about $25 a month per project.",
  "rank": 2,
  "covers": "full",
  "docs": [
    {
      "label": "Supabase Auth",
      "href": "https://supabase.com/docs/guides/auth"
    },
    {
      "label": "Row level security",
      "href": "https://supabase.com/docs/guides/database/postgres/row-level-security"
    }
  ],
  "tested": "Tested 21 October 2026 on the local Supabase stack: 64 access checks across public, stranger, staff, manager and owner, plus the sign-in and bookings screens end to end with middleware.ts and proxy.ts.",
  "body": "## 1. Create the tables and access rules\n\nRun this once in the Supabase SQL editor (or as a migration). It creates the tables from the data model, turns on row-level security everywhere, and encodes the access table with one helper function.\n\n```sql\ncreate type staff_role as enum ('owner', 'manager', 'staff');\n\ncreate table staff_members (\n  user_id uuid primary key references auth.users on delete cascade,\n  email text not null unique,\n  name text,\n  role staff_role not null default 'staff',\n  status text not null default 'active'\n);\n\n-- The signed-in user's role, or null for the public. security definer so policies can call it\n-- without being blocked by staff_members' own policy.\ncreate or replace function public.my_role() returns staff_role\nlanguage sql stable security definer set search_path = public\nas $$ select role from staff_members where user_id = auth.uid() $$;\n\ncreate table settings (id int primary key default 1 check (id = 1), name text, phone text, email text, address text,\n  instagram text, booking_link text, notify_bookings bool default true, notify_enquiries bool default true,\n  notify_reviews bool default false, daily_summary bool default true);\ncreate table bookings (id uuid primary key default gen_random_uuid(), starts_at timestamptz not null, name text not null,\n  party int not null check (party between 1 and 50), phone text, email text, table_label text, note text,\n  status text not null default 'confirmed' check (status in ('confirmed','arrived','no-show','cancelled')),\n  created_at timestamptz default now());\ncreate table orders (id uuid primary key default gen_random_uuid(), number serial, customer_name text not null,\n  placed_at timestamptz default now(), pickup_at timestamptz, items jsonb not null, total_pence int not null,\n  paid bool default false, stage text not null default 'new' check (stage in ('new','preparing','ready','collected')));\ncreate table enquiries (id uuid primary key default gen_random_uuid(), name text not null, email text not null, phone text,\n  subject text, message text not null, received_at timestamptz default now(),\n  status text not null default 'new' check (status in ('new','replied','archived')));\ncreate table menu_sections (id uuid primary key default gen_random_uuid(), name text not null, position int default 0);\ncreate table menu_items (id uuid primary key default gen_random_uuid(), section_id uuid references menu_sections on delete cascade,\n  name text not null, description text, price_pence int not null, tags text[] default '{}', available bool default true, position int default 0);\ncreate table customers (id uuid primary key default gen_random_uuid(), name text, email text unique, phone text,\n  visits int default 0, last_visit date, spend_pence int default 0, notes text, marketing_opt_in bool default false);\ncreate table reviews (id uuid primary key default gen_random_uuid(), source text, author text, rating int check (rating between 1 and 5),\n  text text, posted_at timestamptz, reply text);\ncreate table opening_hours (day int primary key check (day between 0 and 6), open bool default true, opens time, closes time);\ncreate table closures (id uuid primary key default gen_random_uuid(), starts_on date not null, ends_on date not null, note text);\n\ndo $$ declare t text; begin\n  foreach t in array array['staff_members','settings','bookings','orders','enquiries','menu_sections','menu_items',\n    'customers','reviews','opening_hours','closures'] loop\n    execute format('alter table %I enable row level security', t);\n  end loop;\nend $$;\n\n-- Public content: everyone reads, managers and owners write.\ncreate policy \"public read\" on menu_sections for select using (true);\ncreate policy \"public read\" on menu_items for select using (true);\ncreate policy \"public read\" on opening_hours for select using (true);\ncreate policy \"public read\" on closures for select using (true);\ncreate policy \"public read\" on settings for select using (true);\ncreate policy \"managers write\" on menu_sections for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers write\" on menu_items for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers write\" on opening_hours for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers write\" on closures for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"owner writes\" on settings for update to authenticated using (my_role() = 'owner') with check (my_role() = 'owner');\n\n-- Bookings and orders: the public can only create; the team reads; staff change status; managers do the rest.\ncreate policy \"public creates\" on bookings for insert to anon, authenticated with check (status = 'confirmed');\ncreate policy \"team reads\" on bookings for select to authenticated using (my_role() is not null);\ncreate policy \"team updates\" on bookings for update to authenticated using (my_role() is not null) with check (my_role() is not null);\ncreate policy \"managers delete\" on bookings for delete to authenticated using (my_role() in ('owner','manager'));\ncreate policy \"public creates\" on orders for insert to anon, authenticated with check (stage = 'new' and paid = false);\ncreate policy \"team reads\" on orders for select to authenticated using (my_role() is not null);\ncreate policy \"team updates\" on orders for update to authenticated using (my_role() is not null) with check (my_role() is not null);\n\n-- Row-level security can't limit columns, so a trigger stops staff changing anything but the one\n-- column their job needs (status on bookings, stage on orders). Managers, owners and the service role pass.\ncreate or replace function public.staff_may_only_change() returns trigger\nlanguage plpgsql set search_path = public\nas $$\nbegin\n  if my_role() = 'staff' and (to_jsonb(new) - tg_argv[0]) is distinct from (to_jsonb(old) - tg_argv[0]) then\n    raise exception 'Staff can only change %', tg_argv[0] using errcode = '42501';\n  end if;\n  return new;\nend $$;\ncreate trigger staff_only_status before update on bookings for each row execute function staff_may_only_change('status');\ncreate trigger staff_only_stage before update on orders for each row execute function staff_may_only_change('stage');\n\n-- Enquiries: public creates, managers and owners read and manage.\ncreate policy \"public creates\" on enquiries for insert to anon, authenticated with check (status = 'new');\ncreate policy \"managers manage\" on enquiries for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\n\n-- Customers and reviews: managers and owners only.\ncreate policy \"managers manage\" on customers for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers manage\" on reviews for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\n\n-- Staff list: everyone on the team sees their own row, managers see all, only the owner changes it.\ncreate policy \"see own row\" on staff_members for select to authenticated using (user_id = auth.uid() or my_role() in ('owner','manager'));\ncreate policy \"owner manages\" on staff_members for all to authenticated using (my_role() = 'owner') with check (my_role() = 'owner');\n```\n\n\n## 2. Make the first owner\n\nSign up once through admin-sign-in (or Authentication → Users → Add user), then run, with that email:\n\n```sql\ninsert into staff_members (user_id, email, name, role)\nselect id, email, 'Owner', 'owner' from auth.users where email = 'owner@example.com';\n```\n\n## 3. Protect /admin on the server\n\nIn Next.js, use `@supabase/ssr` and check the user for every `/admin` route except the sign-in page. On Next.js 16 save this as `proxy.ts` and rename the function to `proxy`; on Next.js 15 and earlier keep `middleware.ts` (16 still accepts it). Both were tested.\n\n```ts\n// middleware.ts\nimport { createServerClient } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nexport async function middleware(request: NextRequest) {\n  const response = NextResponse.next({ request });\n  const supabase = createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {\n    cookies: {\n      getAll: () => request.cookies.getAll(),\n      setAll: (list) => list.forEach(({ name, value, options }) => response.cookies.set(name, value, options)),\n    },\n  });\n  const { data: { user } } = await supabase.auth.getUser();\n  if (!user && !request.nextUrl.pathname.startsWith(\"/admin/sign-in\")) {\n    return NextResponse.redirect(new URL(\"/admin/sign-in\", request.url));\n  }\n  return response;\n}\n\nexport const config = { matcher: [\"/admin/:path*\"] };\n```\n\nThe middleware only keeps signed-out people away; the row-level security above is what actually protects the data.\n\n## 4. Wire the screens\n\n- admin-sign-in: `onSignIn` → `supabase.auth.signInWithPassword(...)`, `onMagicLink` → `supabase.auth.signInWithOtp({ email })`. Return the error message, if any.\n- Lists: `select` from the matching table; saves: `update`/`insert`/`upsert` with the browser client — the policies decide what each role may do.\n- admin-staff invites: call `supabase.auth.admin.inviteUserByEmail` from a **server** route (it needs the service-role key), then insert the staff_members row.\n- Public pages read menu, hours, closures and settings with the anon key; the booking and contact forms insert only.\n\nNever put the service-role key in browser code or a `NEXT_PUBLIC_` variable.",
  "url": "https://copypasteui.dev/r/recipes/supabase.json",
  "prompt": "# Back office with Supabase\n\nSet up the back office for this site with this backend, following every step. The back-office screens are the CopyPasteUI \"admin\" components (admin-sign-in, admin-overview, admin-bookings …); each takes its data and save actions as props. Best for: Most sites: bookings, orders, enquiries and an editable menu, with a generous free tier. Cost: Free tier for small sites; paid from about $25 a month per project.\n\n## The back-office data model\n\nEvery recipe stores the same data with the same names, so the back-office screens (category \"admin\") work with any backend. Money is stored in pence/cents as whole numbers. Times are stored in UTC.\n\n| Table | Fields | Used by |\n| --- | --- | --- |\n| `staff_members` | user_id (the auth user), email, name, role (`owner` \\| `manager` \\| `staff`), status (`active` \\| `invited`) | admin-staff, every access check |\n| `settings` | one row: name, phone, email, address, instagram, booking_link, notify_bookings, notify_enquiries, notify_reviews, daily_summary | admin-settings, the public header/footer/contact |\n| `bookings` | id, starts_at, name, party, phone, email, table_label, note, status (`confirmed` \\| `arrived` \\| `no-show` \\| `cancelled`), created_at | admin-bookings, admin-overview |\n| `orders` | id, number, customer_name, placed_at, pickup_at, items (list of {name, qty}), total_pence, paid, stage (`new` \\| `preparing` \\| `ready` \\| `collected`) | admin-orders |\n| `enquiries` | id, name, email, phone, subject, message, received_at, status (`new` \\| `replied` \\| `archived`) | admin-enquiries |\n| `menu_sections` | id, name, position | admin-menu-editor, the public menu |\n| `menu_items` | id, section_id, name, description, price_pence, tags (list), available, position | admin-menu-editor, the public menu |\n| `customers` | id, name, email, phone, visits, last_visit, spend_pence, notes, marketing_opt_in | admin-customers |\n| `reviews` | id, source, author, rating (1–5), text, posted_at, reply | admin-reviews |\n| `opening_hours` | day (0 = Monday … 6 = Sunday), open, opens, closes | admin-hours, the public footer/contact |\n| `closures` | id, starts_on, ends_on, note | admin-hours, the closed-notice banner |\n\n## Who may do what\n\nThis is the security contract. Implement it in the database's own rules, not only in the interface — hiding a button doesn't stop anyone.\n\n| Data | Public visitors | Staff | Manager | Owner |\n| --- | --- | --- | --- | --- |\n| menu_sections, menu_items, opening_hours, closures | read | read | read, write | read, write |\n| settings | read (public fields only) | read | read | read, write |\n| bookings | create only (from the booking form), never read | read, change status | read, write | read, write |\n| orders | create only (from checkout), never read | read, change stage | read, write | read, write |\n| enquiries | create only (from the contact form), never read | — | read, write | read, write |\n| customers, reviews | — | — | read, write | read, write |\n| staff_members | — | read own row | read | read, write |\n\nRules that apply to every backend:\n- Every `/admin` page checks the session **on the server** and sends signed-out people to `/admin/sign-in`.\n- Secret keys (service-role keys, admin SDK credentials, API secrets) live only in server code and environment variables, never in the browser bundle.\n- Public forms may only insert, with fields limited to what the form sends; they can never read other people's rows.\n- `/admin` is left out of the navigation and the sitemap, and disallowed in robots.txt.\n- The first owner account is created by hand once (instructions in each recipe); everyone else is invited from admin-staff.\n\n# Supabase: steps\n\n## 1. Create the tables and access rules\n\nRun this once in the Supabase SQL editor (or as a migration). It creates the tables from the data model, turns on row-level security everywhere, and encodes the access table with one helper function.\n\n```sql\ncreate type staff_role as enum ('owner', 'manager', 'staff');\n\ncreate table staff_members (\n  user_id uuid primary key references auth.users on delete cascade,\n  email text not null unique,\n  name text,\n  role staff_role not null default 'staff',\n  status text not null default 'active'\n);\n\n-- The signed-in user's role, or null for the public. security definer so policies can call it\n-- without being blocked by staff_members' own policy.\ncreate or replace function public.my_role() returns staff_role\nlanguage sql stable security definer set search_path = public\nas $$ select role from staff_members where user_id = auth.uid() $$;\n\ncreate table settings (id int primary key default 1 check (id = 1), name text, phone text, email text, address text,\n  instagram text, booking_link text, notify_bookings bool default true, notify_enquiries bool default true,\n  notify_reviews bool default false, daily_summary bool default true);\ncreate table bookings (id uuid primary key default gen_random_uuid(), starts_at timestamptz not null, name text not null,\n  party int not null check (party between 1 and 50), phone text, email text, table_label text, note text,\n  status text not null default 'confirmed' check (status in ('confirmed','arrived','no-show','cancelled')),\n  created_at timestamptz default now());\ncreate table orders (id uuid primary key default gen_random_uuid(), number serial, customer_name text not null,\n  placed_at timestamptz default now(), pickup_at timestamptz, items jsonb not null, total_pence int not null,\n  paid bool default false, stage text not null default 'new' check (stage in ('new','preparing','ready','collected')));\ncreate table enquiries (id uuid primary key default gen_random_uuid(), name text not null, email text not null, phone text,\n  subject text, message text not null, received_at timestamptz default now(),\n  status text not null default 'new' check (status in ('new','replied','archived')));\ncreate table menu_sections (id uuid primary key default gen_random_uuid(), name text not null, position int default 0);\ncreate table menu_items (id uuid primary key default gen_random_uuid(), section_id uuid references menu_sections on delete cascade,\n  name text not null, description text, price_pence int not null, tags text[] default '{}', available bool default true, position int default 0);\ncreate table customers (id uuid primary key default gen_random_uuid(), name text, email text unique, phone text,\n  visits int default 0, last_visit date, spend_pence int default 0, notes text, marketing_opt_in bool default false);\ncreate table reviews (id uuid primary key default gen_random_uuid(), source text, author text, rating int check (rating between 1 and 5),\n  text text, posted_at timestamptz, reply text);\ncreate table opening_hours (day int primary key check (day between 0 and 6), open bool default true, opens time, closes time);\ncreate table closures (id uuid primary key default gen_random_uuid(), starts_on date not null, ends_on date not null, note text);\n\ndo $$ declare t text; begin\n  foreach t in array array['staff_members','settings','bookings','orders','enquiries','menu_sections','menu_items',\n    'customers','reviews','opening_hours','closures'] loop\n    execute format('alter table %I enable row level security', t);\n  end loop;\nend $$;\n\n-- Public content: everyone reads, managers and owners write.\ncreate policy \"public read\" on menu_sections for select using (true);\ncreate policy \"public read\" on menu_items for select using (true);\ncreate policy \"public read\" on opening_hours for select using (true);\ncreate policy \"public read\" on closures for select using (true);\ncreate policy \"public read\" on settings for select using (true);\ncreate policy \"managers write\" on menu_sections for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers write\" on menu_items for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers write\" on opening_hours for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers write\" on closures for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"owner writes\" on settings for update to authenticated using (my_role() = 'owner') with check (my_role() = 'owner');\n\n-- Bookings and orders: the public can only create; the team reads; staff change status; managers do the rest.\ncreate policy \"public creates\" on bookings for insert to anon, authenticated with check (status = 'confirmed');\ncreate policy \"team reads\" on bookings for select to authenticated using (my_role() is not null);\ncreate policy \"team updates\" on bookings for update to authenticated using (my_role() is not null) with check (my_role() is not null);\ncreate policy \"managers delete\" on bookings for delete to authenticated using (my_role() in ('owner','manager'));\ncreate policy \"public creates\" on orders for insert to anon, authenticated with check (stage = 'new' and paid = false);\ncreate policy \"team reads\" on orders for select to authenticated using (my_role() is not null);\ncreate policy \"team updates\" on orders for update to authenticated using (my_role() is not null) with check (my_role() is not null);\n\n-- Row-level security can't limit columns, so a trigger stops staff changing anything but the one\n-- column their job needs (status on bookings, stage on orders). Managers, owners and the service role pass.\ncreate or replace function public.staff_may_only_change() returns trigger\nlanguage plpgsql set search_path = public\nas $$\nbegin\n  if my_role() = 'staff' and (to_jsonb(new) - tg_argv[0]) is distinct from (to_jsonb(old) - tg_argv[0]) then\n    raise exception 'Staff can only change %', tg_argv[0] using errcode = '42501';\n  end if;\n  return new;\nend $$;\ncreate trigger staff_only_status before update on bookings for each row execute function staff_may_only_change('status');\ncreate trigger staff_only_stage before update on orders for each row execute function staff_may_only_change('stage');\n\n-- Enquiries: public creates, managers and owners read and manage.\ncreate policy \"public creates\" on enquiries for insert to anon, authenticated with check (status = 'new');\ncreate policy \"managers manage\" on enquiries for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\n\n-- Customers and reviews: managers and owners only.\ncreate policy \"managers manage\" on customers for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\ncreate policy \"managers manage\" on reviews for all to authenticated using (my_role() in ('owner','manager')) with check (my_role() in ('owner','manager'));\n\n-- Staff list: everyone on the team sees their own row, managers see all, only the owner changes it.\ncreate policy \"see own row\" on staff_members for select to authenticated using (user_id = auth.uid() or my_role() in ('owner','manager'));\ncreate policy \"owner manages\" on staff_members for all to authenticated using (my_role() = 'owner') with check (my_role() = 'owner');\n```\n\n\n## 2. Make the first owner\n\nSign up once through admin-sign-in (or Authentication → Users → Add user), then run, with that email:\n\n```sql\ninsert into staff_members (user_id, email, name, role)\nselect id, email, 'Owner', 'owner' from auth.users where email = 'owner@example.com';\n```\n\n## 3. Protect /admin on the server\n\nIn Next.js, use `@supabase/ssr` and check the user for every `/admin` route except the sign-in page. On Next.js 16 save this as `proxy.ts` and rename the function to `proxy`; on Next.js 15 and earlier keep `middleware.ts` (16 still accepts it). Both were tested.\n\n```ts\n// middleware.ts\nimport { createServerClient } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nexport async function middleware(request: NextRequest) {\n  const response = NextResponse.next({ request });\n  const supabase = createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {\n    cookies: {\n      getAll: () => request.cookies.getAll(),\n      setAll: (list) => list.forEach(({ name, value, options }) => response.cookies.set(name, value, options)),\n    },\n  });\n  const { data: { user } } = await supabase.auth.getUser();\n  if (!user && !request.nextUrl.pathname.startsWith(\"/admin/sign-in\")) {\n    return NextResponse.redirect(new URL(\"/admin/sign-in\", request.url));\n  }\n  return response;\n}\n\nexport const config = { matcher: [\"/admin/:path*\"] };\n```\n\nThe middleware only keeps signed-out people away; the row-level security above is what actually protects the data.\n\n## 4. Wire the screens\n\n- admin-sign-in: `onSignIn` → `supabase.auth.signInWithPassword(...)`, `onMagicLink` → `supabase.auth.signInWithOtp({ email })`. Return the error message, if any.\n- Lists: `select` from the matching table; saves: `update`/`insert`/`upsert` with the browser client — the policies decide what each role may do.\n- admin-staff invites: call `supabase.auth.admin.inviteUserByEmail` from a **server** route (it needs the service-role key), then insert the staff_members row.\n- Public pages read menu, hours, closures and settings with the anon key; the booking and contact forms insert only.\n\nNever put the service-role key in browser code or a `NEXT_PUBLIC_` variable.\n\nDocs to check before you start, since providers change their APIs: Supabase Auth (https://supabase.com/docs/guides/auth); Row level security (https://supabase.com/docs/guides/database/postgres/row-level-security).\n"
}
