Here is a list of some things I like to do when first setting up a Supabase project.

Update the Email Templates

This step is critical when working with Supabase. The @supabase/supabase-js library was originally designed for single-page applications (SPAs). When building server-side rendering (SSR) applications, the library is not fully compatible by default. To address this, Supabase provides the separate @supabase/ssr library. However, this library uses Proof Key for Code Exchange (PKCE) authentication by default, which does not work well with certain email-based authentication flows, such as confirmation links.

To resolve this issue, update the email templates and modify your application code to process the link clicked in the email.

In your application, create an endpoint that accepts the request from the email and processes it. This endpoint can be named anything, but following the convention in the Supabase documentation, call it /auth/confirm. This endpoint will use code from the @supabase/ssr library to handle the processing. First, however, modify the email templates.

The main change involves the link in the email, which is currently represented by the {{ .ConfirmationURL }} template variable.

Example of a default confirmation email:

<h2>Confirm your signup</h2>

<p>Follow this link to confirm your user:</p>
<p><a href="{{ .ConfirmationURL }}">Confirm your mail</a></p>

Instead of relying on the {{ .ConfirmationURL }} variable, manually construct the confirmation URL using the {{ .SiteURL }} template variable and other parts.

The new “Confirm your mail” link will look like this:

<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your mail</a>

This approach uses the {{ .TokenHash }} variable, which contains hashed information about the user that only the Supabase Auth system can read. Adding the type parameter set to email indicates that this is a signup or sign-in request.

Next, create the endpoint in your application to handle this request. The following example uses Next.js, but the concept is similar for any framework.

import { createClient } from "@/lib/supabase/server";
import { EmailOtpType } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const token_hash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType | null;
  const next = searchParams.get("next") ?? "/";

  // Create redirect link without the code
  const redirectTo = new URL(next);
  redirectTo.searchParams.delete("code");

  if (token_hash && type) {
    const supabase = await createClient();
    const { error } = await supabase.auth.verifyOtp({ 
      type,
      token_hash,
    });
    if (!error) {
      return NextResponse.redirect(redirectTo);
    }
  }

  return NextResponse.redirect("/auth/auth-confirm-error");
}

The most important part, which is not framework-specific, is this:

const { error } = await supabase.auth.verifyOtp({ 
  type,
  token_hash,
});

You can reuse the same /auth/confirm endpoint for all other authentication email templates. The main difference in each template is the value of the type parameter, so the authentication system knows how to handle the token hash.

Magic Link

<h2>Magic Link</h2>

<p>Follow this link to login:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}">Log In</a></p>

<p>The link will remain valid for 24 hours or until it is used, whichever comes first. If you need to request another link, <a href="{{ .SiteURL }}/auth/signin">please click here</a>.</p>

Password Recovery

<h2>Password Reset</h2>

<p>We are sorry to hear that you are having trouble accessing your account. To reset your password and regain access, please click the link below:</p>

<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}">Reset Password</a></p>

<p>If you did not request a password reset, please ignore this message and do not click the link. Your account security is important to us, and we take all necessary steps to protect it.</p>

Email Change

<h2>Confirm Change of Email</h2>

{{ if eq .Email .SendingTo }}
  <p>We are writing to confirm you initiated an email change from {{ .Email }} to {{ .NewEmail }}</p>
  <p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change">Change Email</a></p>
{{ else }}
  <p>Please confirm your email address ({{ .NewEmail }}) for this account.</p>
  <p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change">Confirm Email Address</a></p>
{{ end }}

Some benefits of this approach are:

  • The emails use your own domain URL (reducing the chance of spam filters blocking messages sent from a shared subdomain like .supabase.co).
  • You gain more control over the verification process.

Lock Down the Supabase CLI Version

I always install the CLI locally in my projects with a specific, locked version number. This prevents unexpected behavior from updates until I have tested them.

npm install supabase@2.67.1 --save-dev

Use Separate Environments

When starting with Supabase, it is easy to rely on the hosted platform and remain in that workflow until the application goes live. At that point, making changes without affecting production becomes a challenge.

I always recommend using a migration system to manage the database. The Supabase CLI is a good choice if you are comfortable with SQL; otherwise, you can use something like Drizzle ORM (you can use Drizzle only for schema migrations if you prefer).

My development environment runs locally on my computer using the Supabase CLI. This allows me to test changes without risking the hosted instance. Local development also provides a faster feedback loop, and I can work offline during long flights.

In standard development practice, you typically have development, staging (sometimes a separate testing or QA environment), and production environments.

This is how it looks in my setup:

  • Development: Supabase CLI running locally
  • Staging: A Supabase hosted project (or a persistent preview branch)
  • Production: Another Supabase hosted project

I use migrations to keep these environments in sync. I make all changes locally and never directly on staging or production. This requires discipline for yourself and your team. Never push changes directly to these environments; instead, set up a CI/CD process that applies migrations when you merge a branch into the target environment.

Note that Supabase offers Branching, which is excellent for managing preview and staging environments through Git-integrated workflows.