Supabase
Supabase clients, middleware, and a Storage image loader for Next.js
@zeno-lib/supabase wraps @supabase/ssr and @supabase/supabase-js, so you don't write the client, cookie, and middleware setup yourself. It targets Next.js 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 or session handling, for backend, API, or script code that doesn'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 set SUPABASE_SECRET_KEY only where the code needs admin access.
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 you must await it. 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 blocks unauthenticated requests to non-public paths. For the default setup, re-export the ready-made middleware from 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
By default, the middleware redirects unauthenticated users to /sign-in and treats /sign-in as public. That matches the /sign-in UI of the @zeno-lib/authentication package, so the two work together with no configuration.
Only /sign-in is public by default, so pass signInPath and publicPaths to createMiddleware or updateSession when your routes differ. For example, add /sign-up and /recover-password so users can reach those flows.
List signInPath itself in publicPaths, or unauthenticated users hit an infinite redirect loop (the middleware sends them to the sign-in page, then redirects that request too). 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 from environment variables at import time, build the loader explicitly with createSupabaseImageLoader({ projectId }) and default-export the result.
The loader returns http(s) sources unchanged and rewrites relative paths 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). The client still works, but it loses the auth state across navigations without an error. 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. - Using
clientwhennext-serverwould do. If there's a signed-in user, use the server client so RLS enforces access for you. Keepclientfor session-less work. ItscreateAnonClientruns as theanonrole, and itscreateAdminClientbypasses RLS. - Leaving
signInPathout ofpublicPaths. The middleware redirects unauthenticated users to the sign-in page, which isn't exempt, so it redirects them again (an infinite loop). The defaults keep the two in sync.