# From plain supabase-js

> Move an app that calls supabase.from() directly to typed repositories, one query at a time.

Source: https://bettersupabase.com/docs/migration/supabase-js

better-supabase wraps the supabase-js client you already have. Nothing about
your database, RLS policies, auth or storage changes, and old
`supabase.from()` calls keep working next to the new repositories, so you
can move one query at a time.

## Set up [#set-up]

### 1. Install and generate [#install-and-generate]

```bash
pnpm add better-supabase
pnpm add -D pg @supabase/postgrest-typegen@0.4.0
pnpm better-supabase init
pnpm better-supabase gen
```

`gen` writes `database.types.ts` with the same output as
`supabase gen types`, so code typed with `createClient<Database>()` keeps
compiling. Point your existing imports at the new file, or keep generating
the old one until you've moved over.

### 2. Wrap the client you already create [#wrap-the-client-you-already-create]

```ts title="src/lib/supabase/index.ts"
import { defineSupabase } from "better-supabase";

import { schema } from "./generated.ts";

export const betterSupabase = defineSupabase(schema);
```

```ts
const supabase = createClient(url, publishableKey); // or createServerClient(...)
const db = betterSupabase.connect(supabase);
```

`betterSupabase.connect()` takes any `SupabaseClient`, including the one from
`@supabase/ssr`. Connect per request with the user's client, as you do
today, so RLS still applies. `db.$client` is the same client, for anything
you haven't moved yet.

### 3. Move queries over [#move-queries-over]

