Skip to main content

Supabase

CLI

The supabase CLI can be installed here

Setup

Here is how you can set up your project:

  1. Run supabase login to login
  2. Run supabase init to initialize supabase for your project
  3. Run supabase link to link your local supabase config to a project on supabase so you can connect to the cloud
supabase link --project-ref <project-id>

secrets

You can use the supabase secrets command to perform CRUD operations on secrets and upload them to the cloud.

  • supabase secrets set <KEY>=<VALUE>: sets a secret env pair in the cloud
  • supabase secrets list: shows all secrets you have associated with your supabase project

Here is how to setup multiple secrets at once by pointing to an env file to upload:

supabase secrets set --env-file .env

WARNING

Any custom secrets you set for edge functions in supabase CANNOT start with SUPABASE_ prefix as that is reserved only for special env vars supabase auto-injects into each function execution environment.

Local Dev with Supabase

Overview

Runnign supabase locally via docker compose runs all of the supabase services on localhost, meaning to connect to supabase locally, you have to configure your code to access these local services:

  • http://localhost:54321: the default local origin of where your supabase project runs, basically where all the REST APIs for the project live
  • http://localhost:54323: the default studio API

You can change all local behavior, ports, urls, etc. via the supabase/config.toml, which controls the configuration for local development.

Migrations and DB schemas

Declarative schema development

Supabase has a new way to automatically generate migrations for local development.

  1. Create a SQL file that defines a table in the supabase/schemas folder
  2. Use the supabase db diff command to create a migration based on the diff of the current state of the database and the SQL file.
  3. Use the supabase migration up command to apply the migration to the db
supabase db diff -f <migration_name>

Declarative sync

The new supabase declarative db schema sync command allows you to generate migrations automatically and sync it to the database automatically. Use this command:

supabase db schema declarative sync
  1. Enable the [experimental.pgDelta] key in the supabase/config.toml

Seed file

For local development you can create a supabase/seed.sql that will run automatically on database initialization to seed the database.

If you have a bunch of data in the database you want to create as initial data for the seed, this is how you can copy the current data in the database into a seed file:

supabase db diff -f my_schema
supabase db dump --local --data-only > supabase/seed.sql
supabase stop --no-backup

Push up migrations

  • supabase migrations up: applies migrations to local db
  • supabase db push: pushes up local db migrations and changes to the prod db

Local auth

External provider setup

Here is how to set up auth for an external provider like google:

  1. In the root .env of your app, set up the client id and client secret of the oauth provider
  2. In the supabase/config.toml, setup auth like so:
[auth.external.github]
enabled = true
client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
secret = "env(GOOGLE_OAUTH_SECRET)"
# redirect uri must be this, since local project url is http://localhost:54321
redirect_uri = "http://localhost:54321/auth/v1/callback"

Then for the external auth providers, you should add the new redirect uri and authorized origins to account for http://localhost:54321

SDK

Setup

  1. Install the supabase SDK @supabase/supabase-js package.

  2. Create a supabase client by going to the project api and copying over the connection strings.

On the main dashboard, this is what the project connection stuff will look like:

Here is where you can go to get the public and secret keys:

  • public key: suitable for use on the client and basically only works client side
  • secret key: only works server-side

Then you can implement that in typescript like so:

import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export function createSupabaseClient(
supabaseUrl: string,
supabaseAnonKey: string
) {
return createClient(supabaseUrl, supabaseAnonKey);
}

export function createSupabaseAdmin(
supabaseUrl: string,
supabaseSecretKey: string
) {
return createClient(supabaseUrl, supabaseSecretKey!);
}

DB querying

Supabase is a super light wrapper over postgreSQL, and all db queries return an error and data object. Nothing is strongly typed, so it serves to create your own wrapper over supabase.

Here is a basic example of all the CRUD operations:

  • supabase.from(tableName): sets up a chain of creating postgres query statements
  • .delete(): creates a DELETE statement
  • .eq(field, value): equivalent to a WHERE statement with checking equality of the specified field to the specified value
async function deleteTaskByID(id: string) {
const {data, error} = await supabase.from("tasks") // on tasks table
.delete() // DELETE FROM
.eq("id", id) // WHERE id = ?
}

async function orderTasks() {
const {data, error} = await supabase.from("tasks")
.select("*")
.order_by("created_at": {
ascending: true
})
}

async function insertTask(task: Task) {
const {data, error} = await supabase.from("tasks")
.insert(task)
.single() // need this to specify single

}

async function insertManyTasks(tasks: Task[]) {
const {data, error} = await supabase.from("tasks")
.insert(tasks)
}

async function updateTask(taskId: string, task: Partial<Task>) {
const {data, error} = await supabase.from("tasks")
.update(task)
.eq("id", taskId)

}

Here's an abstraction over common supabase DB operations:

