Back to Blog
Development

Supabase and Next.js Server Actions: The Pattern That Avoids Redundant Fetches

Codoric Team

Codoric Team

2026-07-134 min read
Supabase and Next.js Server Actions: The Pattern That Avoids Redundant Fetches

Supabase and Next.js Server Actions: The Pattern That Avoids Redundant Fetches

Supabase and Next.js App Router are a common pairing, and a lot of the example code circulating for it predates Server Actions or bolts them on awkwardly. The result is a pattern that works, but does more round trips and more redundant client-side fetching than it needs to. Here's the version that avoids that.

The mistake this pattern avoids

The common (and understandable) default: fetch data server-side in a Server Component for the initial render, then also fetch it again client-side after a mutation to refresh the UI — often via a client-side Supabase call inside a useEffect or a manual refetch function. That's two different code paths reading the same data, two different places auth/RLS context has to be threaded through correctly, and a UI that's slightly slower than it needs to be because the client waits on its own round trip after the server already did the mutation.

The fix: mutations happen in a Server Action, and revalidatePath (or revalidateTag) tells Next.js to re-render the Server Component with fresh data — no client-side refetch call needed at all.

Step 1: one Supabase client helper, used consistently

The most common setup mistake is creating the Supabase client inconsistently across Server Components, Server Actions, and Route Handlers — each with slightly different cookie handling, which causes auth to silently fail in one context while working in another. Use @supabase/ssr and a single shared helper:

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export function createClient() {
  const cookieStore = 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)
          );
        },
      },
    }
  );
}

This same helper is used in Server Components, Server Actions, and Route Handlers — one place that handles the cookie-based session correctly, instead of three slightly different implementations drifting apart over time.

Step 2: read data in the Server Component

// app/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { NewProjectForm } from './new-project-form';

export default async function ProjectsPage() {
  const supabase = createClient();
  const { data: projects } = await supabase.from('projects').select('*');

  return (
    <div>
      <ul>
        {projects?.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
      <NewProjectForm />
    </div>
  );
}

This runs on the server, using the same RLS-scoped client the Server Action will use — no separate client-side fetch needed for the initial render.

Step 3: mutate through a Server Action, then revalidate

// app/projects/actions.ts
'use server';

import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';

export async function createProject(formData: FormData) {
  const supabase = createClient();
  const name = formData.get('name') as string;

  const { error } = await supabase.from('projects').insert({ name });

  if (error) {
    return { error: error.message };
  }

  revalidatePath('/projects');
}
// app/projects/new-project-form.tsx
'use client';

import { createProject } from './actions';

export function NewProjectForm() {
  return (
    <form action={createProject}>
      <input name="name" required />
      <button type="submit">Create</button>
    </form>
  );
}

revalidatePath('/projects') tells Next.js to re-render ProjectsPage on the server, which re-runs the select('*') query and sends fresh HTML/RSC payload down. The client never makes its own Supabase call to get the updated list — it just receives the re-rendered result of the Server Action's own request-response cycle.

Why this avoids the redundant-fetch problem

In the client-side-refetch version, a mutation triggers: (1) the mutation request, (2) a separate client-side read request to refresh the list, and (3) React state updates to reflect it — three things to keep in sync, and a brief window where the UI shows stale data until step 2 resolves. In the Server Action + revalidatePath version, the mutation and the refreshed read happen as part of the same server-side request cycle that Next.js already orchestrates — there's no second network round trip initiated from the client at all.

Where client-side Supabase calls still make sense

This pattern is for anything reachable through a normal navigation/mutation cycle — CRUD forms, most dashboard interactions. It is not a replacement for Supabase Realtime subscriptions, which are inherently a client-side, persistent-connection concern (a Server Component can't hold a websocket open). Use Server Actions for request-triggered mutations, and keep client-side Supabase usage scoped specifically to realtime subscriptions and genuinely interactive, connection-based features.

A note on optimistic updates

For interactions where waiting on the server round trip feels sluggish (a checkbox toggle, a like button), pair the Server Action with useOptimistic rather than reaching back for a client-side Supabase call — it keeps the single-source-of-truth property of this pattern intact while still giving instant visual feedback.

If your Next.js + Supabase app has grown a tangle of client-side refetch logic that this pattern would simplify, that's a common and fairly quick refactor — book a call if you want help untangling it.