{
  "slug": "postgres",
  "name": "Postgres + Better Auth or Clerk",
  "summary": "Your own stack: any Postgres (Neon, Vercel Postgres, Railway, your own) with Drizzle, and Better Auth or Clerk for sign-in. Access is checked in server code.",
  "audience": [
    "developers"
  ],
  "bestFor": "Developers who want to own the database and choose each piece, or who already run Next.js with Neon.",
  "cost": "Neon has a free tier; Better Auth is free and open source; Clerk is free for small user counts.",
  "rank": 5,
  "covers": "full",
  "docs": [
    {
      "label": "Drizzle with Neon",
      "href": "https://orm.drizzle.team/docs/get-started/neon-new"
    },
    {
      "label": "Better Auth",
      "href": "https://www.better-auth.com/docs"
    },
    {
      "label": "Clerk middleware",
      "href": "https://clerk.com/docs/references/nextjs/clerk-middleware"
    }
  ],
  "body": "## 1. Schema\n\nModel the data-model tables in Drizzle (`pgTable`), same names and types: `pgEnum(\"staff_role\", [\"owner\", \"manager\", \"staff\"])`, integer pence, `timestamp({ withTimezone: true })`, `jsonb` for order items, `text().array()` for tags. Run `drizzle-kit push` (or generate migrations).\n\n## 2. Sign-in\n\nPick one:\n- **Better Auth** — add the email-and-password and magic-link plugins, mount its route handler at `/api/auth/[...all]`, and use its Drizzle adapter so users live in the same database.\n- **Clerk** — hosted sign-in screens and invitations; you only store `staff_members` keyed by Clerk's user id.\n\n## 3. Protect /admin on the server\n\nOn Next.js 16 the file is `proxy.ts` and the exported function is `proxy`; on 15 and earlier it's `middleware.ts` / `middleware` (16 still accepts the old name).\n\nBetter Auth:\n\n```ts\n// middleware.ts — a quick cookie check that redirects; real checks happen on the server below.\nimport { getSessionCookie } from \"better-auth/cookies\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nexport function middleware(request: NextRequest) {\n  if (request.nextUrl.pathname.startsWith(\"/admin/sign-in\")) return NextResponse.next();\n  if (!getSessionCookie(request)) return NextResponse.redirect(new URL(\"/admin/sign-in\", request.url));\n  return NextResponse.next();\n}\nexport const config = { matcher: [\"/admin/:path*\"] };\n```\n\nClerk:\n\n```ts\nimport { clerkMiddleware, createRouteMatcher } from \"@clerk/nextjs/server\";\nconst isAdmin = createRouteMatcher([\"/admin(.*)\"]);\nconst isSignIn = createRouteMatcher([\"/admin/sign-in(.*)\"]);\nexport default clerkMiddleware(async (auth, req) => { if (isAdmin(req) && !isSignIn(req)) await auth.protect(); });\nexport const config = { matcher: [\"/((?!_next|.*\\\\..*).*)\"] };\n```\n\n## 4. Check the role in every server action and route\n\nPostgres here has no row-level rules, so the server code is the security. One helper, used everywhere:\n\n```ts\n// lib/access.ts\nimport { headers } from \"next/headers\";\nimport { eq } from \"drizzle-orm\";\nimport { auth } from \"@/lib/auth\";          // Better Auth instance (Clerk: use auth() from @clerk/nextjs/server)\nimport { db, staffMembers } from \"@/db\";\n\nexport type Role = \"owner\" | \"manager\" | \"staff\";\n\nexport async function requireRole(allowed: Role[]) {\n  const session = await auth.api.getSession({ headers: await headers() });\n  if (!session) throw new Error(\"Not signed in\");\n  const [member] = await db.select().from(staffMembers).where(eq(staffMembers.userId, session.user.id));\n  if (!member || member.status !== \"active\" || !allowed.includes(member.role)) throw new Error(\"Not allowed\");\n  return member;\n}\n```\n\nFollow the access table: e.g. `setBookingStatus` calls `requireRole([\"owner\", \"manager\", \"staff\"])` and updates **only** `status`; `saveMenu` calls `requireRole([\"owner\", \"manager\"])`; `updateSettings` and staff changes call `requireRole([\"owner\"])`. Public form actions don't call it, but insert only the fields the form sends with status forced to its starting value, and are rate-limited.\n\n## 5. The first owner\n\nAfter signing up once, insert their `staff_members` row by hand (`role = 'owner'`) with a one-off SQL statement or Drizzle Studio.\n\n## 6. Wire the screens\n\nLoad data in server components with Drizzle and pass it into the screens; their callbacks call server actions that start with `requireRole`. The database URL and auth secrets stay in server environment variables.",
  "url": "https://copypasteui.dev/r/recipes/postgres.json",
  "prompt": "# Back office with Postgres + Better Auth or Clerk\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: Developers who want to own the database and choose each piece, or who already run Next.js with Neon. Cost: Neon has a free tier; Better Auth is free and open source; Clerk is free for small user counts.\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# Postgres + Better Auth or Clerk: steps\n\n## 1. Schema\n\nModel the data-model tables in Drizzle (`pgTable`), same names and types: `pgEnum(\"staff_role\", [\"owner\", \"manager\", \"staff\"])`, integer pence, `timestamp({ withTimezone: true })`, `jsonb` for order items, `text().array()` for tags. Run `drizzle-kit push` (or generate migrations).\n\n## 2. Sign-in\n\nPick one:\n- **Better Auth** — add the email-and-password and magic-link plugins, mount its route handler at `/api/auth/[...all]`, and use its Drizzle adapter so users live in the same database.\n- **Clerk** — hosted sign-in screens and invitations; you only store `staff_members` keyed by Clerk's user id.\n\n## 3. Protect /admin on the server\n\nOn Next.js 16 the file is `proxy.ts` and the exported function is `proxy`; on 15 and earlier it's `middleware.ts` / `middleware` (16 still accepts the old name).\n\nBetter Auth:\n\n```ts\n// middleware.ts — a quick cookie check that redirects; real checks happen on the server below.\nimport { getSessionCookie } from \"better-auth/cookies\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nexport function middleware(request: NextRequest) {\n  if (request.nextUrl.pathname.startsWith(\"/admin/sign-in\")) return NextResponse.next();\n  if (!getSessionCookie(request)) return NextResponse.redirect(new URL(\"/admin/sign-in\", request.url));\n  return NextResponse.next();\n}\nexport const config = { matcher: [\"/admin/:path*\"] };\n```\n\nClerk:\n\n```ts\nimport { clerkMiddleware, createRouteMatcher } from \"@clerk/nextjs/server\";\nconst isAdmin = createRouteMatcher([\"/admin(.*)\"]);\nconst isSignIn = createRouteMatcher([\"/admin/sign-in(.*)\"]);\nexport default clerkMiddleware(async (auth, req) => { if (isAdmin(req) && !isSignIn(req)) await auth.protect(); });\nexport const config = { matcher: [\"/((?!_next|.*\\\\..*).*)\"] };\n```\n\n## 4. Check the role in every server action and route\n\nPostgres here has no row-level rules, so the server code is the security. One helper, used everywhere:\n\n```ts\n// lib/access.ts\nimport { headers } from \"next/headers\";\nimport { eq } from \"drizzle-orm\";\nimport { auth } from \"@/lib/auth\";          // Better Auth instance (Clerk: use auth() from @clerk/nextjs/server)\nimport { db, staffMembers } from \"@/db\";\n\nexport type Role = \"owner\" | \"manager\" | \"staff\";\n\nexport async function requireRole(allowed: Role[]) {\n  const session = await auth.api.getSession({ headers: await headers() });\n  if (!session) throw new Error(\"Not signed in\");\n  const [member] = await db.select().from(staffMembers).where(eq(staffMembers.userId, session.user.id));\n  if (!member || member.status !== \"active\" || !allowed.includes(member.role)) throw new Error(\"Not allowed\");\n  return member;\n}\n```\n\nFollow the access table: e.g. `setBookingStatus` calls `requireRole([\"owner\", \"manager\", \"staff\"])` and updates **only** `status`; `saveMenu` calls `requireRole([\"owner\", \"manager\"])`; `updateSettings` and staff changes call `requireRole([\"owner\"])`. Public form actions don't call it, but insert only the fields the form sends with status forced to its starting value, and are rate-limited.\n\n## 5. The first owner\n\nAfter signing up once, insert their `staff_members` row by hand (`role = 'owner'`) with a one-off SQL statement or Drizzle Studio.\n\n## 6. Wire the screens\n\nLoad data in server components with Drizzle and pass it into the screens; their callbacks call server actions that start with `requireRole`. The database URL and auth secrets stay in server environment variables.\n\nDocs to check before you start, since providers change their APIs: Drizzle with Neon (https://orm.drizzle.team/docs/get-started/neon-new); Better Auth (https://www.better-auth.com/docs); Clerk middleware (https://clerk.com/docs/references/nextjs/clerk-middleware).\n"
}
