Authenticating users with Remix and Supabase

Learn how to use Remix and Supabase to authenticate users in your application.

Supabase is an open-source Firebase alternative that provides a hosted Postgres database, realtime APIs, storage, authentication and an admin dashboard. It's a great alternative to Firebase for developers who want to build apps without having to worry about managing infrastructure.

In this tutorial, we want to show you how to use Remix and Supabase to authenticate users in your application. The code in this article is a basic and small extraction of the upcoming Remix Supabase SaaS kit, a full stack starter kit that will be released soon for building a complete SaaS.

Remix and Supabase play particularly well together since they can both be used in Edge environments, such as Cloudflare Workers: Edge environments are great for building SaaS applications because they allow you to run your code closer to your users, which can improve performance and reduce costs.

At the time of writing, the Makerkit Supabase Kit is still in beta. If you want to try it out, please contact me.

Since you're here, check out the Launch Week 6 the Supabase Team is hosting. It's a great opportunity to learn more about Supabase and get to know the team. I'm looking forward to hearing the surprises they will announce!

Getting started

To get started, you will:

  • Create a Supabase project
  • Create a Remix project
  • Install the Supabase libraries into your Remix starter

Let's see how.

Creating a Supabase project

To get started with Supabase, you'll need to create a new Supabase project. You can do this by visiting the Supabase dashboard.

Creating a Remix project

To bootstrap your Remix project, we can create a new project using the following command:

npx create-remix@latest remix-supabase-mini-saas-kit

Now, open the project in your editor or IDE.

Installing the Supabase libraries

To use Supabase in our Remix project, we'll need to install the Supabase libraries. We can do this by running the following command:

npm i supabase @supabase/supabase-js @supabase/auth-helpers-remix

After installing Supabase, we'll need to initialize it. To do this, run the following command at the root of your project:

supabase init

Adding the Supabase commands to launch the app

Now that we've installed the Supabase libraries, we'll need to add the commands to launch the app.

We can do this by opening the package.json file and adding the following commands:

{
"scripts": {
"dev": "run-p dev:*",
"dev:css": "npm run generate:css -- --watch",
"dev:remix": "emix dev",
"generate:css": "tailwindcss -i ./app/styles/index.css -o app/styles/dist.css",
"supabase:start": "supabase start",
"supabase:stop": "supabase stop",
"typegen": "supabase gen types typescript --local > app/database.types.ts",
"test:db": "supabase test db --debug",
"test:reset:db": "supabase db reset && supabase test db --debug",
}
}

Additionally, we added some commands for:

  • Starting and stopping the Supabase server
  • Generating the database types
  • Testing the database
  • Resetting the database
  • Generating the CSS using Tailwind CSS
  • Running the app in development mode

Running the project

Now that we have the commands to launch the app, we can run the following commands to start the app.

To launch the Remix server, run the following command:

npm run dev

You should now be able to visit the app at http://localhost:3000.

Next, to launch the Supabase server, run the following command (requires Docker running):

npm run supabase:start

If everything is working correctly, you should be able to see an output like in the image below:

Our setup work is mostly finished! Now it's time to build the authentication flow of our app.

Providing the environment variables to our application

Unlike Next.js, Remix does not inject environment variables into the browser by default. To do this, we'll need to inject the environment variables into the browser by adding them to the global object window:

To do so, open the file root.tsx. First, we create a function to collect the environment variables we want to inject into the browser:

function getBrowserEnvironment() {
const env = process.env;
return {
SITE_URL: env.SITE_URL,
DEFAULT_LOCALE: env.DEFAULT_LOCALE,
NODE_ENV: env.NODE_ENV,
SUPABASE_URL: env.SUPABASE_URL,
SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY,
};
}

We will need variables such as SUPABASE_ANON_KEY and SUPABASE_URL to connect to our Supabase database. You will need to retrieve these from your Supabase dashboard after creating a new project.

Subsequently, we need to add these variables to the global declaration of the window object. Create a file named global.d.ts in the root of your project and add the following code:

