Back to writing

Building my own admin panel: Postgres, Row Level Security, and Server Actions

SupabasePostgresNext.jsRow Level Security

I could have used a ready-made CMS to manage the projects shown on this site. Plenty of good ones exist. I built my own instead, on purpose, because I wanted to actually understand the pieces underneath: a real database, real authentication, and real access control, not just a form that happens to save data somewhere.

The stack

  • Supabase for Postgres and authentication
  • Next.js Server Actions for the write operations (add, edit, delete, reorder)
  • @supabase/ssr, the official pattern for handling auth sessions across server and browser in a Next.js App Router project
  • @dnd-kit for drag-and-drop reordering in the admin UI

The part that actually matters: Row Level Security

Anyone can build a form that writes to a database. The real question is what stops someone who isn't me from writing to it too.

The public Work section on this site reads directly from a projects table in Postgres, no separate API layer hiding it. That means the database itself has to be the thing enforcing who can do what, not just the UI. This is what Row Level Security (RLS) is for.

The policy is simple in shape:

  • Anyone can read (SELECT), since the project list is public content
  • Only an authenticated session can write (INSERT, UPDATE, DELETE)

The important part isn't writing that policy, it's proving it actually holds. I tested it directly: sent a write request using only the public, unauthenticated key, no session attached, and confirmed Postgres rejected it at the database level:

42501: new row violates row-level security policy

That's the difference between "the button is hidden in the UI" and "the database itself refuses the write." The first is a UX choice. The second is actual security. I re-queried the table afterward to confirm nothing had changed, no intruder row, all rows untouched.

I also added an explicit getUser() check inside every server action that mutates data, not relying on RLS and middleware alone. That's defense in depth: if any one layer has a bug or gets misconfigured later, there's still another layer catching it before bad data gets anywhere near the table.

A deliberate decision about credentials

While building this, I never handed my database password, connection string, or service key to any AI coding tool, even mid-session, even when it would have been faster. When the migration needed to run against the live database, I copied the SQL myself and ran it directly in Supabase's own SQL editor. It's a small habit, but it's the kind of boundary that matters more as a project touches real user data, and it's one I intend to keep for every project going forward, not just this one.

What this actually gave me

A working admin panel where I can add, edit, delete, and drag-to-reorder the projects shown publicly, with the change reflected live, no redeploy needed. But more than the feature itself, this was a real exercise in the parts of backend development that don't show up in a tutorial: what it means for access control to be enforced where it can't be bypassed, and what it looks like to actually verify a security boundary instead of assuming it works because the UI behaves correctly.

I ran a full audit on the finished panel afterward too, the same kind of pass I ran on the public site before launch, checking for missing confirmations, silent failures, unvalidated input, and accessibility gaps. A few things came up (an unguarded delete with no confirmation being the most important one), and I fixed them the same way: find it, understand why it matters, fix it properly, verify it held.