Supabase Auth with Remix

This submodule provides convenience helpers for implementing user authentication in Remix applications.

Install the Remix helper library#

1npm install @supabase/auth-helpers-remix

This library supports the following tooling versions:

  • Remix: >=1.7.2

Set up environment variables#

Retrieve your project URL and anon key in your project's API settings in the Dashboard to set up the following environment variables. For local development you can set them in a .env file. See an example.

.env
SUPABASE_URL=YOUR_SUPABASE_URL
SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

Loader#

Loader functions run on the server immediately before the component is rendered. They respond to all GET requests on a route. You can create an authenticated Supabase client by calling the createServerClient function and passing it your SUPABASE_URL, SUPABASE_ANON_KEY, and a Request and Response.

1import { json } from '@remix-run/node' // change this import to whatever runtime you are using
2import { createServerClient } from '@supabase/auth-helpers-remix'
3
4export const loader = async ({ request }) => {
5  const response = new Response()
6  // an empty response is required for the auth helpers
7  // to set cookies to manage auth
8
9  const supabaseClient = createServerClient(
10    process.env.SUPABASE_URL,
11    process.env.SUPABASE_ANON_KEY,
12    { request, response }
13  )
14
15  const { data } = await supabaseClient.from('test').select('*')
16
17  // in order for the set-cookie header to be set,
18  // headers must be returned as part of the loader response
19  return json(
20    { data },
21    {
22      headers: response.headers,
23    }
24  )
25}

Supabase will set cookie headers to manage the user's auth session, therefore, the response.headers must be returned from the Loader function.

Action#

Action functions run on the server and respond to HTTP requests to a route, other than GET - POST, PUT, PATCH, DELETE etc. You can create an authenticated Supabase client by calling the createServerClient function and passing it your SUPABASE_URL, SUPABASE_ANON_KEY, and a Request and Response.

1import { json } from '@remix-run/node' // change this import to whatever runtime you are using
2import { createServerClient } from '@supabase/auth-helpers-remix'
3
4export const action = async ({ request }) => {
5  const response = new Response()
6
7  const supabaseClient = createServerClient(
8    process.env.SUPABASE_URL,
9    process.env.SUPABASE_ANON_KEY,
10    { request, response }
11  )
12
13  const { data } = await supabaseClient.from('test').select('*')
14
15  return json(
16    { data },
17    {
18      headers: response.headers,
19    }
20  )
21}

Supabase will set cookie headers to manage the user's auth session, therefore, the response.headers must be returned from the Action function.

Session and User#

You can determine if a user is authenticated by checking their session using the getSession function.

1const {
2  data: { session },
3} = await supabaseClient.auth.getSession()

The session contains a user property.

1const user = session?.user

This is the recommended way for accessing the logged in user. There is also a getUser() function but this does not refresh the session if it has expired.

Client-side#

In order to use the Supabase client in the browser - fetching data in useEffect or subscribing to realtime events - we need to do a little more plumbing. Remix does not include a way to make environment variables available to the browser, so we need to pipe them through from a loader function in our root.jsx route and attach them to the window.

app/root.jsx
1export const loader = () => {
2  const { SUPABASE_URL, SUPABASE_ANON_KEY } = process.env
3  return json({
4    env: {
5      SUPABASE_URL,
6      SUPABASE_ANON_KEY,
7    },
8  })
9}

These may not be stored in process.env for environments other than Node.

Next, we call the useLoaderData hook in our component to get the env object.

app/root.jsx
1const { env } = useLoaderData()

And then, add a <script> tag to attach these environment variables to the window. This should be placed immediately before the <Scripts /> component in app/root.jsx

app/root.jsx
1<script
2  dangerouslySetInnerHTML={{
3    __html: `window.env = ${JSON.stringify(env)}`,
4  }}
5/>

Full example for Node:

app/root.jsx
1import { json } from '@remix-run/node' // change this import to whatever runtime you are using
2import {
3  Form,
4  Links,
5  LiveReload,
6  Meta,
7  Outlet,
8  Scripts,
9  ScrollRestoration,
10  useLoaderData,
11} from '@remix-run/react'
12import { createBrowserClient, createServerClient } from '@supabase/auth-helpers-remix'
13
14export const meta = () => ({
15  charset: 'utf-8',
16  title: 'New Remix App',
17  viewport: 'width=device-width,initial-scale=1',
18})
19
20export const loader = () => {
21  const { SUPABASE_URL, SUPABASE_ANON_KEY } = process.env
22  return json({
23    env: {
24      SUPABASE_URL,
25      SUPABASE_ANON_KEY,
26    },
27  })
28}
29
30export default function App() {
31  const { env } = useLoaderData()
32
33  return (
34    <html lang="en">
35      <head>
36        <Meta />
37        <Links />
38      </head>
39      <body>
40        <Outlet />
41        <ScrollRestoration />
42        <script
43          dangerouslySetInnerHTML={{
44            __html: `window.env = ${JSON.stringify(env)}`,
45          }}
46        />
47        <Scripts />
48        <LiveReload />
49      </body>
50    </html>
51  )
52}

