ZenoZENO

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-js

Plain 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:

  • createAnonClient uses the publishable (anon) key by default, so it honors Row Level Security.
  • createAdminClient uses the secret (service-role) key by default, so it bypasses Row Level Security.
  • createClient takes an explicit URL and key, with no environment fallback.
lib/client.ts
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:

FactoryKey
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:

todo-item.tsx
"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:

ArgumentTypeWhat it does
supabaseUrl?stringSupabase project URL. Defaults to NEXT_PUBLIC_SUPABASE_URL.
supabaseKey?stringSupabase publishable key. Defaults to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.
options?SupabaseClientOptionsForwarded 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:

app/todos/page.tsx
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:

app/todos/actions.ts
"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:

app/api/todos/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:

ArgumentTypeWhat it does
supabaseUrl?stringSupabase project URL. Defaults to NEXT_PUBLIC_SUPABASE_URL.
supabaseKey?stringSupabase publishable key. Defaults to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.
options?SupabaseClientOptionsForwarded 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:

middleware.ts
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):

middleware.ts
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:

middleware.ts
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:

OptionTypeWhat it does
signInPath?stringWhere to redirect unauthenticated requests. Defaults to /sign-in.
publicPaths?string[]Path prefixes that skip the auth check. Defaults to ["/sign-in"].
supabaseUrl?stringSupabase project URL. Defaults to NEXT_PUBLIC_SUPABASE_URL.
supabaseKey?stringSupabase 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:

image-loader.ts
export { supabaseImageLoader as default } from "@zeno-lib/supabase/next-image-loader"
next.config.mjs
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:

OptionTypeWhat it does
projectId?stringSupabase Storage project id. Defaults to NEXT_PUBLIC_SUPABASE_STORAGE_PROJECT_ID, then NEXT_PUBLIC_SUPABASE_PROJECT_ID.
defaultQuality?numberQuality used when next/image doesn't request one. Defaults to 75.

Common mistakes

  • Using next-client on the server (or next-server in 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 await on next-server's createClient. It's async; without await every method call resolves to undefined at runtime, even though it type-checks.
  • Using client when next-server would do. If there's a signed-in user, use the server client so RLS enforces access for you. Keep client for session-less work. Its createAnonClient runs as the anon role, and its createAdminClient bypasses RLS.
  • Leaving signInPath out of publicPaths. 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.