declare global {
interface Window {
ENV: {
ENVIRONMENT: string;
DEFAULT_LOCALE: string;
SITE_URL: string;
NODE_ENV: string;
SUPABASE_URL: string;
SUPABASE_ANON_KEY: string;
};
}
}
export {}

Then, we add the following script to the head of the page. The root layout component will look like the below:

export default function App() {
const data = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<RemixMeta />
<Links />
<Head />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(data.ENV)}`,
}}
/>
</head>
<body className="h-full">
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

As you can see from the above, we have created a loader function. This function will be called before the page is rendered. We can use this function to inject the environment variables into the page or to perform any other actions before the page is rendered.

Using the environment variables isomorphically

To get our variables isomorphically (e.g. on both client and server), we create a function:

import isBrowser from '~/core/generic/is-browser';
function getEnv() {
return isBrowser() ? window.ENV : process.env;
}
export default getEnv;

Supabase Clients

We're now going to create two clients:

  1. A Supabase client to use in our React components (browser client)
  2. A Supabase client to use in our Remix API (server client)
import { createBrowserClient } from '@supabase/auth-helpers-remix';
import getEnv from '~/core/get-env';
import invariant from 'tiny-invariant';
import type { Database } from '../../database.types';
export function getSupabaseBrowserClient() {
const env = getEnv();
invariant(env.SUPABASE_URL, `Supabase URL was not provided`);
invariant(env.SUPABASE_ANON_KEY, `Supabase Anon key was not provided`);
return createBrowserClient<Database>(
env.SUPABASE_URL,
env.SUPABASE_ANON_KEY
);
}

Additionally, we create a React hook to use the Supabase client in our components:

import { useMemo } from 'react';
import { getSupabaseBrowserClient } from '~/lib/supabase/browser-client';
function useSupabase() {
return useMemo(getSupabaseBrowserClient, []);
}
export default useSupabase;

And now, we create the server client:

import { createServerClient } from '@supabase/auth-helpers-remix';
import invariant from 'tiny-invariant';
import type { Database } from '~/database.types';
import getEnv from '~/lib/get-env';
function getSupabaseServerClient(
request: Request,
params = { admin: false }
) {
const response = new Response();
const env = getEnv();
invariant(env.SUPABASE_URL, `Supabase URL not provided`);
invariant(env.SUPABASE_ANON_KEY, `Supabase Anon Key not provided`);
if (params.admin) {
const serviceRoleKey
= process.env.SUPABASE_SERVICE_ROLE_KEY;
invariant(serviceRoleKey, `Supabase Service Role Key not provided`);
return createServerClient<Database>(
env.SUPABASE_URL,
serviceRoleKey, {
request,
response,
});
}
return createServerClient<Database>(
env.SUPABASE_URL,
env.SUPABASE_ANON_KEY, {
request,
response,
});
}
export default getSupabaseServerClient;

We can add these clients to the folder app/lib/supabase.

Authentication Flow

Next, we want to build the authentication pages of our application using Remix.

To do this, we'll need to create a new auth folder in the app folder and add the following files:

  • sign-in.tsx
  • sign-up.tsx

Additionally, we create the auth layout component. This component is placed within the routes folder, and will wrap all the routes within the auth folder with the AuthLayout component:

import { Outlet } from '@remix-run/react';
function AuthLayout() {
return (
<div>
<div>
<Outlet />
</div>
</div>
);
}
export default AuthLayout;

Redirecting authenticated users away from the auth pages

Since we don't want authenticated users to see the auth pages, we need to redirect them away from the auth pages. To do this, we create a loader within the auth layout, so that this can apply to all its routes:

To do so, we can add the loader function below to the Auth layout:

app/routes/auth.tsx
export const loader = async ({ request }: LoaderArgs) => {
try {
const client
= getSupabaseServerClient(request);
const {
data: { session },
} = await client.auth.getSession();
if (session) {
return redirect('/dashboard');
}
return json({});
} catch (e) {
return json({});
}
};

As you can see from the above, we're using the getSupabaseServerClient function to get the Supabase client. We then use the getSession method to get the session.

If the session exists, we redirect the user to the dashboard page.

Sign up

Now, we can create the sign-up page. We'll create a sign-up.tsx file within the routes/auth folder.

For simplicity, we only show the email/password flow, but adapting the below to any of the Supabase authentication flows is straightforward.

app/routes/auth/sign-up.tsx
function SignUp() {
const navigate = useNavigate();
const onSignUp = useCallback(() => {
navigate('/onboarding');
}, [navigate]);
return <EmailPasswordSignUpContainer onSignUp={onSignUp} />
}
export default SignUp;

Below, we create the form to sign users up:

type Credentials = { email: string; password: string };
const EmailPasswordSignUpContainer: React.FCC<{
onSignUp: () => unknown;
}> = ({ onSignUp, onError }) => {
const signUp = useSignUpWithEmailAndPassword();
const onSubmit = useCallback(
async (params: Credentials) => {
try {
await signUp(params);
onSignUp();
} catch (error) {
if (onError) {
onError(error);
}
}
},
[onSignUp, signUp]
);
return (
<EmailPasswordSignUpForm onSubmit={onSubmit} />
);
};
export default EmailPasswordSignUpContainer;

Now, we want to create a React hook to sign users up with their email and password we call useSignUpWithEmailAndPassword:

interface Credentials {
email: string;
password: string;
}
function useSignUpWithEmailAndPassword() {
const supabase = useSupabase();
return useCallback((credentials: Credentials) => {
return client.auth.signUp(
credentials
).then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
});
}

The component EmailPasswordSignUpForm is the form that will be used to sign users up. The component below uses a multitude of components you may not have (such as the Makertkit UI components): please think of these as implementation details, and adapt them to your own components.

The important part here is that we call the onSubmit function when the form is submitted: this will kick off the sign-up flow and then redirect users to the onboarding flow on success.

import { Trans, useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import TextField from '~/core/ui/TextField';
import Button from '~/core/ui/Button';
import If from '~/core/ui/If';
const EmailPasswordSignUpForm: React.FCC<{
onSubmit: (params: {
email: string;
password: string;
repeatPassword: string;
}) => unknown;
loading: boolean;
}> = ({ onSubmit, loading }) => {
const { t } = useTranslation();
const {
register,
handleSubmit,
watch,
formState
} = useForm({
defaultValues: {
email: '',
password: '',
repeatPassword: '',
},
});
const emailControl = register('email', { required: true });
const errors = formState.errors;
const passwordControl = register('password', {
required: true,
minLength: {
value: 6,
message: t<string>(`auth:passwordLengthError`),
},
});
const passwordValue = watch(`password`);
const repeatPasswordControl = register('repeatPassword', {
required: true,
minLength: {
value: 6,
message: t<string>(`auth:passwordLengthError`),
},
validate: (value) => {
if (value !== passwordValue) {
return t<string>(`auth:passwordsDoNotMatch`);
}
return true;
},
});
return (
<form className={'w-full'} onSubmit={handleSubmit(onSubmit)}>
<div className={'flex-col space-y-4'}>
<TextField>
<TextField.Label>
<Trans i18nKey={'common:emailAddress'} />
<TextField.Input
data-cy={'email-input'}
required
type="email"
placeholder={'your@email.com'}
innerRef={emailControl.ref}
onBlur={emailControl.onBlur}
onChange={emailControl.onChange}
name={emailControl.name}
/>
</TextField.Label>
<TextField.Error error={errors.email?.message} />
</TextField>
<TextField>
<TextField.Label>
<Trans i18nKey={'common:password'} />
<TextField.Input
data-cy={'password-input'}
required
type="password"
placeholder={''}
innerRef={passwordControl.ref}
onBlur={passwordControl.onBlur}
onChange={passwordControl.onChange}
name={passwordControl.name}
/>
<TextField.Hint>
<Trans i18nKey={'auth:passwordHint'} />
</TextField.Hint>
<TextField.Error
data-cy="password-error"
error={errors.password?.message}
/>
</TextField.Label>
</TextField>
<TextField>
<TextField.Label>
<Trans i18nKey={'auth:repeatPassword'} />
<TextField.Input
data-cy={'repeat-password-input'}
required
type="password"
placeholder={''}
innerRef={repeatPasswordControl.ref}
onBlur={repeatPasswordControl.onBlur}
onChange={repeatPasswordControl.onChange}
name={repeatPasswordControl.name}
/>
<TextField.Hint>
<Trans i18nKey={'auth:repeatPasswordHint'} />
</TextField.Hint>
<TextField.Error
data-cy="repeat-password-error"
error={errors.repeatPassword?.message}
/>
</TextField.Label>
</TextField>
<div>
<Button
size={'large'}
data-cy={'auth-submit-button'}
className={'w-full'}
color={'primary'}
type="submit"
loading={loading}
>
<If
condition={loading}
fallback={<Trans i18nKey={'auth:getStarted'} />}
>
<Trans i18nKey={'auth:signingUp'} />
</If>
</Button>
</div>
</div>
</form>
);
};
export default EmailPasswordSignUpForm;

That's it! We have finished our sign-up flow to allow users to sign up with their email and password.

The sign-in flow will be very similar, but we have to create a different hook to sign users in with their email and password. We call this hook useSignInWithEmailAndPassword:

import useSupabase from './use-supabase';
interface Credentials {
email: string;
password: string;
}
/**
* @name useSignInWithEmailPassword
*/
function useSignInWithEmailPassword() {
const client = useSupabase();
return useCallback((credentials: Credentials) => {
return client.auth.signInWithPassword(
credentials
).then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
});
}
export default useSignInWithEmailPassword;

If you swap out the useSignUpWithEmailAndPassword hook for the useSignInWithEmailAndPassword hook in the EmailPasswordSignUpForm component and adjust the UI not to require the repeat password field, you will have a sign-in form that will sign users in with their email and password.

Nice, isn't it? We have finished our authentication flow. Now we can move on to the onboarding flow.

Onboarding flow

In the onboarding flow, you can ask your users any information you may require, such as their organization name. After that, you can create their accounts in the database and redirect them to the home page.

Adding a public users table to the database is a common practice to store additional data about your users, such as their names, profile avatar, and so on.

When users land on the onboarding page, we require them to be signed in, and not to be onboarded yet.

To protect our page from unauthorized access, we can use a Remix loader.

First, let's create a requireSession function that will throw an error if the user is not signed in:

import { redirect } from '@remix-run/node';
import type { SupabaseClient } from '@supabase/supabase-js';
import configuration from '~/configuration';
/**
* @name requireSession
* @param client
*/
async function requireSession(
client: SupabaseClient
) {
const { data, error }
= await client.auth.getSession();
if (!data.session || error) {
throw redirect('/auth/sign-in');
}
return data.session;
}
export default requireSession;

Additionally, let's ensure that users are not onboarded yet. To do so, we can finally start interacting with our Supabase Postgres Database. We will use the function below to retrieve data from the database about the signed in user:

import type { SupabaseClient } from '@supabase/supabase-js';
import type UserData from '~/core/session/types/user-data';
/**
* @description Fetch user object data (not auth!) by ID {@link userId}
*/
export async function getUserDataById(
client: SupabaseClient,
userId: string
) {
const result = await client
.from('users')
.select<string, UserData>(
`
id,
displayName: display_name,
photoUrl: photo_url,
onboarded
`
)
.eq('id', userId)
.single();
return result.data;
}

A good convention for creating your Database queries and mutations is to always inject the Supabase client using a parameter. This way, you can easily reuse your function across both client and server.

export async function loader(args: LoaderArgs) {
const client
= getSupabaseServerClient(args.request);
const session
= await requireSession(client);
const userData
= await getUserDataById(client, session.user.id);
// if we cannot find the user's Database record
// the user should go to the onboarding flow
// so that the record will be created after the end of the flow
if (!userData) {
const response: UserSession = {
auth: session.user || undefined,
data: userData ?? undefined,
role: undefined,
};
return json(response);
}
const userId = userData.id;
const onboarded = userData.onboarded;
// there are two cases when we redirect the user to the onboarding
// if they have not been onboarded yet
if (onboarded) {
throw redirectToAppHome();
}
const response = {
auth: session.user || undefined,
data: userData,
role: undefined,
};
return json(response);
}

Submitting the onboarding form

Now that we have our loader, we can create our onboarding form. For simplicity, we will show how to respond to form actions using Remix.

First, we want to create a form:

function OnboardingForm() {
return (
<Form method="post">
<label>
<input type="text" name="profileName" />
</label>
</Form>
);
}

As you can see from the above, we have a form with a single input field. We will use the name attribute to identify the field in the form data. The form will automatically call the action route handler when the form is submitted.

We will now define the action handler on the onboarding route. Remix will take care of the rest.

export async function action(args: ActionArgs) {
const req = args.request;
const formData = await req.formData();
const body = JSON.parse(formData.get('data') as string);
// NB: you may want to validate "body" here
const client = await getSupabaseServerClient(req);
// we always require the user to be currently signed in
const { user } = await requireSession(client);
const userId = user.id;
const profileName = body.name;
const payload = {
userId,
profileName,
client,
};
// here, we can write to the DB
await completeOnboarding(payload);
return redirect('/dashboard');
}

Let's write a simple function that adds the user record to our database given the user's ID and the profile name. Additionally, we mark the user as onboarded:

function completeOnboarding(
params: {
userId: string,
profileName: string,
client: SupabaseClient,
}
) {
return client.from('users').insert({
id: params.userId,
display_name: params.profileName,
onboarded: true,
}).throwOnError();
}

Protecting routes using Remix loaders

Now that we have our onboarding flow, we can protect our routes using Remix loaders. We will use the requireSession function we created earlier to ensure that the user is signed in. We will also use the getUserDataById function to retrieve the user's data from the database.

To do so, let's create a loader function named load-app-data. We can reuse this function for every route that needs to be protected.

const loadAppData = async ({ request }: LoaderArgs) => {
try {
const client
= getSupabaseServerClient(request);
const { user }
= await requireSession(client);
// if for any reason we're not able to fetch the user's data, we redirect
// back to the login page
if (!user) {
return redirectToLogin(request.url);
}
// we fetch the user record from the Database
// which is a separate object from the auth metadata
const userRecord
= await getUserDataById(client, user.id);
const isOnboarded = Boolean(userRecord?.onboarded);
// when the user is not yet onboarded,
// we simply redirect them back to the onboarding flow
if (!isOnboarded || !userRecord) {
return redirectToOnboarding();
}
return json(
{
session: user,
user: userRecord,
},
);
} catch (e) {
const logger = getLogger();
logger.error(`Could not load application data: ${JSON.stringify(e)}`);
// if the user is signed out, we save the requested URL
// so, we can redirect them to where they originally navigated to
return redirectToLogin(request.url);
}
};
function redirectToOnboarding() {
return redirect(configuration.paths.onboarding);
}
function redirectToLogin(
returnUrl: string,
redirectPath = configuration.paths.signIn
) {
// we build the sign in URL
// appending the "returnUrl" query parameter so that we can redirect the user
// straight to where they were headed and the "signOut" parameter
// to force the client to sign the user out from the client SDK
const destination = `${redirectPath}?returnUrl=${returnUrl}&signOut=true`;
return redirect(destination);
}
export default loadAppData;

Then, when creating a Remix route, we can simply import the load-app-data loader and use it as a loader for the route:

export const loader = loadAppData;
function DashboardRoute() {
return <div>Dashboard</div>;
}
export default DashboardRoute;

More commonly, you may want to use this loader as the loader for the root component of your internal application, while using the loaders for the individual routes for loading their page data. In this way, you only need to define it once.

Conclusion

In this article, we have shown how to use Supabase with Remix. We have also shown how to use Remix loaders to protect routes and how to use Remix actions to handle form submissions.

This article is a very basic extraction of the code we use in our upcoming Remix and Supabase kit. We have a lot more code that we use to handle authentication, authorization, multi-tenancy, and so on.

We will be publishing more articles in the future that will cover these topics in more detail, including organizations, Stripe subscriptions, Row-Level Security, and so much more.

Thank you for reading, Ciao!