Expo Router
Server loaders, API routes and middleware that verify the caller and run as them.
import { createExpo } from "better-supabase/expo";
import { betterSupabase } from "./index";
export const bs = createExpo(betterSupabase);createExpo(betterSupabase) is createServer plus the
pieces Expo Router's server output needs. It reads the bearer token first and
the session cookie second, and verifies the JWT locally. Install
expo-server (55 or later) next to better-supabase, and turn on the server
features in app.json. On Expo SDK 57 and earlier, that takes the three
unstable_ options of the expo-router plugin:
{
"expo": {
"web": { "bundler": "metro", "output": "server" },
"plugins": [
[
"expo-router",
{
"unstable_useServerDataLoaders": true,
"unstable_useServerMiddleware": true,
"unstable_useServerRendering": true
}
],
"expo-secure-store"
]
}
}unstable_useServerRendering renders web routes on the server per request,
so a loader sees the caller's cookie; without it, web routes are rendered at
build time and loaders run without a request.
Expo SDK 58 needs no opt-in: with web.output: "server", data loaders,
middleware and server rendering are on, and the three unstable_ options are
deprecated and have no effect. Remove them when you upgrade; the rest of
app.json stays the same. expo-secure-store is the
plugin for secureStorage on the device; add
expo-notifications too when you use the push block.
Loaders
bs.loader(fn, options) returns a route loader that runs fn as the
caller, so RLS applies to every read:
import { useLoaderData } from "expo-router";
import { bs } from "../../lib/supabase/server";
export const loader = bs.loader(
({ db }) => db.customers.findMany({ select: ["id", "name"] }).orThrow(),
{ allow: ["user"] },
);
export default function Customers() {
const customers = useLoaderData<typeof loader>();
// ...
}- A caller
allowrefuses throws aStatusError(401 or 403) that Expo Router answers. - A
DbExceptionfrom.orThrow()becomes aStatusErrorwith the error's status, so a missing row answers 404 with the Problem Details as the body. - During static rendering there is no request, so the loader throws. Read
public data without a caller in a loader built with
createStaticLoaderfromexpo-server. - Loader data is serialized to JSON. Select the columns the screen shows, and convert Temporal values to strings before returning them.
bs.request(request, options) returns the same server context for code that
isn't a loader.
With expo-server's loader helpers
bs.loader returns an expo-server LoaderFunction, so it works anywhere a
loader does. When a route already uses createServerLoader, call
bs.request inside it instead:
import { createServerLoader } from "expo-server";
import { bs } from "../../lib/supabase/server";
export const loader = createServerLoader(async (request, params) => {
const { db } = await bs.request(request, { allow: ["user"] });
const customer = await db.customers
.findById(String(params.id), { select: ["id", "name"] })
.orThrow();
return { customer, country: request.headers.get("cf-ipcountry") };
});bs.request throws the same StatusError for a refused caller. It doesn't
map a DbException from .orThrow(), so a missing row there is a 500 unless
you catch it; use bs.loader when you want that mapping.
To add your own step around every loader, wrap bs.loader in a function that
returns a LoaderFunction:
import type { LoaderFunction } from "expo-server";
import { bs } from "./server";
export function timedLoader<T>(
name: string,
fn: Parameters<typeof bs.loader<T>>[0],
): LoaderFunction<T> {
const loader = bs.loader(fn, { allow: ["user"] });
return async (request, params) => {
const started = performance.now();
try {
return await loader(request, params);
} finally {
console.info(name, Math.round(performance.now() - started), "ms");
}
};
}For public data on routes that are rendered at build time, use
createStaticLoader((params) => ...) from expo-server. It gets no request,
so it can't run as a caller; read only data that every visitor may see.
API routes
bs.handler(fn, options) turns a function into an API route handler.
Results become JSON or Problem Details, and
undefined becomes 204:
import { bs } from "../../lib/supabase/server";
export const GET = bs.handler((_request, { db }) =>
db.customers.findMany({ select: ["id", "name"] }),
);toExpo(entries, handler) runs an @supabase/middleware entry array around
an API route instead. The handler gets the request, the contributions and the
route parameters, and returns a Response:
import { toExpo } from "better-supabase/expo";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "../../lib/supabase/server";
export const GET = toExpo([withBetterSupabase(bs)], async (_request, ctx) =>
Response.json(await ctx.db.notes.findMany().orThrow()),
);Middleware
bs.middleware() in +middleware.ts refreshes an expired cookie session once
per request, before loaders run, and writes the new cookies with
setResponseHeaders. Loaders never refresh on their own.
import { bs } from "../lib/supabase/server";
export default bs.middleware({ redirectTo: "/sign-in", allow: ["user"] });With redirectTo, a caller allow refuses is sent there with the original
path in next. Without it the middleware only refreshes the session. Bearer
tokens are never refreshed.
Native screens
Loaders and middleware run on the server for web requests. iOS and Android use the React Native client and, for offline reads, a PowerSync database. The Expo example runs one list definition on both.
Last updated on