Shared Supabase SSR client factories for Next.js App Router apps. Replaces the
hand-copied lib/supabase/{client,server,middleware}.ts trio that drifted
across ~10 Profullstack apps.
All factories wrap @supabase/ssr using its
recommended getAll/setAll cookie methods, with:
- configurable env var names (default
NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEY), - a hard error naming the missing variable(s) instead of a
!non-null assertion or a silent?? "", - an opt-in
disconnectRealtimeflag for server-side clients (ported from ugig.net's memory-leak fix), - zero hard
nextimports —next/serveris loaded via a literal dynamicimport()only insideupdateSession(), so importing this module never pulls Next.js into non-Next bundles (and bundlers can statically analyze the import).
npm install @profullstack/stack @supabase/ssr @supabase/supabase-js(@supabase/ssr and next are optional peer dependencies of
@profullstack/stack; you only need them for this module. @supabase/ssr
itself peers on @supabase/supabase-js.)
import {
createBrowserSupabase,
createServerSupabase,
updateSession,
} from "@profullstack/stack/supabase";Browser client (replaces each app's lib/supabase/client.ts).
options (SupabaseConfigOptions):
| Option | Type | Default | Notes |
|---|---|---|---|
url |
string |
— | Explicit project URL; wins over env vars. |
anonKey |
string |
— | Explicit anon key; wins over env vars. |
urlEnv |
string |
"NEXT_PUBLIC_SUPABASE_URL" |
Env var to read the URL from. |
anonKeyEnv |
string |
"NEXT_PUBLIC_SUPABASE_ANON_KEY" |
Env var to read the key from. |
Notes:
@supabase/ssr's browser client is already a singleton (isSingleton: true), so no per-app memoization is needed — repeated calls share one client and one WebSocket.- The default env vars are read via static
process.env.NEXT_PUBLIC_*access so Next.js still inlines them into the browser bundle. CustomurlEnv/anonKeyEnvnames are read dynamically and therefore only work server-side (or with your own bundler defines).
// src/lib/supabase/client.ts
import { createBrowserSupabase } from "@profullstack/stack/supabase";
import type { Database } from "@/types/database";
export const createClient = () => createBrowserSupabase<Database>();createServerSupabase<Database = any>(cookieStore, options?) → Promise<SupabaseServerClient<Database>>
Server client bound to the Next.js cookie store (replaces each app's
lib/supabase/server.ts).
cookieStore: the store fromcookies()(next/headers). Pass the promise directly on Next 15+ (createServerSupabase(cookies())) or the awaited / sync store on any version — both are accepted.options: everything fromSupabaseConfigOptions, plus:
| Option | Type | Default | Notes |
|---|---|---|---|
disconnectRealtime |
boolean |
false |
Call client.realtime.disconnect() immediately. Server clients only need Auth/REST; each createServerClient() otherwise allocates a RealtimeClient with WebSocket state, which leaks memory under high request volume. Recommended for middleware/route-heavy apps. |
Cookie semantics (the @supabase/ssr-recommended pattern): getAll delegates
to the store; setAll writes each cookie via store.set(name, value, options)
inside a try/catch — when called from a Server Component the store is
read-only and set() throws, which is safe to ignore as long as middleware
refreshes sessions (see updateSession).
// src/lib/supabase/server.ts
import { cookies } from "next/headers";
import { createServerSupabase } from "@profullstack/stack/supabase";
import type { Database } from "@/types/database";
export const createClient = () => createServerSupabase<Database>(cookies());Next.js middleware helper (replaces each app's lib/supabase/middleware.ts).
Refreshes the auth cookie and returns:
interface SupabaseSessionUpdate {
response: NextResponseLike; // carries any refreshed auth cookies
user: SupabaseUser | null; // null when the request has no session
}request: theNextRequestfrom your middleware (structurally typed — pass it directly).options: same ascreateServerSupabase(url,anonKey,urlEnv,anonKeyEnv,disconnectRealtime).
Cookie semantics (verbatim from the per-app variants, which match the
@supabase/ssr docs): cookies are read from request.cookies; on refresh,
each cookie is mirrored onto request.cookies (so downstream Server
Components see it) and onto a re-created NextResponse.next({ request })
with its full options.
Route protection is intentionally not baked in — every app's variant
differs (protected-path lists, public-route lists, auth-page redirects,
?redirect= sanitization). Use the returned user to apply your own rules:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@profullstack/stack/supabase";
export async function middleware(request: NextRequest) {
const { response, user } = await updateSession(request);
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("redirect", request.nextUrl.pathname.replace(/[<>"']/g, ""));
return NextResponse.redirect(url);
}
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};response is typed structurally (NextResponseLike); at runtime it is a real
NextResponse, so return response; from middleware works as-is. Cast to
NextResponse only if you annotate the middleware's return type.
Resolves { url, anonKey } from explicit options → custom env names → default
env vars. Throws an Error naming the missing variable(s) when resolution
fails. Used internally by all three factories; exported for tests and for apps
that need the raw values (e.g. building a service-role client themselves).
| Type | Meaning |
|---|---|
SupabaseConfigOptions |
{ url?, anonKey?, urlEnv?, anonKeyEnv? } |
CreateServerSupabaseOptions |
SupabaseConfigOptions & { disconnectRealtime? } |
SupabaseConfig |
{ url: string; anonKey: string } |
SupabaseBrowserClient<Database> |
Return type of createBrowserSupabase (the ssr SupabaseClient) |
SupabaseServerClient<Database> |
Return type of createServerSupabase |
SupabaseUser |
The User type from auth.getUser() (no direct @supabase/supabase-js import needed) |
SupabaseCookieStore |
Structural type of the Next.js cookie store |
NextRequestLike / NextResponseLike |
Structural next/server types used by updateSession |
SupabaseSessionUpdate |
{ response, user } returned by updateSession |
Every migration below deletes lib/supabase/client.ts and
lib/supabase/server.ts (or reduces them to two-line re-export shims if you
prefer to keep import paths stable) and rewrites the middleware. Cookie
semantics are unchanged: all variants already used getAll/setAll, and the
try/catch around setAll (Server Component case) is preserved.
Before — client.ts:
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}After:
import { createBrowserSupabase } from "@profullstack/stack/supabase";
import type { Database } from "@/types/database";
export const createClient = () => createBrowserSupabase<Database>();Before — server.ts (had the eager realtime.disconnect() fix). After:
import { cookies } from "next/headers";
import { createServerSupabase } from "@profullstack/stack/supabase";
import type { Database } from "@/types/database";
export const createClient = () =>
createServerSupabase<Database>(cookies(), { disconnectRealtime: true });Before — middleware.ts: updateSession(request) returned a bare
NextResponse and contained the redirect logic. After — keep ugig's
protectedPaths/authPaths logic in src/middleware.ts, but drive it off
the returned user:
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@profullstack/stack/supabase";
export async function middleware(request: NextRequest) {
const { response, user } = await updateSession(request, { disconnectRealtime: true });
const protectedPaths = ["/dashboard", "/profile", "/settings", "/messages"];
if (protectedPaths.some((p) => request.nextUrl.pathname.startsWith(p)) && !user) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("redirect", request.nextUrl.pathname.replace(/[<>"']/g, ""));
return NextResponse.redirect(url);
}
const authPaths = ["/login", "/signup"];
if (authPaths.some((p) => request.nextUrl.pathname === p) && user) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return response;
}Deviation to be aware of: the return type changed from NextResponse to
{ response, user } — update the two call sites (the middleware wrapper and
any test mocks) accordingly.
client.ts already threw on missing env vars — the factory now does that for
you, naming the missing variable:
import { createBrowserSupabase } from "@profullstack/stack/supabase";
export const createClient = () => createBrowserSupabase();server.ts → createServerSupabase(cookies()). Keep createServiceClient
where it is (or move it to a small local util): it is a service-role client
built on @supabase/supabase-js directly, not an SSR cookie client, and is
out of this module's scope.
middleware.ts: keep smshub's PUBLIC_ROUTES / isPublicRoute table in
src/middleware.ts and drive it off { response, user }:
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@profullstack/stack/supabase";
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Skip Supabase entirely for public pages (smshub-specific optimization).
if (isPublicRoute(pathname)) return NextResponse.next({ request });
const { response, user } = await updateSession(request);
const isAuth = pathname.startsWith("/login") || pathname.startsWith("/register");
if (user && isAuth) {
const url = request.nextUrl.clone();
url.pathname = "/inbox";
return NextResponse.redirect(url);
}
if (!user) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return response;
}Deviation: smshub's old updateSession also short-circuited public routes
before refreshing cookies. With the consolidated helper, either keep the
short-circuit in your own middleware as shown above (public pages then never
refresh auth cookies — acceptable for smshub since login/register are the
only auth cookie writers), or drop it and let every request refresh.
Identical shape to ugig minus the realtime fix and with a ?ref= redirect
param. Replace client.ts / server.ts with the two-line factories
(createBrowserSupabase(), createServerSupabase(cookies())), and in
src/middleware.ts:
const { response, user } = await updateSession(request);
const protectedPaths = ["/dashboard", "/settings", "/subscribers"];
if (protectedPaths.some((p) => request.nextUrl.pathname.startsWith(p)) && !user) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("ref", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
return response;Before — client.ts memoized a module-level cached client and fell back to
?? "" on missing env. After:
import { createBrowserSupabase } from "@profullstack/stack/supabase";
export const createClient = () => createBrowserSupabase();Deviations (both deliberate):
- Drop the manual memoization — the ssr browser client is a singleton by
default, so repeated
createClient()calls already share one client and one WebSocket. - Missing env now throws instead of constructing a client with empty strings (which failed later with confusing network errors).
Before — server.ts read env.supabaseUrl / env.supabaseAnonKey from
@/lib/env. After — pass them explicitly:
import { cookies } from "next/headers";
import { createServerSupabase } from "@profullstack/stack/supabase";
import { env } from "@/lib/env";
export const createClient = () =>
createServerSupabase(cookies(), { url: env.supabaseUrl, anonKey: env.supabaseAnonKey });Not migrated. bl0ggers' server.ts is a service-role client built on
@supabase/supabase-js (SUPABASE_SERVICE_ROLE_KEY, persistSession: false,
graceful null on missing env) — not an @supabase/ssr cookie client. Keep
getServerSupabase() as-is. (If bl0ggers later adds a per-request SSR
client, use createServerSupabase(cookies()).)
- All read variants already used the recommended
getAll/setAllcookie methods; the deprecatedget/set/removecookie methods are not supported by this module. If an older app still uses them, rewrite to the factories above rather than porting the deprecated shape. - Env validation is now a thrown
Errornaming the missing variable(s) (smshub/giv1 behavior) — replaces ugig's!assertions and crawlproof's?? "". disconnectRealtime(ugig's always-on fix) is opt-in to keep the default client fully featured; enable it for middleware and high-traffic server usage.updateSessionreturns{ response, user }and performs no redirects; per-app route tables stay in each app'smiddleware.ts.