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.
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.
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.
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.tsWire 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.
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.