# Expo Router

> Server loaders, API routes and middleware that verify the caller and run as them.

Source: https://bettersupabase.com/docs/frameworks/expo

```ts title="src/lib/supabase/server.ts"
import { createExpo } from "better-supabase/expo";
import { betterSupabase } from "./index";

export const bs = createExpo(betterSupabase);
```

`createExpo(betterSupabase)` is [`createServer`](/docs/auth/server) 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:

```json title="app.json"
{
  "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`](/docs/frontend/react-native) on the device; add
`expo-notifications` too when you use the [push block](/docs/blocks/push).

## Loaders [#loaders]

`bs.loader(fn, options)` returns a route `loader` that runs `fn` as the
caller, so RLS applies to every read:

```tsx title="src/app/customers/index.tsx"
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 `allow` refuses throws a `StatusError` (401 or 403) that Expo
  Router answers.
* A `DbException` from `.orThrow()` becomes a `StatusError` with 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 `createStaticLoader`
  from `expo-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 [#with-expo-servers-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:

```tsx title="src/app/customers/[id].tsx"
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`:

```ts title="src/lib/supabase/loader.ts"
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 [#api-routes]

`bs.handler(fn, options)` turns a function into an API route handler.
`Result`s become JSON or [Problem Details](/docs/concepts/results), and
`undefined` becomes 204:

```ts title="src/app/api/customers+api.ts"
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`:

```ts title="src/app/api/notes+api.ts"
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 [#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.

```ts title="src/app/+middleware.ts"
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 [#native-screens]

Loaders and middleware run on the server for web requests. iOS and Android
use the [React Native client](/docs/frontend/react-native) and, for offline
reads, a [PowerSync database](/docs/repository/powersync). The
[Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
runs one list definition on both.