Auth

Supabase Auth with Remix

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

For a complete implementation example, check out this free egghead course or this GitHub repo.

Install the Remix helper library#

Terminal
npm install @supabase/auth-helpers-remix @supabase/supabase-js

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

Code Exchange Route#

The Code Exchange route is required for the server-side auth flow implemented by the Remix Auth Helpers. It exchanges an auth code for the user's session, which is set as a cookie for future requests made to Supabase.

Create a new file at app/routes/auth.callback.jsx and populate with the following:

app/routes/auth.callback.jsx
import { redirect } from '@remix-run/node'
import { createServerClient } from '@supabase/auth-helpers-remix'

export const loader = async ({ request }) => {
const response = new Response()
const url = new URL(request.url)
const code = url.searchParams.get('code')

if (code) {
const supabaseClient = createServerClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{ request, response }
)
await supabaseClient.auth.exchangeCodeForSession(code)
}

return redirect('/', {
headers: response.headers,
})
}

Server-side#

The Supabase client can now be used server-side - in loaders and actions - by calling the createServerClient function.

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.

import { json } from '@remix-run/node' // change this import to whatever runtime you are using
import { createServerClient } from '@supabase/auth-helpers-remix'

export const loader = async ({ request }) => {
const response = new Response()
// an empty response is required for the auth helpers
// to set cookies to manage auth

const supabaseClient = createServerClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{ request, response }
)

const { data } = await supabaseClient.from('test').select('*')

// in order for the set-cookie header to be set,
// headers must be returned as part of the loader response
return json(
{ data },
{
headers: response.headers,
}
)
}

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.

import { json } from '@remix-run/node' // change this import to whatever runtime you are using
import { createServerClient } from '@supabase/auth-helpers-remix'

export const action = async ({ request }) => {
const response = new Response()

const supabaseClient = createServerClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{ request, response }
)

const { data } = await supabaseClient.from('test').select('*')

return json(
{ data },
{
headers: response.headers,
}
)
}

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.

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

The session contains a user property.

const user = session?.user

Or, if you don't need the session, you can call the getUser() function.

const {
data: { user },
} = await supabaseClient.auth.getUser()

Client-side#

We still need to use Supabase client-side for things like authentication and realtime subscriptions. Anytime we use Supabase client-side it needs to be a single instance.

Creating a singleton Supabase client#

Since our environment variables are not available client-side, we need to plumb them through from the loader.

app/root.jsx
export const loader = () => {
const env = {
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY,
}

return json({ env })
}

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
const { env } = useLoaderData()

We then want to instantiate a single instance of a Supabase browser client, to be used across our client-side components.

app/root.jsx
const [supabase] = useState(() => createBrowserClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY))

And then we can share this instance across our application with Outlet Context.

app/root.jsx
<Outlet context={{ supabase }} />

Syncing server and client state#

Since authentication happens client-side, we need to tell Remix to re-call all active loaders when the user signs in or out.

Remix provides a hook useRevalidator that can be used to revalidate all loaders on the current route.

Now to determine when to submit a post request to this action, we need to compare the server and client state for the user's access token.

Let's pipe that through from our loader.

app/root.jsx
export const loader = async ({ request }) => {
const env = {
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY,
}

const response = new Response()

const supabase = createServerClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY, {
request,
response,
})

const {
data: { session },
} = await supabase.auth.getSession()

return json(
{
env,
session,
},
{
headers: response.headers,
}
)
}

And then use the revalidator, inside the onAuthStateChange hook.

app/root.jsx
const { env, session } = useLoaderData()
const { revalidate } = useRevalidator()

const [supabase] = useState(() => createBrowserClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY))

const serverAccessToken = session?.access_token

useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (session?.access_token !== serverAccessToken) {
// server and client are out of sync.
revalidate()
}
})

return () => {
subscription.unsubscribe()
}
}, [serverAccessToken, supabase, revalidate])

Check out this repo for full implementation example

Authentication#

Now we can use our outlet context to access our single instance of Supabase and use any of the supported authentication strategies from supabase-js.

app/components/login.jsx
export default function Login() {
const { supabase } = useOutletContext()

const handleEmailLogin = async () => {
await supabase.auth.signInWithPassword({
email: 'jon@supabase.com',
password: 'password',
})
}

const handleGitHubLogin = async () => {
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'http://localhost:3000/auth/callback',
},
})
}

const handleLogout = async () => {
await supabase.auth.signOut()
}

return (
<>
<button onClick={handleEmailLogin}>Email Login</button>
<button onClick={handleGitHubLogin}>GitHub Login</button>
<button onClick={handleLogout}>Logout</button>
</>
)
}

Subscribe to realtime events#

app/routes/realtime.jsx
import { useLoaderData, useOutletContext } from '@remix-run/react'
import { createServerClient } from '@supabase/auth-helpers-remix'
import { json } from '@remix-run/node'
import { useEffect, useState } from 'react'

export const loader = async ({ request }) => {
const response = new Response()
const supabase = createServerClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY, {
request,
response,
})

const { data } = await supabase.from('posts').select()

return json({ serverPosts: data ?? [] }, { headers: response.headers })
}

export default function Index() {
const { serverPosts } = useLoaderData()
const [posts, setPosts] = useState(serverPosts)
const { supabase } = useOutletContext()

useEffect(() => {
setPosts(serverPosts)
}, [serverPosts])

useEffect(() => {
const channel = supabase
.channel('*')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, (payload) =>
setPosts([...posts, payload.new])
)
.subscribe()

return () => {
supabase.removeChannel(channel)
}
}, [supabase, posts, setPosts])

return <pre>{JSON.stringify(posts, null, 2)}</pre>
}

Ensure you have enabled replication on the table you are subscribing to.

Migration Guide#

Migrating to v0.2.0#

PKCE Auth Flow

PKCE is the new server-side auth flow implemented by the Remix Auth Helpers. It requires a new loader route for /auth/callback that exchanges an auth code for the user's session.

Check the Code Exchange Route steps above to implement this route.

Authentication

For authentication methods that have a redirectTo or emailRedirectTo, this must be set to this new code exchange API Route - /api/auth/callback. This is an example with the signUp function:

supabaseClient.auth.signUp({
email: 'jon@example.com',
password: 'sup3rs3cur3',
options: {
emailRedirectTo: 'http://localhost:3000/auth/callback',
},
})

No cookies. 🍪

We only collect analytics essential to ensuring smooth operation of our services.

Learn more