Supabase
Framework-aware Supabase clients, middleware, and Storage image loader
@zeno-lib/supabase handles the boilerplate of wiring Supabase into different frameworks. The Next.js framework is the main focus for now.
Installation
Install @zeno-lib/supabase alongside its Supabase peer dependencies. next (>= 16) is an optional peer.
npm install @zeno-lib/supabase @supabase/ssr @supabase/supabase-jsPlain client
@zeno-lib/supabase/client provides plain @supabase/supabase-js clients with no cookie/session wiring, for backend, API, or script contexts that don't need the user's session. Three factories cover the common cases:
createAnonClientuses the publishable (anon) key by default, so it honors Row Level Security.createAdminClientuses the secret (service-role) key by default, so it bypasses Row Level Security.createClienttakes an explicit URL and key, with no environment fallback.
import { createAdminClient, createAnonClient } from "@zeno-lib/supabase/client"
import type { Database } from "./database.types" // schema types generated by the supabase CLI.
const anon = createAnonClient<Database>()
const admin = createAdminClient<Database>()All three take (supabaseUrl?, supabaseKey?, options?), return a session-less client (auth.persistSession: false), and forward options to the supabase-js client. The URL resolves from SUPABASE_URL, then NEXT_PUBLIC_SUPABASE_URL (createClient requires it explicitly). They differ in the key:
| Factory | Key |
|---|---|
createAnonClient() | SUPABASE_PUBLISHABLE_KEY, then NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY |
createAdminClient() | SUPABASE_SECRET_KEY |
createClient(url, key) | passed explicitly |
Security
createAdminClient uses the secret (service-role) key, so it bypasses Row Level Security and can read or write any row. Keep it server-side, and scope SUPABASE_SECRET_KEY to where admin access is intended.
Next.js browser client
@zeno-lib/supabase/next-client returns a standard @supabase/ssr browser client. Use it from Client Components and other browser-side code. Queries run as the signed-in user, so RLS applies:
"use client"
import { createClient } from "@zeno-lib/supabase/next-client"
import type { Database } from "./database.types" // schema types generated by the supabase CLI.
export function TodoItem({ id, title, completed }: Todo) {
const supabase = createClient<Database>() // the Database generic is optional
async function toggle() {
// RLS scopes this update to the current user's own row
await supabase.from("todos").update({ completed: !completed }).eq("id", id)
}
return (
<label>
<input checked={completed} onChange={toggle} type="checkbox" />
{title}
</label>
)
}createClient arguments:
| Argument | Type | What it does |
|---|---|---|
supabaseUrl? | string | Supabase project URL. Defaults to NEXT_PUBLIC_SUPABASE_URL. |
supabaseKey? | string | Supabase publishable key. Defaults to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. |
options? | SupabaseClientOptions | Forwarded to @supabase/ssr's createBrowserClient. |
Next.js server client
@zeno-lib/supabase/next-server returns a standard @supabase/ssr server client. It reads cookies through next/headers, so its createClient is async and must be awaited. Use it from Server Components, Route Handlers, and Server Actions. Queries run as the signed-in user, so RLS applies.
Server Component. Fetch the user's data during render:
import { createClient } from "@zeno-lib/supabase/next-server"
import type { Database } from "./database.types" // schema types generated by the supabase CLI.
export default async function TodosPage() {
const supabase = await createClient<Database>()
const { data: todos } = await supabase
.from("todos")
.select("*")
.order("created_at")
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}Server Action. Mutate, then revalidate:
"use server"
import { revalidatePath } from "next/cache"
import { createClient } from "@zeno-lib/supabase/next-server"
import type { Database } from "./database.types" // schema types generated by the supabase CLI.
export async function createTodo(title: string) {
const supabase = await createClient<Database>()
await supabase.from("todos").insert({ title })
revalidatePath("/todos")
}Route Handler. The same client works in route.ts:
import { createClient } from "@zeno-lib/supabase/next-server"
import type { Database } from "./database.types" // schema types generated by the supabase CLI.
export async function GET() {
const supabase = await createClient<Database>()
const { data } = await supabase.from("todos").select("*")
return Response.json(data)
}createClient arguments:
| Argument | Type | What it does |
|---|---|---|
supabaseUrl? | string | Supabase project URL. Defaults to NEXT_PUBLIC_SUPABASE_URL. |
supabaseKey? | string | Supabase publishable key. Defaults to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. |
options? | SupabaseClientOptions | Forwarded to @supabase/ssr's createServerClient; cookie wiring defaults to next/headers. |
Next.js middleware
The middleware refreshes the auth session on every request and gates unauthenticated traffic. The fastest setup is to re-export the ready-made middleware in a middleware.ts file:
export { middleware, config } from "@zeno-lib/supabase/next-middleware"The bundled config.matcher already skips static assets and image files.
To change the redirect target or the public (auth-exempt) routes, build the middleware with createMiddleware. Next requires config.matcher to be statically analyzable, so keep a static config export alongside it (re-export the default or declare your own):
import { createMiddleware, config } from "@zeno-lib/supabase/next-middleware"
export const middleware = createMiddleware({
// /sign-in is already the default; add the other public routes
publicPaths: ["/sign-in", "/sign-up", "/auth", "/recover-password"],
})
export { config }For full control, call updateSession(request, options?) inside your own middleware:
import { type NextRequest } from "next/server"
import { updateSession } from "@zeno-lib/supabase/next-middleware"
export async function middleware(request: NextRequest) {
// ...your logic that does not touch the Supabase response...
return await updateSession(request)
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}Both createMiddleware(options) and updateSession(request, options) accept these options:
| Option | Type | What it does |
|---|---|---|
signInPath? | string | Where to redirect unauthenticated requests. Defaults to /sign-in. |
publicPaths? | string[] | Path prefixes that skip the auth check. Defaults to ["/sign-in"]. |
supabaseUrl? | string | Supabase project URL. Defaults to NEXT_PUBLIC_SUPABASE_URL. |
supabaseKey? | string | Supabase publishable key. Defaults to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. |
Redirect defaults
The middleware defaults to redirecting unauthenticated users to /sign-in and treating /sign-in as public, matching the @zeno-lib/authentication package's /sign-in UI, so the two work together with no configuration.
Since only /sign-in is public by default, pass signInPath and publicPaths to createMiddleware / updateSession (above) when your routes differ, e.g. add /sign-up and /recover-password so those flows stay reachable.
signInPath must itself be listed in publicPaths, or unauthenticated users hit an infinite redirect loop (they get sent to the sign-in page, which then redirects again). The defaults keep this in sync.
Next.js Storage image loader
@zeno-lib/supabase/next-image-loader builds Supabase Storage transformation URLs for next/image. Next's images.loaderFile must point at a project-relative file with a default export, and supabaseImageLoader is a named export:
export { supabaseImageLoader as default } from "@zeno-lib/supabase/next-image-loader"export default {
images: { loader: "custom", loaderFile: "./image-loader.ts" },
}When the project id isn't available via environment variables at import time, build the loader explicitly with createSupabaseImageLoader({ projectId }) and default-export the result.
http(s) sources pass through untouched; relative paths are rewritten to https://<projectId>.supabase.co/storage/v1/object/public/<src>?width=<w>&quality=<q|75>.
createSupabaseImageLoader(options) accepts:
| Option | Type | What it does |
|---|---|---|
projectId? | string | Supabase Storage project id. Defaults to NEXT_PUBLIC_SUPABASE_STORAGE_PROJECT_ID, then NEXT_PUBLIC_SUPABASE_PROJECT_ID. |
defaultQuality? | number | Quality used when next/image doesn't request one. Defaults to 75. |
Common mistakes
- Using
next-clienton the server (ornext-serverin a Client Component). Cookie state diverges and the session silently desyncs across navigations. Match the client to where the code runs. - Forgetting
awaitonnext-server'screateClient. It's async; withoutawaitevery method call resolves toundefinedat runtime, even though it type-checks. - Reaching for
clientwhennext-serverwould do. If there's a signed-in user, use the server client so RLS enforces access for you. Keep the RLS-bypassingclientfor genuinely session-less work. - Leaving
signInPathout ofpublicPaths. Unauthenticated users get redirected to the sign-in page, which isn't exempt, so they're redirected again (an infinite loop). The defaults keep the two in sync.