export class SupabaseDbManager<T extends Record<string, any>> {
constructor(
public supabase: SupabaseClient,
public tableName: string,
private options?: {
idField?: string;
}
) {}

private get id() {
return this.options?.idField ?? "id";
}
getBuilder() {
return this.supabase.from(this.tableName);
}

async insert(data: T): Promise<T> {
const { data: createdData, error } = await this.supabase
.from(this.tableName)
.insert(data)
.select()
.single();
if (error) throw error;
return createdData as T;
}

async insertMany(data: T[]) {
const response = await this.supabase.from(this.tableName).insert(data);
return response;
}

async update(id: string, data: Partial<T>) {
const response = await this.supabase
.from(this.tableName)
.update(data)
.eq(this.id, id);
return response;
}

async delete(id: string) {
const response = await this.supabase
.from(this.tableName)
.delete()
.eq(this.id, id);
return response;
}
}

File storage

You can take advantage of supabase file storage by uploading files and downloading files:

export class SupabaseStorageManager {
constructor(public supabase: SupabaseClient) {}

async uploadFile(file: File, path: string) {
const { data, error } = await this.supabase.storage
.from("files")
.upload(path, file);
if (error) throw error;
return data;
}

async downloadFile(path: string) {
const { data, error } = await this.supabase.storage
.from("files")
.download(path);
if (error) throw error;
return data;
}
}

Auth

Setting up auth

To set up auth on supabase for google, you first need to get an OAuth client id and secret from the google cloud page. You can set the authroized javascript origins to localhost and include the callback url and origin that supabase provides.

You then paste in your client secret and id here:

The final step you need to do is to register redirect URLs to your app with supabase. Go to the /auth/url-configuration route in supaabase to do so.

Client-side Auth

Here's an abstraction over common supabase auth operations:

export class SupabaseAuthManager {
constructor(public supabase: SupabaseClient) {}

async loginWithGoogle() {
const res = await this.supabase.auth.signInWithOAuth({
provider: "google",
// options: {

// }
});
return res;
}

async signOut() {
return await this.supabase.auth.signOut();
}

onAuthStateChange(options: {
onSignedIn: (session: Session) => void;
onSignedOut: () => void;
}) {
const { data: authListener } = this.supabase.auth.onAuthStateChange(
(event, session) => {
if (event === "SIGNED_IN") {
options.onSignedIn(session!);
} else if (event === "SIGNED_OUT") {
options.onSignedOut();
}
}
);

return {
unsubscribe: () => {
authListener.subscription.unsubscribe();
},
};
}

async getUser() {
const {
data: { user },
error,
} = await this.supabase.auth.getUser();
if (error) throw error;
return user;
}

async getSession() {
const {
data: { session },
error,
} = await this.supabase.auth.getSession();
if (error) throw error;
return session;
}
}

Edge functions

Edge functions are serverless cloud functions you create that supabase hosts that you can request like an API endpoint. An example would be something like this:

Setup

Edge functions are individual files that live in the supabase/functions directory, and are run in Deno with typescript.

Here is how to set up for supabase functions:

  1. Run supabase init and click "yes" when asked to generate deno settings.
  2. Run deno init to create a deno.json and allow for installing packages.
  3. Install the deno packages you want using deno add within the supabase/functions folder.
    • For example, once you do deno add npm:zod, you can use zod anywhere in your cloud functions.

The best project structure for supabase functions is like so, where you have access to a complete deno environment, therefore you can do the following:

  • shared code: for best practice, keep all shared code in a _shared folder. You can share code across the functions by importing other files into those functions.
  • tests: you can test functions using the deno testing framework.

Sharing code between functions

The deno.json is necessary for shared code, as that's the new and improved way over import maps for telling functions where your code lives. You can create it with deno init.

Each function should have its own deno.json file to manage dependencies and configure Deno-specific settings. This ensures proper isolation between functions and is the recommended approach for deployment. When you update the dependencies for one function, it won't accidentally break another function that needs different versions.

Some npm packages may not ship out of the box types and you may need to import them from a separate package. You can specify their types with a @deno-types directive:

// @deno-types="npm:@types/express@^4.17"
import express from 'npm:express@^4.17'

To include types for built-in Node APIs, add the following line to the top of your imports:

/// <reference types="npm:@types/node" />

Authentication in edge functions

By default, JWT auth is enabled for edge functions, meaning a user has to be logged in via supabase auth before they can programmatically invoke a function.

That means that when you invoke a function like so, a bearer auth header is automatically passed with the header value being the supabase anon key.

const { data, error } = await supabase.functions.invoke("create-checkout", {
body: {
customerName: "John"
}
})

To create an edge function without authentication, you must disable the JWT auth for the function in the function settings in the dashboard.

WARNING

Disabling JWT auth makes a supabase cloud function work like any old API route, therefore you must be extremely careful with who you let call your API. CORS is necessary to prevent malicious actors when disabling JWT.

Function CLI + local development

All supabase edge function CLI functionality is based off of two commands:

supabase functions serve # serves all functions locally
supabase functions deploy # deploy all functions to cloud

Here is what you can do to develop with functions locally:

  • supabase serve: serve all functions at once.
  • supabase functions serve [function-name]: serves the specified function by name locally.
  • supabase functions serve [function-name] --no-verify-jwt: serves the specified function by name locally without JWT auth
  • supabase functions serve --env-file [env-file-path]: injects the env vars in the specified env file path into the function execution context when developing locally.

And you deploy a function to supabase like so:

