EngineeringMay 15, 20269 min read
After building 10+ production apps with Next.js and Supabase, we've settled on an opinionated setup that handles auth, RLS, real-time, and admin panels. Here's the full setup.
Project Structure
src/
app/
(auth)/ # Auth pages (login, register)
(dashboard)/ # Protected dashboard routes
admin/ # Admin panel (separate layout)
api/ # API routes
lib/
supabase/
client.ts # Browser client
server.ts # Server client (for RSC)
middleware.ts # Auth middleware
db/
schema.ts # Zod schemas matching DB
components/
ui/ # Reusable componentsSupabase Client Setup
// lib/supabase/server.ts — for Server Components and API routes
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}Row Level Security Patterns
RLS is the killer feature. Data security is enforced at the database level — even if your application code has a bug, users can only ever see their own data.
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only see their own posts
CREATE POLICY "users_own_posts" ON posts
FOR ALL USING (auth.uid() = user_id);
-- Published posts are visible to everyone
CREATE POLICY "published_posts_public" ON posts
FOR SELECT USING (status = 'published');
-- Admin role can see everything
CREATE POLICY "admin_all_access" ON posts
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid() AND role = 'admin'
)
);Admin Panel Pattern
We build a custom admin panel using the Supabase service role key (server-side only), which bypasses RLS. Never expose this key to the browser.
// app/admin/layout.tsx — server component
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function AdminLayout({ children }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
if (profile?.role !== "admin") redirect("/dashboard");
return <div>{children}</div>;
}Create a separate Supabase client using SUPABASE_SERVICE_ROLE_KEY for admin operations. This key bypasses RLS and should NEVER be sent to the browser.
Next.jsSupabasePostgreSQLTypeScript

