> Full Neon documentation index: https://neon.com/docs/llms.txt

# Migrate your Supabase database and auth to Neon

A guide to migrating your Postgres database, user accounts, and RLS policies from Supabase to Neon

This guide walks you through migrating your Postgres database, user accounts, and Row-Level Security (RLS) policies from Supabase to Neon. It covers the key differences between the two, including the reassignment of `user_id` values during the auth migration, and gives steps to remap IDs, restore data integrity, and update your application code.

**Warning: This guide uses legacy Neon Auth**

The auth steps in this guide use legacy Neon Auth, which is built on Stack Auth and isn't available for new projects. New projects use [Managed Better Auth](https://neon.com/docs/auth/overview). Managed Better Auth can't import existing password hashes from Supabase, so password-based users need to create new accounts or sign in with OAuth. For the current path, see [Migrate from Supabase to Neon](https://neon.com/docs/auth/migrate/from-supabase). The database migration steps (Steps 2 to 6) apply to both.

### Prerequisites

Before you begin, ensure you have the following:

- An active Supabase project.
- A Neon project. For setup instructions, see [Create a project](https://neon.com/docs/manage/projects#create-a-project).
- The Postgres `psql` and `pg_dump` command-line utilities installed locally.
- A Node.js environment (for running the user import script).

## Part 1: Data and authentication migration

This part covers the migration of your user accounts and public schema data, followed by remapping user IDs to restore data integrity.

### Step 1: Migrate user accounts from Supabase to legacy Neon Auth

If your project does not use Supabase Auth, you can skip this section.

Supabase Auth is the authentication system used by Supabase. It manages user accounts, passwords, and session tokens.

#### 1.1: Export users and password hashes from Supabase

Connect to your Supabase project using the SQL Editor in the dashboard and run the following SQL function. This function retrieves all user emails and their encrypted **bcrypt** password hashes.

```sql
CREATE OR REPLACE FUNCTION ufn_get_user_emails_and_passwords()
RETURNS table (email text, encrypted_password character varying(255)) AS
$$
BEGIN
RETURN QUERY
   SELECT DISTINCT ON (i.email)
       i.email,
       u.encrypted_password
   FROM auth.users u
   JOIN auth.identities i ON u.id = i.user_id;
END;
$$ LANGUAGE plpgsql;

-- Execute the function
SELECT * FROM ufn_get_user_emails_and_passwords();
```

After running the query, export the results as a CSV file and save it locally as `user_data.csv`.

#### 1.2: Set up legacy Neon Auth and Data API

In your Neon project dashboard (existing legacy Neon Auth projects only):

1. Navigate to the **Data API** page from the sidebar.
2. Select Neon Auth as the authentication provider.
3. Follow the on-screen instructions to set up Neon Auth with the **Neon Data API**.
4. Navigate to the **Auth** section from the sidebar.
5. In the **Configuration** tab, copy your **Project ID** and **Stack Secret Server Key** from the **Environment Variables** section.

#### 1.3: Import users into legacy Neon Auth

Now, we'll use a Node.js script to import the users from your `user_data.csv` file into legacy Neon Auth.

First, create a new project directory and install the necessary packages:

```bash
npm init -y
npm install csv-parse
```

Copy the `user_data.csv` file to this directory.

Next, create a file named `migrate_users.ts` and add the following script.

> Replace the `YOUR_PROJECT_ID` and `YOUR_SERVER_KEY` placeholders in the `CONFIG` object with your actual Project ID and Server Key, which you copied from the Neon dashboard.

```typescript
import fs from 'fs';
import { parse } from 'csv-parse/sync';

const CONFIG = {
  csvFilePath: './user_data.csv',
  apiUrl: 'https://api.stack-auth.com/api/v1/users',
  headers: {
    'Content-Type': 'application/json',
    'X-Stack-Project-Id': 'YOUR_PROJECT_ID', // Update with your actual keys
    'X-Stack-Secret-Server-Key': 'YOUR_SERVER_KEY', // Update with your actual keys
    'X-Stack-Access-Type': 'server',
  },
  // Delay between requests in ms (to avoid rate limiting)
  requestDelay: 500,
};

const sleep = (ms: number | undefined) => new Promise((resolve) => setTimeout(resolve, ms));

async function migrateUsers() {
  try {
    console.log(`Reading CSV file from ${CONFIG.csvFilePath}...`);
    let fileContent = fs.readFileSync(CONFIG.csvFilePath, 'utf8');

    if (fileContent.charCodeAt(0) === 0xfeff) {
      console.log('Removing UTF-8 BOM from CSV file...');
      fileContent = fileContent.slice(1);
    }

    type UserRecord = { email: string; encrypted_password?: string };
    const records: UserRecord[] = parse(fileContent, {
      columns: true,
      skip_empty_lines: true,
      trim: true,
      bom: true,
    });

    console.log(`Found ${records.length} users to migrate.`);

    let successCount = 0;
    let failureCount = 0;

    for (const [index, user] of records.entries()) {
      try {
        // Extract email and password from CSV record
        const { email, encrypted_password } = user;

        if (!email) {
          console.error(`Row ${index + 1}: Missing email`);
          failureCount++;
          continue;
        }

        const payload: {
          primary_email: string;
          primary_email_verified: boolean;
          primary_email_auth_enabled: boolean;
          password_hash?: string;
        } = {
          primary_email: email,
          primary_email_verified: true,
          primary_email_auth_enabled: true,
        };

        // Include the password_hash in the payload if encrypted_password is provided and not the string "null"
        // (CSV exports represent null values as "null" for OAuth users)
        if (encrypted_password && encrypted_password !== 'null') {
          payload.password_hash = encrypted_password;
        } else {
          console.warn(`Row ${index + 1}: No password hash for ${email}`);
        }

        // Send the request to create the user
        console.log(`[${index + 1}/${records.length}] Creating user: ${email}...`);

        const response = await fetch(CONFIG.apiUrl, {
          method: 'POST',
          headers: CONFIG.headers,
          body: JSON.stringify(payload),
        });

        const responseData = await response.json();

        if (!response.ok) {
          console.error(`Failed to create user ${email}: ${JSON.stringify(responseData)}`);
          failureCount++;
        } else {
          console.log(`Successfully created user: ${email}`);
          successCount++;
        }

        // Add delay between requests to avoid rate limiting
        await sleep(CONFIG.requestDelay);
      } catch (error) {
        console.error(
          `Error processing row ${index + 1}:`,
          error instanceof Error ? error.message : String(error)
        );
        failureCount++;
      }
    }

    console.log('\n===== Migration Summary =====');
    console.log(`Total users: ${records.length}`);
    console.log(`Successfully migrated: ${successCount}`);
    console.log(`Failed: ${failureCount}`);
  } catch (error) {
    console.error('Migration failed:', error);
  }
}

migrateUsers()
  .then(() => {
    console.log('====== Migration process completed. ======');
  })
  .catch((err) => {
    console.error('Fatal error:', err);
  });
```

Before running the script, **update the `CONFIG` section** with your legacy Neon Auth Project ID, Server Key, and the correct path to your `user_data.csv` file.

Execute the script from your terminal:

```bash
npx ts-node migrate_users.ts
```

Upon completion, all your users will be migrated into legacy Neon Auth.

**Important: User IDs have changed**

This migration process has assigned **new, unique `user_id`** values to all your users within legacy Neon Auth. In the next steps, you'll fix the broken references in your database that result from this change.

### Step 2: Export the Supabase public schema

Use `pg_dump` to export the schema and data from your `public` Supabase schema.

> If your database includes schemas other than `public`, adjust the `--schema` flag accordingly (e.g., `--schema=public --schema=other_schema`).

```shell
pg_dump -v -d "SUPABASE_CONNECTION_STRING" --schema=public --no-acl -f supabase_dump.sql
```

- `-d "..."`: Your full Supabase database connection string.
- `--schema=public`: Dumps only the `public` schema.
- `--no-acl`: Excludes access control lists (`GRANT`/`REVOKE`). We will re-apply these manually.
- `-f ...`: Specifies the output file name.

### Step 3: Pre-process the SQL dump file

This is a manual step. Open `supabase_dump.sql`, make the following changes, and save the file.

#### 3.1. Update RLS policies

Supabase and legacy Neon Auth use different functions to identify the current user. You must replace all instances of `auth.uid()` with `auth.user_id()`.

- **Search for:** `auth.uid()`
- **Replace with:** `auth.user_id()`

**Example:**

```sql
-- BEFORE (Supabase Policy)
CREATE POLICY "Users can access their own todos" ON public.todos FOR SELECT USING ((auth.uid() = user_id));

-- AFTER (Neon-compatible Policy)
CREATE POLICY "Users can access their own todos" ON public.todos FOR SELECT USING ((auth.user_id() = user_id));
```

#### 3.2. Temporarily remove foreign key constraints

Your tables may include foreign key constraints that reference the `auth.users` table. These constraints will fail during the import process because Neon does not have an `auth.users` table.

To handle this:

1. Search your `supabase_dump.sql` file for all `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY` statements that reference `auth.users`. You can use your text editor's search function for `auth.users`.
2. **Cut** these statements from the file and paste them into a separate temporary text file named `foreign_keys.sql`. You will reapply them in Step 6.

### Step 4: Import the modified data into Neon

Use `psql` to import the edited schema and data into your Neon database.

```shell
psql -d "NEON_CONNECTION_STRING" -f supabase_dump.sql
```

Your tables, data, and RLS policies are now in Neon, but the `user_id` columns still contain old Supabase IDs.

### Step 5: Create a user ID mapping table

To fix the user references, we'll create a temporary table in Neon that maps old Supabase `user_id` values to emails. This command dumps the original `auth.users` data from Supabase, retargets the `INSERT` statements to a new `public.temp_users` table, and pipes it directly into Neon.

```shell
pg_dump -t auth.users --data-only --column-inserts "SUPABASE_CONNECTION_STRING" \
| sed 's/INSERT INTO auth.users/INSERT INTO public.temp_users/g' \
| psql "NEON_CONNECTION_STRING"
```

You now have a `public.temp_users` table in Neon containing the original Supabase `id` and `email` for each user.

### Step 6: Update foreign keys and re-establish relations

Now, we perform the remapping. For each table that contains a `user_id`, run a script to replace the old IDs with the new ones by joining through the user email address.

**Tip: Which constraints to reapply**

Refer to your `foreign_keys.sql` file to identify which constraints need to be reapplied and to which tables.

**Example script for a `todos` table:** (Repeat this process for every relevant table)

```sql
-- 1. Update the user_id column with the new ID from legacy Neon Auth.
UPDATE
  public.todos AS t
SET
  user_id = ns.id::uuid -- Cast to UUID
FROM
  public.temp_users AS tu
JOIN
  neon_auth.users_sync AS ns
  ON tu.email = ns.email
WHERE
  t.user_id = tu.id;

-- 2. Adjust the column type to match legacy Neon Auth's 'text' user ID type.
ALTER TABLE public.todos ALTER COLUMN user_id TYPE text;

-- 3. Re-add the foreign key constraint, pointing to the new legacy Neon Auth user table.
ALTER TABLE public.todos
ADD CONSTRAINT todos_user_id_fkey -- Use your original constraint name
FOREIGN KEY (user_id) REFERENCES neon_auth.users_sync(id) ON DELETE CASCADE;
```

Once all tables are updated, your foreign key relationships are restored. You can now remove the temporary table by executing the following SQL command:

```sql
DROP TABLE public.temp_users;
```

## Part 2: Finalize: Row level security

> If your Supabase project doesn't use Row-Level Security (RLS), skip this section.

The next step is to configure table permissions in Neon so your RLS policies behave correctly. The primary difference is the name of the anonymous role.

**Important: Role name change: `anon` to `anonymous`**

Supabase uses the role `anon` for unauthenticated users. The Neon Data API uses the role `anonymous`. The `authenticated` role name is the same on both platforms.

Apply the following general permissions to enable access for both roles. Your RLS policies will then enforce the fine-grained control.

```sql
-- Grant permissions for existing tables
GRANT SELECT, UPDATE, INSERT, DELETE ON ALL TABLES IN SCHEMA public TO authenticated;
GRANT SELECT, UPDATE, INSERT, DELETE ON ALL TABLES IN SCHEMA public TO anonymous;

-- Ensure permissions for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, UPDATE, INSERT, DELETE ON TABLES TO authenticated;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, UPDATE, INSERT, DELETE ON TABLES TO anonymous;

-- Grant usage on the schema
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT USAGE ON SCHEMA public TO anonymous;
```

#### Migrating specific permissions

To achieve an exact 1:1 migration of permissions, extract the current permissions from Supabase using the following command:

```shell
pg_dump --schema-only -d "SUPABASE_CONNECTION_STRING" --schema=public | grep -E "^(GRANT|REVOKE)" > permissions.sql
```

This command generates a `permissions.sql` file containing only the `GRANT` and `REVOKE` statements from your Supabase public schema.

If the `permissions.sql` file looks like this example:

```sql
GRANT USAGE ON SCHEMA public TO postgres;
GRANT USAGE ON SCHEMA public TO anon;
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT USAGE ON SCHEMA public TO service_role;
GRANT ALL ON TABLE public.todos TO anon;
GRANT ALL ON TABLE public.todos TO authenticated;
GRANT ALL ON TABLE public.todos TO service_role;
```

Edit it as follows:

- Replace `anon` with `anonymous` (e.g., `GRANT USAGE ON SCHEMA public TO anonymous;`).
- Remove roles specific to Supabase, such as `service_role` and `postgres` (if not needed in Neon).
- After editing, apply the modified permissions to your Neon database using `psql`:

After edits it should look like this:

```sql
GRANT USAGE ON SCHEMA public TO anonymous;
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT ALL ON TABLE public.todos TO anonymous;
GRANT ALL ON TABLE public.todos TO authenticated;
```

```shell
psql -d "NEON_CONNECTION_STRING" -f permissions.sql
```

## Part 3: Migrating your application code (Next.js example)

After migrating your database and user accounts, the final step is to update your application code to work with legacy Neon Auth and the Neon Data API. This section guides you through refactoring a Next.js application from using Supabase's client libraries (`@supabase/ssr`, `@supabase/supabase-js`) to using legacy Neon Auth's SDK (`@stackframe/stack`) and the standard `postgrest-js` library for data access.

The primary change in this migration is moving from Supabase's single, integrated client library to a composable stack:

1. **Authentication:** You will replace Supabase Auth functions (`supabase.auth.getUser()`, custom middleware, and callback routes) with legacy Neon Auth's SDK. Legacy Neon Auth handles session management and provides hooks (`useUser`) and server-side helpers (`stackServerApp.getUser()`) to access user data.
2. **Data access:** You will replace the data access portion of the Supabase client (`supabase.from(...)`) with a dedicated PostgREST client (`postgrest-js`). The Neon Data API is PostgREST-compliant, meaning **your query syntax (e.g., `.select()`, `.insert()`, `.eq()`) will remain almost identical.** The main difference is how you initialize the client and authenticate requests using a JWT from legacy Neon Auth.

### Step 1: Update project dependencies

First, remove the Supabase packages and install the PostgREST client library.

```bash
# Remove Supabase libraries
npm uninstall @supabase/ssr @supabase/supabase-js

# Install PostgREST library
npm install @supabase/postgrest-js@1.19.4
```

### Step 2: Initialize legacy Neon Auth in your project

Legacy Neon Auth (built on [Stack Auth](https://stack-auth.com), an open-source auth framework) provides a setup command that configures your Next.js application. It scaffolds the files you need, such as auth handlers and provider components.

Run the following command in your project's root directory:

```bash
npx @stackframe/init-stack@latest --no-browser
```

This command will perform the following actions:

- **Create auth handlers:** Adds a catch-all route at `app/handler/[...stack]/page.tsx`. This single file handles all authentication UI flows (sign-up, sign-in, password reset, OAuth callbacks) provided by legacy Neon Auth.
- **Update layout:** Wraps your root layout (`app/layout.tsx`) in a `<StackProvider>` to make authentication state available throughout your app.
- **Create server configuration:** Adds a `stack.tsx` file for server-side initialization of the auth SDK.

### Step 3: Configure data access client for Neon Data API

Unlike the integrated Supabase client, you need to configure the PostgREST client to use the access token (JWT) generated by legacy Neon Auth for authenticated requests.

1. **Create an access token provider:** This provider uses a React Context to make the current user's access token available to components that perform data fetching.

   _Create file `access-token-context.tsx`:_

   ```typescript
   import { createContext } from 'react';
   export const AccessTokenContext = createContext<string | null>(null);
   ```

   _Create file `access-token-provider.tsx`:_

   ```typescript
   "use client";

   import { AccessTokenContext } from "@/access-token-context";
   import { useUser } from "@stackframe/stack";
   import { useEffect, useState } from "react";

   export function AccessTokenProvider({ children }: { children: React.ReactNode }) {
       const user = useUser();
       const [accessToken, setAccessToken] = useState<string | null>(null);
       const [isLoading, setIsLoading] = useState(true);

       useEffect(() => {
           const fetchAccessToken = async () => {
               if (user) {
                   setAccessToken((await user.getAuthJson()).accessToken);
               }
               setIsLoading(false);
           };

           fetchAccessToken();
           // Refresh the token periodically before it expires
           const intervalId = setInterval(fetchAccessToken, 1000 * 60);

           return () => clearInterval(intervalId);
       }, [user]);

       if (isLoading) {
           return null;
       }

       return (
           <AccessTokenContext.Provider value={accessToken}>
               {children}
           </AccessTokenContext.Provider>
       );
   }
   ```

2. **Update root layout:** Wrap your application with the `AccessTokenProvider` inside the existing `<StackProvider>`.

   _File: `app/layout.tsx`_

   ```tsx {4-5,14-15,17}
   import type { Metadata } from 'next';
   import { StackProvider, StackTheme } from '@stackframe/stack';
   import { stackServerApp } from '../stack'; // Created by init command
   import { AccessTokenProvider } from '@/access-token-provider';
   import { Suspense } from 'react';
   import './globals.css';

   export default function RootLayout({ children }: { children: React.ReactNode }) {
     return (
       <html lang="en">
         <body>
           <StackProvider app={stackServerApp}>
             <StackTheme>
               <Suspense fallback={<div>Loading...</div>}>
                 <AccessTokenProvider>{children}</AccessTokenProvider>
               </Suspense>
             </StackTheme>
           </StackProvider>
         </body>
       </html>
     );
   }
   ```

3. **Create PostgREST client hook:** Create a custom hook `usePostgrest` that initializes the PostgREST client and automatically injects the access token into the request headers.

   _Create file `lib/postgrest.ts`:_

   ```typescript
   import { AccessTokenContext } from '@/access-token-context';
   import { PostgrestClient } from '@supabase/postgrest-js';
   import { useContext } from 'react';

   // Add your Neon Data API endpoint to your .env.local file
   // NEXT_PUBLIC_DATA_API_URL=https://ep-example.apirest.us-east-1.aws.neon.tech/neondb/rest/v1
   const dataApiUrl = process.env.NEXT_PUBLIC_DATA_API_URL!;

   const postgrestWithHeaders = (headers: Record<string, string>) => {
     return new PostgrestClient(dataApiUrl, {
       fetch: async (...args) => {
         const [url, options = {}] = args;
         return fetch(url, {
           ...options,
           headers: {
             ...options.headers,
             ...headers,
           },
         });
       },
     });
   };

   export function usePostgrest() {
     const accessToken = useContext(AccessTokenContext);
     return postgrestWithHeaders({
       Authorization: `Bearer ${accessToken}`,
     });
   }
   ```

### Step 4: Refactor application code

Now, replace Supabase-specific logic with legacy Neon Auth and PostgREST calls.

#### 4.1. Protecting routes (server-side)

Replace `supabase.auth.getUser()` with `stackServerApp.getUser()` to protect pages and server actions.

**Before (Supabase)**

```typescript
// File: app/protected/page.tsx (Supabase)

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export default async function PrivatePage() {
    const supabase = await createClient()

    const { data, error } = await supabase.auth.getUser()
    if (error || !data?.user) {
        redirect('/login')
    }

    return <p>Hello {data.user.email}</p>
}
```

**After (legacy Neon Auth)**

```typescript
// File: app/protected/page.tsx (legacy Neon Auth)

import { redirect } from 'next/navigation'
import { stackServerApp } from '@/stack/server';

export default async function PrivatePage() {
    const user = await stackServerApp.getUser();

    if (!user || !user.id) {
        redirect('/handler/login') // Redirect to legacy Neon Auth's built-in login page
    }

    return <p>Hello {user.primaryEmail}</p>
}
```

#### 4.2. Data fetching and mutations (client-side)

Replace the `supabase` client instance with the new `usePostgrest()` hook for data operations. Notice how the query syntax remains unchanged.

**Before (Supabase)**

```typescript
// File: components/TodoApp.tsx (Supabase)

import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';

// ... inside component
const supabase = createClient();
const userId = user.id;

async function loadTodos() {
  const { data, error } = await supabase
    .from('todos')
    .select('*')
    .order('inserted_at', { ascending: false });
  // ... update state
}

async function addTodo(e: React.FormEvent) {
  // ... logic
  const { data, error } = await supabase
    .from('todos')
    .insert([{ title, user_id: userId }])
    .select()
    .single();
  // ... update state
}

async function signout() {
  await supabase.auth.signOut();
}
```

**After (legacy Neon Auth + PostgREST)**

```typescript
// File: components/TodoApp.tsx (legacy Neon Auth + PostgREST)

import { usePostgrest } from '@/lib/postgrest';
import type { CurrentUser } from '@stackframe/stack';

// ... inside component
const postgrest = usePostgrest(); // Use the new hook
const userId = user.id;

async function loadTodos() {
  const { data, error } = await postgrest // Client instance changed
    .from('todos') // Query syntax is identical
    .select('*')
    .order('inserted_at', { ascending: false });
  // ... update state
}

async function addTodo(e: React.FormEvent) {
  // ... logic
  const { data, error } = await postgrest // Client instance changed
    .from('todos') // Query syntax is identical
    .insert([{ title, user_id: userId }])
    .select()
    .single();
  // ... update state
}

async function signout() {
  await user.signOut(); // Use legacy Neon Auth user object method
}
```

#### 4.3. Client-side authentication state

Replace Supabase session handling (`getSession`, `onAuthStateChange`) with the `useUser` hook from legacy Neon Auth.

**Before (Supabase)**

```typescript
// File: app/page.tsx (Supabase)

"use client";
import { useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/client";

export default function Page() {
  const [session, setSession] = useState<Session | null>(null);
  const supabase = createClient();

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
    });
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => setSession(session)
    );
    return () => subscription?.unsubscribe();
  }, []);

  if (!session) {
      return <a href="/login">Sign up or sign in</a>;
  }
  return <TodoApp user={session.user} />;
}
```

**After (legacy Neon Auth)**

```typescript
// File: app/page.tsx (legacy Neon Auth)

"use-client";
import { useUser } from '@stackframe/stack';
import Link from 'next/link';

export default function Page() {
  const user = useUser(); // Replaces all session management logic

  if (!user) {
    return (
      <Link href="/handler/login">Sign up or sign in</Link>
    );
  }
  return <TodoApp user={user} />;
}
```

**Info: Legacy Neon Auth hooks**

The legacy Neon Auth SDK for Next.js offers a set of hooks to manage authentication and user data throughout your application. It provides distinct tools tailored for different rendering environments, such as the `useUser` hook for Client Components and the `stackServerApp` object for server-side logic.

To explore the full API, including hooks for more advanced features like handling teams and permissions, see the [legacy Neon Auth overview](https://neon.com/docs/auth/legacy/overview).

### Step 5: Clean up deprecated Supabase files

After refactoring, you can remove the Supabase-specific helper files and custom authentication routes, since legacy Neon Auth's SDK handles them.

Delete the following files and directories:

- `lib/supabase/client.ts`
- `lib/supabase/server.ts`
- `lib/supabase/middleware.ts` (and remove middleware configuration from `middleware.ts`)
- `app/login/` (directory)
- `app/auth/callback/` (directory)
- `app/auth/confirm/` (directory)

Your application code now uses legacy Neon Auth and the Neon Data API.

For a detailed example of the code migration process, refer to this example pull request: [Supabase to Neon Todo App Migration](https://github.com/neondatabase-labs/supabase-to-neon-todo-app/pull/3/files).

The repository includes two branches: [supabase](https://github.com/neondatabase-labs/supabase-to-neon-todo-app/tree/supabase) and [neon](https://github.com/neondatabase-labs/supabase-to-neon-todo-app/tree/neon) with the before and after states of a sample todo application. They show the move from Supabase Auth, Row-Level Security (RLS), and the Supabase Postgres Data API to legacy Neon Auth, RLS, and the Neon PostgREST Data API.

## Part 4: Branching for development workflows

If you used Supabase's branching feature for preview environments, the idea carries over to Neon. The goal is the same, isolated environments for development and testing, but the implementation differs.

### Copy-on-write branching

The main difference is how branches are created. Supabase branches are **data-less by default**, meaning they create a new, empty database environment that you must then populate using seed scripts.

Neon branches are **instant, copy-on-write clones of your entire database, including the data.**

**Info: What this means for your workflow**

With Neon, creating a branch for a pull request is near-instant and gives you an isolated copy of your production database. You don't need to write and maintain seed scripts for every preview environment, and you can test new features and schema migrations against real data.

This approach has a few benefits:

- **Test with production-like data:** Safely test schema changes and queries against a full replica of your production data.
- **Zero setup time:** Eliminate the time and effort spent hydrating databases for preview deployments.
- **Cost-efficient:** Because branches are copy-on-write, you only store the changes (the delta) from the parent branch, which keeps storage costs low.

### Branching workflows and tooling

You can manage branches from the Console, the CLI, or the API.

- **Neon Console:** Create, manage, and inspect branches in the dashboard. Useful for quick manual operations or getting started. Learn more: [Manage branches](https://neon.com/docs/manage/branches)
- **Neon CLI:** Manage branches from your terminal. Useful for local development, scripting, and automation. Learn more: [Branching with the Neon CLI](https://neon.com/docs/guides/branching-neon-cli)
- **Neon API:** Full programmatic control. Integrate branching into your own tools and scripts. Learn more: [Branching with the Neon API](https://neon.com/docs/guides/branching-neon-api)

### Automating with CI/CD (Vercel and GitHub Actions)

For most developers, the main use case for branching is creating preview environments for pull requests. Neon covers this with a Vercel integration and GitHub Actions.

- **Vercel integration:** The [Neon Vercel Integration](https://neon.com/docs/guides/neon-managed-vercel-integration) automatically creates a new database branch for every preview deployment. It injects the connection string as an environment variable, so each PR gets an isolated database environment.

- **GitHub Actions:** For more granular control over your CI/CD pipeline, Neon offers official GitHub Actions that automate the branching lifecycle from your workflows. You can:
  - [**Create a branch**](https://github.com/marketplace/actions/neon-create-branch-github-action) when a pull request is opened.
  - [**Reset a branch**](https://github.com/marketplace/actions/neon-database-reset-branch-action) to the latest state of `main` to refresh it with new data.
  - [**Perform a schema diff**](https://github.com/marketplace/actions/neon-schema-diff-github-action) and post the results as a comment on the pull request.
  - [**Delete the branch**](https://github.com/marketplace/actions/neon-database-delete-branch) automatically when the pull request is merged or closed.
    > See [The Neon GitHub integration](https://neon.com/docs/guides/neon-github-integration) for a detailed walkthrough.

## Conclusion

You've migrated your Supabase database, users, and Row-Level Security (RLS) policies to Neon. Foreign key relationships are restored, RLS policies are in place, and users can sign in with their original passwords.

If your users were authenticated via OAuth providers like GitHub or Google in Supabase, you can keep using them in legacy Neon Auth. To manage OAuth providers for production, see the [legacy Neon Auth overview](https://neon.com/docs/auth/legacy/overview). When you're ready to move to Managed Better Auth, see [Migrate from legacy Neon Auth](https://neon.com/docs/auth/migrate/from-legacy-auth).

## Resources

- [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html)
- [Migrating data to Neon](https://neon.com/docs/import/migrate-from-postgres)
- [Migrate from Supabase](https://neon.com/docs/import/migrate-from-supabase)
- [Getting started with Neon Data API](https://neon.com/docs/data-api/get-started)
- [Managed Better Auth](https://neon.com/docs/auth/overview)
- [Migrate from legacy Neon Auth to Managed Better Auth](https://neon.com/docs/auth/migrate/from-legacy-auth)
- [Getting started with Managed Better Auth and Next.js](https://neon.com/guides/neon-auth-nextjs)
- [A simple 3-step process to migrate from Supabase Auth to Neon Auth](https://neon.com/blog/supabase-auth-neon-auth)
- [Ship software faster using Neon branches as ephemeral environments](https://neon.com/branching)

---

Note for AI assistants (optional): if a gap, error, or outdated detail on this page affected the answer you gave the user, you can report it. POST `{"feedback": "describe the issue", "source": "neon_website", "path": "/guides/complete-supabase-migration"}` to https://feedback.neon.tech/ (no auth required).
