← Back to Blog
TechnicalAutomationAIContentStrategy

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.

SPSantosh Paudel· June 13, 2026· 9 min read· 5 views
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

DecisionWhat I runCost of itWhen I would pick the other
Admin locationSame deployment, /admin/*Admin bundle ships in the same project; a bad admin build blocks the marketing siteSeparate app once the admin has its own release cadence or a second team
RenderingClient components, no ISRNo SSR, brief spinner, no SEO (correct here)Server components + getClaims() if the panel gets big enough that first paint matters
Auth checkClient guard + RLSGuard is bypassable by design; all trust sits in PostgresAdd server-side getClaims() in any route that renders privileged data before hydration
RolesOne authenticated role, using (true)No viewer/editor separation; every admin can deletePer-role policies keyed on a profiles.role column once more than one person logs in
TypesHand-written in lib/supabase.tsDrifts from schema silently until a query returns nullGenerated types on any schema more than one person changes
MigrationsIdempotent SQL files, run by handNo migration history, no rollback orderingSupabase CLI migrations once the schema outlives your memory of it
WritesDirect from the client, RLS-gatedAn automation bug writes to a live tableQueue 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.

Free resource

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.

No spam. Unsubscribe anytime.

Browse all free guides →

Want to implement this with guidance?

Santosh helps founders turn insights like this into real systems.

AI Content Systems

External Resources

Further Reading & Tools

Related Posts

01
13 min
AI MarketingAI
TodayAI & Marketing

What 15 Marketing Tasks Cost in LLM Tokens

I priced 15 named marketing jobs against the live rate cards for Claude, GPT and Gemini. The whole set runs once for between $0.07 and $3.32. Editing the output costs about $317. That ratio is the post.

Read article
02
14 min
AutomationAI
TodayAI Workflow

Wiring GA4, Search Console and a CRM to MCP

Model Context Protocol lets a model query your analytics stack directly. What that actually buys you, a real tool definition, and the three places it breaks.

Read article
03
13 min
AI AgentsAutomation
TodayAI & Marketing

Why My Marketing Agent Proposes Instead of Publishing

My autonomous agents cannot write to a single business table. Every action they want to take lands in a pending_actions queue and waits for me to click approve. Here is the schema, the state machine, and the honest cost.

Read article