Supabase + Vercel Admin Panel: Routes, Auth, and RLS
The admin panel structure I actually run in production: /admin/* client routes behind a guard, one Supabase client, and row-level security doing the real authorization work.
Table of contents
The structure is four decisions. Put the admin at /admin/* inside the same Next.js deployment as the public site. Make admin pages client components wrapped in an auth guard, and leave public pages as server components with ISR. Export one Supabase client from one file and import it everywhere. Then treat row-level security — not the guard — as the authorization boundary, because the guard only redirects.
That is the whole shape. Everything below is the detail that cost me time: the RLS policy Postgres will not let you write twice, and the auth call the Supabase docs tell you not to trust.
One deployment, two shapes
The admin is not a separate app. app/admin/posts/page.tsx is a route like any other, so Vercel needs no configuration for it and the admin ships on the same commit as the public site. That is the entire deployment story — the pipeline is the one I described in commit, push, deploy from one prompt.
What is not shared is the rendering model. The two surfaces want opposite things.
Public pages: server components, cached
Public routes read Supabase directly inside the server component. No API layer, no client fetch, no loading spinner.
// app/blog/page.tsx
export const revalidate = 3600;
async function getAllPosts() {
const { data } = await supabase
.from("blog_posts")
.select("id,title,slug,excerpt,tags,published_at")
.eq("published", true)
.order("published_at", { ascending: false });
return data ?? [];
}
There is no session here and there should not be. The page is rendered once and served from cache to everyone. The full walkthrough of the public half is in the full-stack build in two hours.
Admin pages: client components, no cache
Every admin page in my app opens the same way:
// app/admin/posts/page.tsx
"use client";
export default function AdminPostsPage() {
return (
<AdminGuard>
<AdminShell>
<PostList />
</AdminShell>
</AdminGuard>
);
}
Two reasons, and only two. The session lives in browser storage, so a server render has nothing to authenticate with. And admin data must never be cached across users — a prerendered /admin/posts is a leak with a build step.
The auth guard is a redirect, not a boundary
Here is the real guard, trimmed:
// components/admin/AdminGuard.tsx
"use client";
export default function AdminGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setLoading(false);
if (!data.session) router.replace("/admin/login");
});
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
if (!s) router.replace("/admin/login");
});
return () => sub.subscription.unsubscribe();
}, [router]);
if (loading) return <div>Checking session…</div>;
if (!session) return null;
return <>{children}</>;
}
The onAuthStateChange subscription is the part people leave out: without it, a token that expires in an open tab leaves the panel rendering a shell over queries that now return nothing.
But be clear about what this component is. It decides whether to show a spinner, a redirect, or the UI. It is not security. Anyone can edit client state.
What the docs actually say
Supabase's Next.js server-side auth guide is blunt about this. On protecting pages: "The server gets the user session from the cookies, which can be spoofed by anyone." On the specific call my guard uses: the session "is loaded directly from local storage and isn't re-validated against the Auth server," and you should "Never trust supabase.auth.getSession() inside server code" (Supabase docs).
So if you do render admin data on the server, validate the token instead of reading the cookie:
const { data, error } = await supabase.auth.getClaims();
if (error || !data) redirect("/admin/login");
getClaims() verifies the JWT signature against the project's published public keys on every call. That is the difference between checking a claim and believing one.
Next.js has moved the same direction. In Next.js 16 the middleware file convention is deprecated and renamed to proxy, and the docs now recommend "users avoid relying on Middleware unless no other options exist", adding that a refactor "can silently remove Proxy coverage. Always verify authentication and authorization inside each Server Function rather than relying on Proxy alone" (Next.js proxy reference).
I run no auth middleware at all. Gate in the UI, enforce in the database.
RLS is where authorization actually lives
Every read and write from the panel goes to PostgREST carrying the user's JWT. Postgres decides. That is why blog_posts carries two policies, not one:
alter table blog_posts enable row level security;
create policy "Public can read published posts"
on blog_posts for select
using (published = true);
create policy "Authenticated can manage posts"
on blog_posts for all to authenticated
using (true) with check (true);
Permissive policies combine with OR — Postgres: "All permissive policies which are applicable to a given query will be combined together using the Boolean 'OR' operator" (CREATE POLICY). So an anonymous visitor sees published rows, a logged-in admin sees everything, and there is no if (isAdmin) anywhere in my TypeScript.
Postgres has no CREATE POLICY IF NOT EXISTS
This is the one that bites. The synopsis in the Postgres docs offers AS, FOR, TO, USING and WITH CHECK — and nothing else. Re-running a migration file throws. A migration you can safely re-run needs a guard, so every policy should be written like this:
alter table pending_actions enable row level security;
do $$ begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'pending_actions'
and policyname = 'authenticated_all_pending_actions'
) then
create policy "authenticated_all_pending_actions"
on pending_actions for all to authenticated
using (true) with check (true);
end if;
end $$;
revoke insert, update, delete on public.pending_actions from anon;
That last line is not decoration
Policies do not take grants back. Supabase states it directly: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it," and adding policies "doesn't remove them" (Supabase RLS guide).
The saving grace is the direction of the default. Postgres: "If row-level security is enabled for a table, but no applicable policies exist, a 'default deny' policy is assumed." Enable RLS the moment you create the table and the failure mode is an empty admin screen you notice in a minute, rather than an open table you never notice.
Two rules that decide whether RLS is fast
Both from the Supabase guide above. First, wrap auth functions in a subquery — (select auth.uid()) rather than auth.uid() — which "causes an initPlan to be run by the Postgres optimizer," caching the result per statement instead of per row. Second, index every column a policy filters on, because "an unindexed filter column turns a read into a sequential scan."
Also worth knowing: auth.uid() returns null for an unauthenticated request, and null = user_id is never true — but Supabase still recommends writing the check explicitly as using (auth.uid() is not null and auth.uid() = user_id).
One client, one file
// lib/supabase.ts
const isServer = typeof window === "undefined";
export const supabase = createClient(url, key, {
auth: { persistSession: !isServer, autoRefreshToken: !isServer },
});
The singleton is not a style preference. supabase-js warns when you create more than one browser client — "Multiple GoTrueClient instances detected in the same browser context… may produce undefined behavior when used concurrently under the same storage key" (auth-js #725). Two clients racing on one storage key produces logouts you cannot reproduce.
The conditional matters too. Session persistence has to stay on in the browser for the guard to work, and a server render has no session to persist, so the auth machinery is skipped there.
Typing the tables
Types live in the same file as the client. Hand-written, one type per table:
export type BlogPost = {
id: string
title: string
slug: string
content: string
tags: string[]
published: boolean
published_at: string | null
business_id?: string | null
}
The habit that pays off is deriving narrow types for narrow selects instead of pretending a partial row is a full one:
type BlogListPost = Pick<BlogPost, "id" | "title" | "slug" | "tags"> & {
is_featured?: boolean
};
A listing page that selects six columns should not be typed as BlogPost. When it is, the first post.content someone adds compiles and then renders undefined in production.
Hand-written types drift from the schema — that is the real cost, and supabase gen types typescript removes it. I stayed hand-written because the generated file is large, regenerating needs CLI auth, and the drift is caught by the build. On a team, generate them.
Decisions and tradeoffs
| Decision | What I run | Cost of it | When I would pick the other |
|---|---|---|---|
| Admin location | Same deployment, /admin/* | Admin bundle ships in the same project; a bad admin build blocks the marketing site | Separate app once the admin has its own release cadence or a second team |
| Rendering | Client components, no ISR | No SSR, brief spinner, no SEO (correct here) | Server components + getClaims() if the panel gets big enough that first paint matters |
| Auth check | Client guard + RLS | Guard is bypassable by design; all trust sits in Postgres | Add server-side getClaims() in any route that renders privileged data before hydration |
| Roles | One authenticated role, using (true) | No viewer/editor separation; every admin can delete | Per-role policies keyed on a profiles.role column once more than one person logs in |
| Types | Hand-written in lib/supabase.ts | Drifts from schema silently until a query returns null | Generated types on any schema more than one person changes |
| Migrations | Idempotent SQL files, run by hand | No migration history, no rollback ordering | Supabase CLI migrations once the schema outlives your memory of it |
| Writes | Direct from the client, RLS-gated | An automation bug writes to a live table | Queue table + approval, which is what I use for agents |
That last row is the one I feel strongest about. The autonomous agents on this site do not write to live tables at all — they insert proposals into a pending_actions table with a status of pending, approved or rejected, and I approve them from the admin panel. The reasoning is in why my AI agents get an approval queue, not write access.
What I would change
The panel has one authenticated role and policies that say using (true). That was right for a single-user CRM and it is the first thing that breaks with a second user, because RLS cannot express "manager can edit but not delete" when every policy grants everything to everyone logged in. The fix is a profiles table with a role column and policies that read it — which also means indexing that column, per the rule above.
The other honest gap: the guard uses getSession(). Today no admin route renders on the server, so the weak check gates nothing but a spinner — a defensible position that stops being true the first time someone adds a server component under /admin.
FAQ
How do I structure a Next.js admin panel with Supabase?
Put it at app/admin/* in the same project as the public site. Wrap each admin page in a client-side guard that checks for a session and redirects to a login route. Export one Supabase client from one module. Enforce access with RLS policies rather than with UI checks.
Can I use Supabase Auth to protect admin routes?
For the UI, yes. For authorization, the auth check is not enough on its own — Supabase's docs warn that session cookies can be spoofed and that getSession() should not be trusted in server code. Use getClaims() where you need a verified check, and let RLS decide what data comes back.
Why does CREATE POLICY IF NOT EXISTS not work in Postgres?
Because the clause does not exist. CREATE POLICY accepts AS, FOR, TO, USING and WITH CHECK only. Wrap the statement in a do block that checks pg_policies first if you want the migration to be re-runnable.
Should admin pages be server components or client components?
Client components, if the session lives in browser storage — a server render has no session to read. Server components work if you validate the token with getClaims() and make sure the route is never cached.
Do I need middleware to protect the admin?
No, and Next.js now advises against leaning on it: middleware is deprecated in favour of proxy in Next.js 16, and the docs recommend verifying auth inside each server function instead of relying on the proxy layer alone.
Need an admin panel that your own team can actually operate? CMS, CRM, lead inbox and an approval queue, built on the same Next.js and Supabase stack this site runs on. See my services or get in touch.
Get the AI Automation Playbook
The real architecture behind a 6-agent AI content team — what it saves, what it gets wrong, and the propose-then-approve pattern that makes it safe to trust.
Browse all free guides →Want to implement this with guidance?
Santosh helps founders turn insights like this into real systems.
External Resources
Further Reading & Tools
Make.com Blog
No-code automation tutorials, workflow templates, and integration guides
Zapier Learn
Automation best practices, productivity guides, and tool integrations
OpenAI Platform
API documentation for building AI-powered automation workflows
n8n Blog
Open-source workflow automation guides and integration examples