Replace `supabase.from()` calls as you touch them, using the table below.
Once the server side is on repositories, you can replace your hand-written
`@supabase/ssr` setup with an adapter (see [below](#replace-the-ssr-glue)).

## Queries [#queries]

The config's `casing` decides column names. With `casing: 'camel'`,
`organization_id` becomes `organizationId` in `where`, `select` and the rows
you get back. With `casing: 'snake'`, names stay as they are in the database.

| supabase-js                                                             | better-supabase                                                            |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `.from('customers').select('id, name')`                                 | `db.customers.findMany({ select: ['id', 'name'] })`                        |
| `.select('*')`                                                          | `findMany()` (every column)                                                |
| `.select('id, organization(name)')`                                     | `select: ['id'], include: { organization: { select: ['name'] } }`          |
| `.select('*, notes(count)')`                                            | `include: { _count: { notes: true } }`                                     |
| `.eq('status', 'active')`                                               | `where: { status: 'active' }`                                              |
| `.neq`, `.gt`, `.gte`, `.lt`, `.lte`                                    | `where: { total: { gte: 100 } }`                                           |
| `.in('status', ['lead', 'active'])`                                     | `where: { status: { in: ['lead', 'active'] } }`                            |
| `.is('deleted_at', null)`                                               | `where: { deletedAt: null }`                                               |
| `.not('email', 'is', null)`                                             | `where: { email: { not: null } }`                                          |
| `.ilike('name', '%acme%')`                                              | `where: { name: { contains: 'acme' } }` (escapes `%` and `_`)              |
| `.or('status.eq.lead,kvk.eq.1001')`                                     | `where: { OR: [{ status: 'lead' }, { kvk: '1001' }] }`                     |
| `.textSearch('document', 'acme')`                                       | `where: { document: { search: 'acme' } }`                                  |
| `.select('*, notes!inner(*)').eq('notes.kind', 'call')`                 | `where: { notes: { some: { kind: 'call' } } }`                             |
| `.order('name', { ascending: false })`                                  | `orderBy: { name: 'desc' }`                                                |
| `.range(20, 29)`                                                        | `limit: 10, offset: 20`, or [`paginate`](/docs/repository/pagination)      |
| `.eq('id', id).single()`                                                | `findById(id)` (a `not_found` error when missing)                          |
| `.eq('slug', slug).maybeSingle()`                                       | `findUnique({ where: { slug } })` (`null` when missing)                    |
| `.eq('status', 'active').maybeSingle()`                                 | `findOnly({ where: { status: 'active' } })` (`multiple_rows` for several)  |
| `.limit(1).maybeSingle()`                                               | `findFirst({ where })`                                                     |
| `.select('*', { count: 'exact', head: true })`                          | `count({ where })`                                                         |
| `.select('*', { count: 'exact' }).range(20, 29)`                        | `paginate({ offset: 20, limit: 10, count: 'exact' })`                      |
| `.insert(row).select().single()`                                        | `create(row)`                                                              |
| `.insert(rows).select()`                                                | `createMany(rows)`                                                         |
| `.update(patch).eq('id', id).select().single()`                         | `update(id, patch)`                                                        |
| `.update(patch).eq('status', 'lead')`                                   | `updateMany({ where: { status: 'lead' }, data: patch })`                   |
| `.update(patch).eq('id', id).eq('organization', organization).select()` | `update(id, patch, { where: { organization } })`                           |
| `.update(patch).in('id', ids).select()`                                 | `updateMany({ where: { id: { in: ids } }, data: patch, returning: true })` |
| `.upsert(row, { onConflict: 'organization_id,kvk' })`                   | `upsert(row, { onConflict: ['organizationId', 'kvk'] })`                   |
| `.delete().eq('id', id)`                                                | `delete(id)`                                                               |
| `supabase.rpc('archive_customer', args)`                                | `db.$rpc('archive_customer', args)`                                        |

Each call is still one PostgREST request, including relation filters and
counts. Every column, relation and operator is typed, and enum columns only
accept their allowed values. See [Filtering](/docs/repository/filtering) and
[Writing](/docs/repository/writing) for the full list.

## Errors [#errors]

supabase-js returns `{ data, error }` with a `PostgrestError`. Repositories
return a `Result` with the same two fields plus `ok`, and a `DbError` whose
`kind` tells you what happened:

```ts
// Before
const { data, error } = await supabase
  .from("tags")
  .insert({ name })
  .select()
  .single();
if (error?.code === "23505")
  return { field: "name", message: "Tag already exists" };
if (error) throw error;

// After
const result = await db.tags.create({ organizationId, name });
if (result.error?.kind === "conflict")
  return { field: "name", message: "Tag already exists" };
if (!result.ok) return result;
const tag = result.data;
```

Use `.orThrow()` where you used to write `if (error) throw error`. The
[Results](/docs/concepts/results) page lists every kind and its HTTP status.

## Replace the SSR glue [#replace-the-ssr-glue]

If you wrote the `@supabase/ssr` cookie handling yourself, an adapter
replaces it. It verifies the token locally, refreshes it only in the proxy,
and gives every handler a `db` for the current user:

| You have                                                        | Use                                                                       |
| --------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `createServerClient` in a Next.js `middleware.ts` or `proxy.ts` | `bs.proxy(request)` ([Next.js](/docs/frameworks/next))                    |
| `createServerClient` in Server Components and route handlers    | `const { db } = await bs.context()`, `bs.route(...)`                      |
| `createBrowserClient`                                           | `createClient(betterSupabase)` ([Browser client](/docs/frontend/client))  |
| A client per request in Hono, oRPC or an Edge Function          | `bs.middleware()` or `bs.handler()` ([Frameworks](/docs/frameworks/hono)) |

Auth flows (`supabase.auth.signInWithOtp()` and friends), storage and
realtime stay on the supabase-js client: `db.$client` on the server,
`bs.supabase` in the browser.

## What to expect [#what-to-expect]

* **Types come from the schema, not the query string.** A typo in a column
  name is a type error, and the row type follows `select` and `include`.
  Rerun `better-supabase gen` after every migration, and add
  `gen --check` to CI.
* **Database errors don't throw.** Code that relied on try and catch
  around a query needs `.orThrow()`.
* **No `declare module` or `Database` generics.** The `schema` object
  carries the types, so there's nothing to pass to `createClient`.
* **PostgREST's limits still apply.** There are no transactions across
  requests; see [Limitations](/docs/guides/limitations) for what to use
  instead.