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.
The repository with the code for this article will be published as soon as possible. Please stay tuned, or subscribe to the newsletter to receive updates.
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;
Please don't worry if we don't list all the code we import. We'll provide a repository so that you can have a look at the full code.
Supabase Clients
We're now going to create two clients:
- A Supabase client to use in our React components (browser client)
- 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;
In the repository, we add Tailwind styles to make it look nicer. For simplicity, the code examples do not have styles so we can highlight the important parts only.
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:
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.
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!
The repository with the code for this article will be published as soon as possible. Please stay tuned, or subscribe to the newsletter to receive updates.