Now we can call createBrowserClient in our components to fetch data client-side, or subscribe to realtime events - changes in the database.

Authentication#

Now that authentication is based on cookies, users can sign in and out server-side with actions.

Given this Remix <Form /> component.

1<Form method="post">
2  <input type="text" name="email" />
3  <input type="password" name="password" />
4  <button type="submit">Go!</button>
5</Form>

Signing Up#

Any of the supported authentication strategies from supabase-js will work server-side. This is how you would handle simple email and password auth.

1export const action = async ({ request }) => {
2  const { email, password } = Object.fromEntries(await request.formData())
3  const response = new Response()
4
5  const supabaseClient = createServerClient(
6    process.env.SUPABASE_URL,
7    process.env.SUPABASE_ANON_KEY,
8    { request, response }
9  )
10
11  const { data, error } = await supabaseClient.auth.signUp({
12    email,
13    password,
14  })
15
16  // in order for the set-cookie header to be set,
17  // headers must be returned as part of the loader response
18  return json(
19    { data, error },
20    {
21      headers: response.headers,
22    }
23  )
24}

Login#

Any of the supported authentication strategies from supabase-js will work server-side. This is how you would handle simple email and password auth.

1export const action = async ({ request }) => {
2  const { email, password } = Object.fromEntries(await request.formData())
3  const response = new Response()
4
5  const supabaseClient = createServerClient(
6    process.env.SUPABASE_URL,
7    process.env.SUPABASE_ANON_KEY,
8    { request, response }
9  )
10
11  const { data, error } = await supabaseClient.auth.signInWithPassword({
12    email: String(loginEmail),
13    password: String(loginPassword),
14  })
15
16  // in order for the set-cookie header to be set,
17  // headers must be returned as part of the loader response
18  return json(
19    { data, error },
20    {
21      headers: response.headers,
22    }
23  )
24}

Logout#

1export const action = async ({ request }) => {
2  const { email, password } = Object.fromEntries(await request.formData())
3  const response = new Response()
4
5  const supabaseClient = createServerClient(
6    process.env.SUPABASE_URL,
7    process.env.SUPABASE_ANON_KEY,
8    { request, response }
9  )
10
11  const { error } = await supabaseClient.auth.signOut()
12
13  // in order for the set-cookie header to be set,
14  // headers must be returned as part of the loader response
15  return json(
16    { error },
17    {
18      headers: response.headers,
19    }
20  )
21}

Subscribe to realtime events#

1import { createBrowserClient } from '@supabase/auth-helpers-remix'
2import { useState, useEffect } from 'react'
3
4export default function SubscribeToRealtime() {
5  const [data, setData] = useState([])
6
7  useEffect(() => {
8    const supabaseClient = createBrowserClient(
9      window.env.SUPABASE_URL,
10      window.env.SUPABASE_ANON_KEY
11    )
12    const channel = supabaseClient
13      .channel('test')
14      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'test' }, (payload) => {
15        setData((data) => [...data, payload.new])
16      })
17      .subscribe()
18
19    return () => {
20      supabaseClient.removeChannel(channel)
21    }
22  }, [session])
23
24  return <pre>{JSON.stringify({ data }, null, 2)}</pre>
25}

Note: window.env is not automatically populated by Remix. Check out the "Client-side" instructions above to configure this.

In this example we are listening to INSERT events on the test table. Anytime new rows are added to Supabase's test table, our UI will automatically update new data.

Merge server and client state on realtime events#

