← All posts
·7 min read·GuardLayer

use server vs server-only: they're opposites

Next.jsServer ActionsSupabaseSecurity

"use server" doesn't mean "server only". It means the reverse: every exported async function in the file becomes callable from the browser through a POST request. To keep a module out of the client, use import "server-only". Put "use server" only on actions you mean to expose, and make each one check who's calling.

The two names look like they belong together, and "use client" makes it worse. If "use client" means "this runs in the browser", "use server" surely means "this runs on the server"? It doesn't, and the gap between those readings is how a Supabase admin helper turns into a public endpoint.

What's the difference between "use server" and server-only?

"use server" opens a door from the client to the server, and server-only locks the client out of a module. The React docs put the first one in a sentence: "'use server' marks server-side functions that can be called from client-side code." The Next.js data security guide says the server-only package works "by causing a build error if the module is imported in the client environment."

"use server"import "server-only"
What it doesTurns exported async functions into Server Functions the client can callFails the build if a client module imports this file
DirectionOpens a path from browser to serverBlocks a path from server code to browser
Runtime effectCreates an RPC endpoint (a POST to your app)None; it's a build-time guard
Use it forServer Actions you deliberately exposeData access, admin clients, anything holding a secret
Needs an auth check?Yes, inside every functionYes, in the functions that touch data; server-only checks nothing at runtime

Neither one replaces the other. People mix them up because both "keep code on the server", but only one of them keeps callers off it.

The mistake: "use server" on a helper file

Here's what the confusion looks like in a real repo. Someone builds a service role client and wants to be sure it never ends up in the bundle, so they reach for the directive that sounds right:

"use server";
// Marked "use server" so this admin client never reaches the browser.

import { createClient } from "@supabase/supabase-js";

const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function deleteAccountData(userId: string) {
  await admin.from("profiles").delete().eq("id", userId);
  await admin.from("documents").delete().eq("owner_id", userId);
}
guardlayer scan · lib/supabase/admin.tsLive engine output
Passed with warnings
92/100 · A
  • Warninglib/supabase/admin.ts:12

    Server Action without auth check

    At the top of every Server Action, resolve and verify the current user (e.g. const { user } = await getUser(); if (!user) throw …) and authorize the specific operation before mutating data. If you do guard it with a custom helper, this warning is a false positive.

The comment is half true, and that's why the mistake survives code review. The key really does stay out of the browser: the client gets a reference to the function, not its source. What leaks is the ability to run it. deleteAccountData is now a Server Action that takes a userId from whoever calls it and deletes that user's rows with a key that bypasses every RLS policy. Once that action is reachable, anyone who can send a POST to your app can delete any account's data.

The Next.js docs describe the exposure plainly:

"By default, when a Server Action is created and exported, it is reachable via a direct POST request, not just through your application's UI. This means, even if a Server Action or utility function is not imported elsewhere in your code, it can still be called externally."

To be fair to the framework: the same page says Next.js encrypts action IDs and strips unused actions from the client bundle during next build. That helps, but it isn't a guarantee you can build on. The moment one client component imports the helper, maybe a "Delete account" button, the action has a live ID. The docs tell you to "treat Server Actions as reachable via direct POST requests and verify authentication and authorization inside each one." That applies to every export in a "use server" file, including the ones you think of as internal helpers.

Why "use server" doesn't need to be there at all

Server Components, route handlers and proxy.ts already run on the server. None of them needs a directive to get there. A plain module with no directive runs wherever it's imported from: on the server when a Server Component imports it, in the browser bundle when a Client Component does. "use server" doesn't change that for helpers. It has exactly one job, which is exposing functions to client calls. So if a file isn't a set of actions you want the browser to invoke, the directive does nothing useful and one dangerous thing.

The risk you were actually worried about, a secret-holding module sneaking into a client bundle, is what server-only is for. Importing a secret-reading module into a Client Component is one of the three common ways Next.js apps leak secrets, and server-only turns that mistake into a build error.

How do I fix it?

Use a server-only data layer with the authorization inside it, plus a thin action on top. That's the pattern the Next.js docs recommend.

// lib/data/account.ts
import "server-only";
import { createClient as createAdmin } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";

export async function deleteOwnAccountData() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error("Unauthorized");

  const admin = createAdmin(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  // The id comes from the verified session, never from the caller.
  await admin.from("profiles").delete().eq("id", user.id);
  await admin.from("documents").delete().eq("owner_id", user.id);
}
// app/settings/actions.ts
"use server";
import { deleteOwnAccountData } from "@/lib/data/account";

export async function deleteAccountAction() {
  await deleteOwnAccountData();
}

Three things changed:

  1. The admin module is server-only. A client import now fails the build instead of shipping quietly.
  2. The action takes no userId. The ID comes from getUser(), which revalidates the session with Supabase Auth. Any ID a caller can pass in, a caller can forge. This is the same auth check every Server Action needs.
  3. Only one file carries "use server", and it contains nothing you'd mind a stranger calling.

If you don't need the service role at all, and for "delete my own rows" you usually don't, drop it and let RLS scope the delete to auth.uid(). The admin client is for operations RLS genuinely can't express. Keeping secrets out of a Next.js app covers where the key itself should live.

A 60-second self-check

# 1. Every file that exposes actions: is each one meant to?
grep -rlE "[\"']use server[\"']" app lib src utils 2>/dev/null

# 2. Red flag: "use server" in a lib/ or utils/ helper
grep -rlE "[\"']use server[\"']" lib utils src/lib 2>/dev/null

# 3. Modules that read secrets without the server-only guard
grep -rlE "SERVICE_ROLE|sb_secret_|SECRET_KEY" lib src/lib 2>/dev/null \
  | xargs grep -L "server-only"

Any file from step 2 should either become a real actions file where every export checks the caller, or have its directive swapped for import "server-only". Any file from step 3 needs the import added.

FAQ

Does "use server" keep my secrets out of the client bundle? The code and the secret stay on the server, yes. But every exported async function becomes something the client can call, so an attacker doesn't need your key. They can call the function that uses it.

Can I use "use server" and server-only in the same file? Yes. The Next.js docs confirm import "server-only" works in a "use server" file too, even when a Client Component imports an action from it (to pass to useActionState, for example). But it doesn't make the actions any less public. They still need their own auth checks.

Do Server Components need "use server"? No. In the App Router, components are Server Components by default. "use server" is for Server Functions (actions), not components.

Doesn't Next.js remove unused Server Actions? It strips unused actions from the client bundle during next build, which shrinks the exposed surface. The docs still tell you to treat every action as reachable by direct POST, and any action a client component imports is live. Don't treat dead-code elimination as access control.

Does server-only stop NEXT_PUBLIC_ variables from leaking? No. A NEXT_PUBLIC_ variable is inlined into the client bundle wherever it's read. server-only only prevents client imports of the module. Keep secrets unprefixed.

Catch this before it ships — free

Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.

No signup, no card. Raw files aren’t kept; only the report is saved.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.