devsnack

Building an Admin Panel for Your Mobile App with Next.js and Supabase

Row-level security, a typed client, and a dashboard you can hand to a non-developer — plus the one key that must never reach the browser.

DevSnack11 Aug 2026 · 15 min

Every app eventually needs a back office: approve this, refund that, feature the other. Building it as a second mobile app is miserable, and handing someone the Supabase table editor is how a non-developer drops a production row.

A small Next.js panel over the same Postgres is the right size for this. The whole job is three decisions, and one of them is a security decision you cannot get wrong.

The one that matters: the service role key

Supabase gives you two keys. The anon key is public and constrained by row-level security. The service_role key bypasses RLS entirely — it is a master key to your database.

In Next.js, any variable prefixed NEXT_PUBLIC_ is inlined into the client bundle. Putting the service role key behind that prefix publishes your database to anyone who opens devtools. It happens regularly, and it is unrecoverable — the key is in every browser that ever loaded the page.

lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

// The "server-only" import above turns an accidental client
// import into a build error rather than a breach. Use it on every
// module that touches this key.
export const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,   // no NEXT_PUBLIC_ prefix
  { auth: { persistSession: false } },
);

Reach for that client only where you genuinely need to bypass policy — a moderation action, a backfill. Everything else should go through the user's own session so RLS still applies.

Policies, not application checks

Write the rules in the database. An if (user.isAdmin) in a route handler protects that one route; a policy protects every path to the data, including the one you add next month.

supabase/migrations/0002_policies.sql
alter table wallpapers enable row level security;

-- The mobile app: published rows only, no auth required.
create policy "public reads published" on wallpapers
  for select
  using (status = 'published');

-- The panel: admins do anything. Membership lives in a table,
-- not in a JWT claim you have to remember to refresh.
create policy "admins do anything" on wallpapers
  for all
  using (
    exists (select 1 from admins where admins.user_id = auth.uid())
  );

Keeping admin membership in a table rather than a JWT claim means revoking access takes effect on the next query. With a claim, a revoked admin keeps their powers until their token expires.

Generate your types, do not write them

npx supabase gen types typescript --project-id "<your-ref>" \
  > lib/database.types.ts

Wire it into the client and a renamed column becomes a failed build instead of a dashboard rendering undefined at 2am. Add the command to package.json and re-run it as part of your migration workflow — generated types that drift are worse than none, because you trust them.

app/(admin)/wallpapers/page.tsx
export default async function WallpapersPage() {
  // A Server Component reads Postgres directly. No API route,
  // no fetch, no loading state for the initial render.
  const { data, error } = await supabaseAdmin
    .from("wallpapers")
    .select("id, title, status, created_at")
    .order("created_at", { ascending: false })
    .limit(50);

  if (error) throw error;

  return <WallpaperTable rows={data} />;
}

Build it for the person who will actually use it

The panel gets handed to someone who is not you. Three things matter far more than the framework:

Destructive actions need friction and a trail. Soft delete with a deleted_at column, and an audit_log row recording who did what. "The wallpaper vanished" is unanswerable without it.

Show status, not booleans. A column reading pending review is usable; a checkbox labelled is_approved is a support ticket.

Paginate from day one. The table has forty rows in development and forty thousand in production, and an unpaginated admin query is how you find out.

Wallo — $11

Ships the whole stack described here — Jetpack Compose client, Supabase backend with policies, and the Next.js admin panel on top.

See the live demo