Fetching latest headlines…
How to Actually Use Supabase RLS With Next.js App Router (Without Losing Your Mind)
NORTH AMERICA
🇺🇸 United StatesJuly 7, 2026

How to Actually Use Supabase RLS With Next.js App Router (Without Losing Your Mind)

3 views0 likes0 comments
Originally published byDev.to

Row Level Security (RLS) is one of Supabase's most powerful features — and one of the most misunderstood. If you've ever written a Supabase policy that works in the dashboard but fails silently in your Next.js app, this post is for you.

I'm building Wishyze, an AI-powered affirmation platform. We run on Next.js 14, Supabase (Auth + Postgres), and have around 28,000 users. RLS is the backbone of our data model — every user's rituals, streaks, and preferences are isolated at the database level. Here's everything I learned the hard way.

Why RLS Matters (Even for Solo Devs)

The pitch is simple: instead of remembering to add userId checks in every API route and every query, you write policies once in Postgres and the database enforces access control automatically.

Sounds great. In practice, there are three things that trip people up:

  1. RLS is disabled by default on new Supabase tables
  2. The anon key bypasses RLS unless you're authenticated
  3. Server-side vs client-side clients behave differently with auth.uid()

Let's fix all three.

Step 1: Enable RLS on Every Table

This is the most common mistake. You create a table, write policies, and nothing happens because RLS was never turned on.

ALTER TABLE rituals ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE streak_data ENABLE ROW LEVEL SECURITY;

In the Supabase dashboard, there's a toggle for this. Use it.

Step 2: Write Policies That Actually Work

Here's a typical pattern for a user's own data:

-- Users can only see their own rituals
CREATE POLICY "Users can view own rituals"
  ON rituals
  FOR SELECT
  USING (auth.uid() = user_id);

-- Users can insert their own rituals
CREATE POLICY "Users can create own rituals"
  ON rituals
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Users can update their own rituals
CREATE POLICY "Users can update own rituals"
  ON rituals
  FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

The key distinction is USING vs WITH CHECK:

  • USING determines which rows you can see (SELECT, UPDATE, DELETE)
  • WITH CHECK determines what values you can write (INSERT, UPDATE)

For most apps where users own their data, USING and WITH CHECK are identical.

Step 3: The Server Client Pattern

Here's where Next.js App Router and Supabase get interesting. You need two different Supabase clients:

// app/lib/supabase/server.ts
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";

export async function createServerClient() {
  const cookieStore = await cookies();

  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Called from a Server Component — ignore
          }
        },
      },
    }
  );
}

This client reads the user's session from cookies. When you call supabase.auth.getUser() through this client, it verifies the JWT against your Supabase project and auth.uid() in your RLS policies resolves correctly.

// app/api/rituals/route.ts
import { createServerClient } from "@/app/lib/supabase/server";
import { NextResponse } from "next/server";

export async function GET() {
  const supabase = await createServerClient();
  const { data: { user }, error } = await supabase.auth.getUser();

  if (error || !user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // RLS handles the rest — only this user's rituals come back
  const { data, error: queryError } = await supabase
    .from("rituals")
    .select("*")
    .order("created_at", { ascending: false });

  if (queryError) {
    return NextResponse.json({ error: queryError.message }, { status: 500 });
  }

  return NextResponse.json({ rituals: data });
}

Notice what we're not doing: there's no WHERE user_id = auth.uid() in the query. The RLS policy handles that automatically. This is the whole point.

Step 4: The Service Role Client (Use Carefully)

Sometimes you need to bypass RLS — admin operations, background jobs, webhooks. That's what the service role key is for:

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

// NEVER expose this key to the client
export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    }
  );
}

Rules I follow:

  • This file lives only in app/lib/supabase/ — never imported by any client component
  • We use it exclusively in app/api/ routes that handle Paddle webhooks or background syncs
  • Every usage has a comment explaining why RLS bypass is needed

Common Gotchas I've Hit in Production

"My query returns everything"

RLS is off. Double-check:

SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = public;

If rowsecurity is f, you need to ENABLE ROW LEVEL SECURITY on that table.

"auth.uid() returns null in my API route"

You're probably using the anon client instead of the server client. The server client reads cookies and resolves the JWT. The anon client doesn't have access to the user's session unless you explicitly pass a Bearer token.

"RLS works in development but breaks in production"

Check your RLS policies use auth.uid(), not auth.jwt()->>sub. The former works with both JWT verification methods. The latter can fail if your Supabase project uses a different JWT secret than expected.

"My Supabase realtime subscription shows no data"

Realtime is also affected by RLS policies. Make sure your SELECT policy allows the authenticated user to see the rows you're trying to subscribe to.

The Policy Patterns We Use in Production

After 28,000 users and a year of iteration, here's what our policy layer looks like:

users table       → Users can only access their own profile
rituals table     → Users can CRUD their own rituals
streak_data table → Users can read/update their own streaks
premium_status    → Service role only (managed by Paddle webhook)

For the premium_status table specifically, we use a policy that only allows the service role:

CREATE POLICY "Service role only for premium status"
  ON premium_status
  FOR ALL
  USING (false);

The service role bypasses RLS entirely, so the webhook handler can insert/update freely. Regular users see nothing. This is cleaner than trying to write permissive policies and accidentally exposing payment data.

Key Takeaways

  1. Enable RLS on every table — don't rely on "I'll remember"
  2. Write INSERT, SELECT, and UPDATE policies separately — the defaults are permissive and confusing
  3. Use the server client pattern — it's the only way auth.uid() works in App Router API routes
  4. Guard the service role key — treat it like a database root password
  5. Test policies with SET ROLE in SQL — simulate different users before deploying

RLS adds a small amount of overhead to each query, but the safety net is worth it. Once your policies are solid, you can refactor your frontend, add new API routes, or even open-source your Supabase project without worrying about data leaks.

If you're building something that needs per-user data isolation, give Supabase RLS a try. It's one of those features that feels like overkill until you need it — and then you can't imagine building without it.

If you want to see how we've put this pattern to use in production, check out wishyze.com.

Comments (0)

Sign in to join the discussion

Be the first to comment!