Server-side Authentication With Remix And Supabase In 5 Simple Steps

December 17, 2023

remix and supabase

Implementation of the server-side passwordless (email magic link) authentication with Remix and Supabase in 5 simple steps.

Setup

Install dependencies

npm install @supabase/supabase-js @supabase/ssr

Environment variables

Create a Supabase project (if you don't have one already) and get the URL and anon key from the project API settings.

Create .env file

SUPABASE_URL=YOUR_SUPABASE_URL
SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

Implementation

Now everything is set up, and we can start adding the code.

1. Create a Supabase server client

app/supabase.server.ts

import { createServerClient, parse, serialize } from '@supabase/ssr'

export const createSupabaseServerClient = (request: Request) => {
  const cookies = parse(request.headers.get('Cookie') ?? '')
  const headers = new Headers()

  const supabaseClient = createServerClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(key) {
          return cookies[key]
        },
        set(key, value, options) {
          headers.append('Set-Cookie', serialize(key, value, options))
        },
        remove(key, options) {
          headers.append('Set-Cookie', serialize(key, '', options))
        },
      },
    },
  )

  return { supabaseClient, headers }
}

2. Create a Sign-in page

app/routes/sign-in.tsx

import { json } from '@remix-run/node'
import { Form, useActionData } from '@remix-run/react'
import type { ActionFunctionArgs } from '@remix-run/node'

import { createSupabaseServerClient } from '~/supabase.server'

export const action = async ({ request }: ActionFunctionArgs) => {
  const { supabaseClient, headers } = createSupabaseServerClient(request)

  const formData = await request.formData()

  const { error } = await supabaseClient.auth.signInWithOtp({
    email: formData.get('email') as string,
    options: {
      emailRedirectTo: 'http://localhost:3000/auth/callback',
    },
  })

  // just for this example
  // if there is no error, we show "Please check you email" message
  if (error) {
    return json({ success: false }, { headers })
  }

  return json({ success: true }, { headers })
}

const SignIn = () => {
  const actionResponse = useActionData<typeof action>()

  return (
    <>
      {!actionResponse?.success ? (
        <Form method="post">
          <input type="email" name="email" placeholder="Your Email" required />
          <br />
          <button type="submit">Sign In</button>
        </Form>
      ) : (
        <h3>Please check your email.</h3>
      )}
    </>
  )
}

export default SignIn

3. Create a callback route

When the user clicks the magic link in the email, they will be redirected to the callback route we defined in the sign-in file - emailRedirectTo. We need to exchange the code (from the magic link) for a session and redirect the user to the dashboard.

app/routes/auth.callback.tsx

import type { ActionFunctionArgs } from '@remix-run/node'
import { redirect } from '@remix-run/node'

import { createSupabaseServerClient } from '~/supabase.server'

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

  if (code) {
    const { supabaseClient, headers } = createSupabaseServerClient(request)
    const { error } = await supabaseClient.auth.exchangeCodeForSession(code)
    if (error) {
      return redirect('/sign-in')
    }

    return redirect('/dashboard', {
      headers,
    })
  }

  return new Response('Authentication faild', {
    status: 400,
  })
}

If the sign-in is successful, the user will be redirected to the dashboard route.

On the dashboard page, we want to check if the user is authenticated.

4. Dashboard page

app/routes/dashboard.tsx

import { redirect } from '@remix-run/node'
import type { LoaderFunctionArgs } from '@remix-run/node'
import { Form } from '@remix-run/react'
import { createSupabaseServerClient } from '~/supabase.server'

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { supabaseClient } = createSupabaseServerClient(request)
  const {
    data: { user },
  } = await supabaseClient.auth.getUser()

  if (!user) {
    return redirect('/')
  }

  return new Response(null)
}

const Dashboard = () => {
  return (
    <div>
      <h1>Dashboard</h1>

      <Form action="/sign-out" method="post">
        <button type="submit">Sign Out</button>
      </Form>
    </div>
  )
}

export default Dashboard

You'll notice that we added a sign-out button. Now we need to create a sign-out route.

5. Sign out

app/routes/sign-out.tsx

import { redirect } from '@remix-run/node'
import type { ActionFunctionArgs } from '@remix-run/node'

import { createSupabaseServerClient } from '~/supabase.server'

export const action = async ({ request }: ActionFunctionArgs) => {
  const { supabaseClient, headers } = createSupabaseServerClient(request)

  // check if user is logged in
  const {
    data: { session },
  } = await supabaseClient.auth.getSession()
  if (!session?.user) {
    return redirect('/')
  }

  // sign out
  await supabaseClient.auth.signOut()
  return redirect('/', {
    headers,
  })
}

That's it! 🎉