1import { json, LoaderFunction } from '@remix-run/node';
2import { useLoaderData, useNavigate } from '@remix-run/react';
3import {
4  createServerClient,
5  createBrowserClient
6} from '@supabase/auth-helpers-remix';
7import { useEffect } from 'react';
8import { Database } from '../../db_types';
9
10// this route demonstrates how to subscribe to realtime updates
11// and synchronize data between server and client
12export const loader: LoaderFunction = async ({
13  request
14}: {
15  request: Request;
16}) => {
17  const response = new Response();
18  const supabaseClient = createServerClient<Database>(
19    process.env.SUPABASE_URL!,
20    process.env.SUPABASE_ANON_KEY!,
21    { request, response }
22  );
23
24  const {
25    data: { session }
26  } = await supabaseClient.auth.getSession();
27
28  const { data, error } = await supabaseClient.from('test').select('*');
29
30  if (error) {
31    throw error;
32  }
33
34  // in order for the set-cookie header to be set,
35  // headers must be returned as part of the loader response
36  return json(
37    { data, session },
38    {
39      headers: response.headers
40    }
41  );
42};
43
44export default function SubscribeToRealtime() {
45  const { data, session } = useLoaderData();
46  const navigate = useNavigate();
47
48  useEffect(() => {
49    // Note: window.env is not automatically populated by Remix
50    // Check out the [example in this repo](../root.tsx) or
51    // [Remix docs](https://remix.run/docs/en/v1/guides/envvars#browser-environment-variables) for more info
52    const supabaseClient = createBrowserClient<Database>(
53      window.env.SUPABASE_URL,
54      window.env.SUPABASE_ANON_KEY
55    );
56    // make sure you have enabled `Replication` for your table to receive realtime events
57    // https://supabase.com/docs/guides/database/replication
58    const channel = supabaseClient
59      .channel('test')
60      .on(
61        'postgres_changes',
62        { event: '*', schema: 'public', table: 'test' },
63        (payload: any) => {
64          // you could manually merge the `payload` with `data` here
65          // the `navigate` trick below causes all active loaders to be called again
66          // this handles inserts, updates and deletes, keeping everything in sync
67          // which feels more remix-y than manually merging state
68          // https://sergiodxa.com/articles/automatic-revalidation-in-remix
69          navigate('.', { replace: true });
70        }
71      )
72      .subscribe();
73
74    return () => {
75      supabaseClient.removeChannel(channel);
76    };
77  }, [session]);
78
79  return (
80    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
81      <pre>{JSON.stringify({ data }, null, 2)}</pre>
82    </div>
83  );
84}

Note: window.env is not automatically populated by Remix. Check out the "Client-side" instructions above to configure this.

Usage with TypeScript#

You can pass types that were generated with the Supabase CLI to the createServerClient or createBrowserClient functions to get enhanced type safety and auto completion:

Server-side#

1import { createServerClient } from '@supabase/auth-helpers-remix'
2import { Database } from '../../db_types'
3
4export const loader = async ({ request }) => {
5  const response = new Response()
6
7  const supabaseClient = createServerClient<Database>(
8    process.env.SUPABASE_URL,
9    process.env.SUPABASE_ANON_KEY,
10    { request, response }
11  )
12}

Client-side#

1import { createBrowserClient } from '@supabase/auth-helpers-remix'
2import { Database } from '../../db_types'
3
4const supabaseClient = createBrowserClient<Database>(
5  process.env.SUPABASE_URL,
6  process.env.SUPABASE_ANON_KEY
7)

Server-side data fetching to OAuth APIs using provider_token#

When using third-party auth providers, sessions are initiated with an additional provider_token field which is persisted in the auth cookie and can be accessed within the session object. The provider_token can be used to make API requests to the OAuth provider's API endpoints on behalf of the logged-in user.

1import { json, LoaderFunction, redirect } from '@remix-run/node'; // change this import to whatever runtime you are using
2import { useLoaderData } from '@remix-run/react';
3import { createServerClient, User } from '@supabase/auth-helpers-remix';
4import { Database } from '../../db_types';
5
6export const loader: LoaderFunction = async ({
7  request
8}: {
9  request: Request;
10}) => {
11  const response = new Response();
12
13  const supabaseClient = createServerClient<Database>(
14    process.env.SUPABASE_URL!,
15    process.env.SUPABASE_ANON_KEY!,
16    { request, response }
17  );
18
19  const {
20    data: { session }
21  } = await supabaseClient.auth.getSession();
22
23  if (!session) {
24    // there is no session, therefore, we are redirecting
25    // to the landing page. we still need to return
26    // response.headers to attach the set-cookie header
27    return redirect('/', {
28      headers: response.headers
29    });
30  }
31
32  // Retrieve provider_token & logged in user's third-party id from metadata
33  const { provider_token, user } = session;
34  const userId = user.user_metadata.user_name;
35
36  const allRepos = await (
37    await fetch(`https://api.github.com/search/repositories?q=user:${userId}`, {
38      method: 'GET',
39      headers: {
40        Authorization: `token ${provider_token}`
41      }
42    })
43  ).json();
44
45  // in order for the set-cookie header to be set,
46  // headers must be returned as part of the loader response
47  return json(
48    { user, allRepos },
49    {
50      headers: response.headers
51    }
52  );
53};
54
55export default function ProtectedPage() {
56  // by fetching the user in the loader, we ensure it is available
57  // for first SSR render - no flashing of incorrect state
58  const { user, allRepos } = useLoaderData<{ user: User; allRepos: any }>();
59
60  return <pre>{JSON.stringify({ user, allRepos }, null, 2)}</pre>;
61}