supabase functions deploy [function-name]

Accessing secrets

In supabase function code you have access to the environment variables that are supabase secrets. Here are some examples of the secrets automatically set by supabase and thus available in the environment variables injected into a function execution context.

  • SUPABASE_URL: The API gateway for your Supabase project
  • SUPABASE_ANON_KEY: The anon key for your Supabase API. This is safe to use in a browser when you have Row Level Security enabled
  • SUPABASE_SERVICE_ROLE_KEY: The service_role key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass Row Level Security
  • SUPABASE_DB_URL: The URL for your Postgres database. You can use this to connect directly to your database

Since supabase functions run using deno, you can retrieve any environment variable like so:

Deno.env.get("SOME_SECRET_KEY")

In development, you can load environment variables in two ways:

  1. Through an .env file placed at supabase/functions/.env, which is automatically loaded on supabase start
  2. Through the --env-file option for supabase functions serve. This allows you to use custom file names like .env.local to distinguish between different environments.

Using supabase client in edge functions

A major use case of edge functions is using supabase storage, auth, and database in a way to bypass RLS restrictions.

It's best practice to store the supabase clients in some sort of shared code like in a _shared folder:

import { createClient } from 'npm:@supabase/supabase-js@2'

// For user-facing operations (respects RLS)
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!
)

// For admin operations (bypasses RLS)
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)

CORS

CORS is extremely important when creating an API and especially when you have JWT verification disabled. Here are the two major types of mistakes people make with CORS:

  • mistake 1 (no cors permissions): Without cors, nobody can invoke your function at all, meaning no frontend can call a supabase function.
  • mistake 2 (lax cors permissions): If you enable every origin to invoke your function from their frontend, then you're vulnerable to attacks on your server.

The best way to do CORS is to only allow origins you trust and methods you trust.

Here's an example of a utility function I use to always return the correct CORS headers:

// region CORS
const allowedOrigins = [
"http://localhost:8080",
Deno.env.get("PROD_DOMAIN") || "https://production-bettermeals.vercel.app",
];

export const getCorsHeaders = (origin: string | null) => {
// Check if the request origin is in your allowed list
const allowedOrigin = origin && allowedOrigins.includes(origin)
? origin
: allowedOrigins[1]; // Default to your main prod domain

return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
};

IMPORTANT

The most important thing people forget is that CORS headers must be returned on every single response, no matter the status code (server error or request success). This is because a frontend will not be able to read the server or cloud function response if there is no CORS policy allowing the frontend to read the server response.

From the Fetch / CORS spec:

Every response to a cross-origin request must include appropriate CORS headers — not just successful ones.

That includes:

  • ✅ 200 / 201 success
  • ✅ 400 / 401 / 403 client errors
  • ✅ 500 server errors
  • ✅ ANY early return

👉 The browser doesn’t care why you returned — it only checks headers.

Function examples

Stripe Edge function

This is what an edge function completely looks like:

  1. Grab the Supabase client and populate it with your environment variables
  2. Create a server using Deno.serve
  3. Check headers for authorization and use that to get authenticated supabase user
  4. Do work and then return a response.

By default, all edge functions need an Authorization and apikey header, both of which should be set to the supabase publishable anon key if you have JWT auth for functions turned on.

// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "jsr:@supabase/supabase-js@2";
import OpenAI from "npm:openai";

// Load environment variables
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};

Deno.serve(async (req) => {

// 1. handle cors
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}

try {
const { title, description } = await req.json();

console.log("🔄 Creating task with AI suggestions...");

// 2. ensure supabase user is logged in
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Error("No authorization header");
}

// 3. Initialize Supabase client
const supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
headers: { Authorization: authHeader },
},
});

// 4. Get user session
const {
data: { user },
} = await supabaseClient.auth.getUser();
if (!user) throw new Error("No user found");

// Create the task
const { data, error } = await supabaseClient
.from("tasks")
.insert({
title,
description,
completed: false,
user_id: user.id,
})
.select()
.single();

if (error) throw error;

// Initialize OpenAI
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});

// Get label suggestion from OpenAI
const prompt = `Based on this task title: "${title}" and description: "${description}", suggest ONE of these labels: work, personal, priority, shopping, home. Reply with just the label word and nothing else.`;

const completion = await openai.chat.completions.create({
messages: [{ role: "user", content: prompt }],
model: "gpt-4o-mini",
temperature: 0.3,
max_tokens: 16,
});

const suggestedLabel = completion.choices[0].message.content
?.toLowerCase()
.trim();

console.log(`✨ AI Suggested Label: ${suggestedLabel}`);

// Validate the label
const validLabels = ["work", "personal", "priority", "shopping", "home"];
const label = validLabels.includes(suggestedLabel) ? suggestedLabel : null;

// Update the task with the suggested label
const { data: updatedTask, error: updateError } = await supabaseClient
.from("tasks")
.update({ label })
.eq("task_id", data.task_id)
.select()
.single();

if (updateError) throw updateError;

return new Response(JSON.stringify(updatedTask), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("Error in create-task-with-ai:", error.message);
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});

DB

Security policies

This is what a security policy will look like to enable row level security