Zeno

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

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

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

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

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 gates unauthenticated traffic. The fastest setup is to re-export the ready-made middleware in 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

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:

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

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). Cookie state diverges and the session silently desyncs across navigations. 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.
  • Reaching for client when next-server would do. If there's a signed-in user, use the server client so RLS enforces access for you. Keep the RLS-bypassing client for genuinely session-less work.
  • Leaving signInPath out of publicPaths. 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.