Home | Send Feedback | Share on Bluesky |

Getting started with Supabase

Published: 3. March 2026  •  baas, angular

In previous blog posts, I showed you how to use PocketBase and Appwrite as backend services for your applications. In this blog post, we will look at another popular backend-as-a-service (BaaS) option: Supabase.

I will show you how to build a simple Todo app with Supabase as the backend and cover the following topics:

What is Supabase?

Supabase is a backend-as-a-service (BaaS) platform built around PostgreSQL. It gives you a Postgres database plus production-ready services around it:

Supabase offers a hosted cloud service with a free tier that allows you to get started without managing infrastructure. But, like Appwrite and PocketBase, you can also self-host Supabase in your own environment, giving you more control and privacy.

Supabase provides many different client libraries and can be used for many different types of applications. In this tutorial, we will focus on using the JavaScript client library (@supabase/supabase-js) in an Angular app, but the concepts apply broadly to any frontend framework or even server-side applications.

Local setup with Supabase CLI

The easiest way to get started with Supabase is to use the Supabase CLI and then run the whole stack locally with Docker. This gives you a full local environment that closely matches the hosted Supabase experience, including the API, database, auth, and storage services.

The first step is to install the Supabase CLI. You can find installation instructions in the official documentation.

Make sure Docker is running, then run the following command from your project root:

supabase start

This downloads all the necessary Docker images and starts your full local Supabase stack. You can inspect current local endpoints and keys at any time with:

supabase status

Supabase Studio (the web UI) is available at http://127.0.0.1:54323 by default. You can manage your database, auth users, storage buckets, and more from there. Additionally, Supabase provides an MCP endpoint at http://127.0.0.1:54321/mcp for LLM development and a catch-all SMTP server UI at http://127.0.0.1:54324 for testing email flows.

Database schema

The first step is to define the database schema. In Supabase, your database is a standard Postgres instance, so you can define your schema using SQL.

You can create schemas in two ways:

  1. Supabase Studio (UI)
  2. Migration SQL files (versioned code)

In this tutorial, I use the migration-file route. First, create a migration file with:

supabase migration new init

This command creates an empty file in supabase/migrations with a timestamp prefix. init is just a name for the migration; you can have as many as you want and name them descriptively.

You can edit that file to define your database schema and security policies as SQL code.

Make sure that you never change existing migration files after they've been applied in the production database.

Tables

In this demo application, I created two tables: profiles and todos. Both tables have foreign key relationships to the auth.users table, which is managed by Supabase Auth. This allows us to associate profiles and todos with specific authenticated users.

Everything is standard Postgres SQL, so you can use all the power of Postgres to define your schema, constraints, and relationships. You can also define triggers and functions for more complex logic.

For this application, I created a trigger function to automatically update the updated_at timestamp whenever a row is updated. Both tables will use this trigger to keep track of when rows are modified.

create function public.set_current_timestamp_updated_at()
returns trigger
set search_path = ''
language plpgsql
as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

20260221000001_init.sql


Profiles

The profiles table stores the avatar URL for each user. The id column is a UUID that references the auth.users table, so each profile is linked to a specific authenticated user (one-to-one). The updated_at column is automatically updated with the current timestamp whenever the profile is updated.

create table public.profiles (
  id          uuid references auth.users on delete cascade not null primary key,
  updated_at  timestamp with time zone,
  avatar_url  text
);

create function public.handle_new_user()
returns trigger
set search_path = ''
language plpgsql security definer
as $$
begin
  insert into public.profiles (id)
  values (new.id);
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

create trigger profiles_set_updated_at
  before update on public.profiles
  for each row execute procedure public.set_current_timestamp_updated_at();

20260221000001_init.sql

handle_new_user is a trigger function that runs after a new user is created in the auth.users table. It automatically inserts a new row into the profiles table with the same id as the new user, ensuring that every user has a corresponding profile.


Todos

A straightforward table to store todos. Each todo has a foreign key relationship to the auth.users table through the user_id column, which allows us to associate each todo with a specific authenticated user (one-to-many). The updated_at column is automatically updated with the current timestamp whenever the todo is updated.

create table public.todos (
  id          bigint generated always as identity primary key,
  user_id     uuid references auth.users on delete cascade not null default auth.uid(),
  title       text not null check (char_length(title) > 0),
  description text,
  is_complete boolean not null default false,
  priority    text not null default 'medium' check (priority in ('low', 'medium', 'high')),
  due_date    date,
  inserted_at timestamp with time zone not null default now(),
  updated_at  timestamp with time zone not null default now()
);

create index todos_user_id_idx on public.todos (user_id);

create trigger todos_set_updated_at
  before update on public.todos
  for each row execute procedure public.set_current_timestamp_updated_at();

20260221000001_init.sql


Storage

Supabase also provides a storage service for file uploads. We can also manage the bucket in the migration file. To create a bucket, you can insert a row into the storage.buckets table. This table is a Supabase-managed table that defines your storage buckets. For this example, I created an avatars bucket.

insert into storage.buckets (id, name, public)
  values ('avatars', 'avatars', true);

20260221000001_init.sql

Row Level Security (RLS)

When you create tables as shown above, they are wide open by default. Any user can read/write any row. Most of the time, this is not what you want in a real app. You want to restrict access so users can only see and modify their own data.

For this reason, Supabase has Row Level Security (RLS) built-in. When you define your tables in the Supabase Studio UI, RLS is enabled by default. RLS is not enabled by default when you define your tables in a migration file, so you need to enable it manually with an SQL command.

alter table public.profiles enable row level security;

20260221000001_init.sql

alter table public.todos enable row level security;

20260221000001_init.sql

These two commands enable RLS, and now nobody can access any rows in those tables until we define policies.

To create policies, we use the CREATE POLICY command. Policies define who can access which rows and under what conditions. In this example, we want to allow only authenticated users to access their own profiles and todos when the user ID matches the authenticated user's ID.

auth.uid() is a special function that returns the UUID of the currently authenticated user. We can use this function in our policies to compare against the id field in the profiles table and the user_id field in the todos table.

For the profiles table, we only need to enable reading and updating, because inserts are handled by the trigger and we don't want users to delete their profile rows directly.

create policy "Users can view their own profile."
  on public.profiles for select
  to authenticated
  using ((select auth.uid()) = id);

create policy "Users can update their own profile."
  on public.profiles for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

20260221000001_init.sql


For the todos table, we add policies for all operations (select, insert, update, delete) because users need to be able to manage their todos fully.

create policy "Users can view their own todos."
  on public.todos for select
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "Users can insert their own todos."
  on public.todos for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

create policy "Users can update their own todos."
  on public.todos for update
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "Users can delete their own todos."
  on public.todos for delete
  to authenticated
  using ((select auth.uid()) = user_id);

20260221000001_init.sql

Column Level Security (CLS)

Supabase can control not only which rows a user can access with RLS, but also which columns they can access or update with Column Level Security (CLS).

To define CLS policies, we use the GRANT and REVOKE commands. In this example, we want to allow authenticated users to read and update their own avatar_url in the profiles table. Note that revoke all revokes all permissions, but we don't enable insert and delete permissions for the profiles table. Users should not delete their profile, and inserts are handled by the trigger.

revoke all on public.profiles from authenticated;
grant select on public.profiles to authenticated;
grant update (avatar_url) on public.profiles to authenticated;

20260221000001_init.sql


For the todos table, we want to allow authenticated users to delete their own todos and read/update/insert only certain columns. For example, we don't want users to update the user_id column. This column is set by default to the authenticated user's ID when a new todo is inserted, and we don't want users to change the ownership of a todo by updating the user_id column. Similarly, we don't want users to update the id, inserted_at, and updated_at columns because these are managed by the database and should not be modified by users.

revoke all on public.todos from authenticated;
grant delete on public.todos to authenticated;
grant select (id, title, description, is_complete, priority, due_date, inserted_at) on public.todos to authenticated;
grant insert (title, description, is_complete, priority, due_date) on public.todos to authenticated;
grant update (title, description, is_complete, priority, due_date) on public.todos to authenticated;

20260221000001_init.sql


RLS and CLS are very important concepts to understand when building a secure application with Supabase. Read more about RLS and CLS in the official documentation.

Storage Security

The storage bucket is also public by default, but you can control access to the files in the bucket with storage object policies, which are defined in the storage.objects table.

Profile pictures are stored with the avatars/{user_id}/{filename} pattern, so we can use the storage.foldername(name) function to extract the user_id from the file path and compare it with the authenticated user's ID to create policies that allow users to manage only their own avatar files. Read access is public, so avatars can be displayed in the app to other users.

create policy "Avatar images are publicly accessible."
  on storage.objects for select
  using (bucket_id = 'avatars');

create policy "Authenticated users can upload an avatar."
  on storage.objects for insert
  to authenticated
  with check (bucket_id = 'avatars' and (select auth.uid())::text = (storage.foldername(name))[1]);

create policy "Users can update their own avatar."
  on storage.objects for update
  to authenticated
  using (bucket_id = 'avatars' and (select auth.uid())::text = (storage.foldername(name))[1]);

create policy "Users can delete their own avatar."
  on storage.objects for delete
  to authenticated
  using (bucket_id = 'avatars' and (select auth.uid())::text = (storage.foldername(name))[1]);

20260221000001_init.sql


After setting up the backend, we can move on to the frontend and see how to use the @supabase/supabase-js client library to interact with our Supabase backend.

Installing @supabase/supabase-js

supabase-js is the official JavaScript client library for Supabase. It provides a simple and intuitive API for interacting with all of Supabase's services, including auth, database, storage, and more.

Add it to your project as you would any other npm package:

npm install @supabase/supabase-js

Client initialization

In this application, I created a SupabaseService that wraps the supabase-js client and provides methods for auth and database operations. This keeps all the Supabase wiring in one place and gives components a clean API to work with.

The client is the main entry point to all Supabase services. You initialize it with your Supabase URL and publishable key. You can find these values in the console when you run supabase status.

In an Angular application, you typically store these values in an environment file. In this example, the environment.development.ts file contains the local Supabase URL and key:

export const environment = {
  supabaseUrl: 'http://127.0.0.1:54321',
  supabaseKey: 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH',
};

environment.development.ts

The application uses the createClient function from @supabase/supabase-js to create a client instance in the service constructor.

@Injectable({
  providedIn: 'root',
})
export class SupabaseService {
  private supabase: SupabaseClient;

  constructor() {
    this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey);
  }

supabase.service.ts

Auth walkthrough

This application uses a simple email/password auth flow. The Supabase client provides methods for signing up, signing in, and signing out users in the auth namespace. The client also manages the user session and provides methods to get the current user and listen to auth state changes.

Sign-up

After the user enters their email and password in the sign-up form, the component calls the signUp method in the service, which uses supabase.auth.signUp to create a new user.

  signUp(email: string, password: string) {
    return this.supabase.auth.signUp({ email, password });
  }

supabase.service.ts

Note that in the local Supabase configuration, email confirmations are disabled, so users can sign up and use the app immediately without confirming their email. In a production environment, you typically want to enable email confirmations and configure SMTP settings to ensure that users verify their email addresses before accessing the app.

In your local setup, you can enable email confirmations in the supabase/config.toml file. After making changes to the config file, you need to restart the Supabase stack for the changes to take effect (supabase stop && supabase start).

enable_confirmations = true

Emails in the local environment are caught by the built-in SMTP server and can be viewed in the SMTP UI at http://localhost:54324.


Sign-in

To sign in, the user enters their email and password, and the application calls supabase.auth.signInWithPassword to authenticate the user.

  signIn(email: string, password: string) {
    return this.supabase.auth.signInWithPassword({ email, password });
  }

supabase.service.ts

If the credentials are correct, the user is logged in and a session is created. The session is stored in local storage and managed by the client, so it persists across page reloads.


Sign-out

To sign out, the application calls the supabase.auth.signOut method, which clears the session and logs the user out.

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

supabase.service.ts


Session + route protection

To fetch the currently logged-in user, the application calls the supabase.auth.getUser method, which returns the user data if there is an active session.

  async getUser(): Promise<User | null> {
    const { data, error } = await this.supabase.auth.getUser();
    if (error) return null;
    return data.user;
  }

supabase.service.ts

Angular uses this method and route guards to protect the routes and ensure that only authenticated users can access the app pages, while unauthenticated users can only access the sign-in and sign-up pages.

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { SupabaseService } from '../supabase.service';

export const authGuard: CanActivateFn = async () => {
  const supabase = inject(SupabaseService);
  const router = inject(Router);
  const user = await supabase.getUser();
  if (user) return true;
  return router.createUrlTree(['/sign-in']);
};

export const guestGuard: CanActivateFn = async () => {
  const supabase = inject(SupabaseService);
  const router = inject(Router);
  const user = await supabase.getUser();
  if (!user) return true;
  return router.createUrlTree(['/todos']);
};

auth.guard.ts

import { Routes } from '@angular/router';
import { authGuard, guestGuard } from './guards/auth.guard';

export const routes: Routes = [
  { path: '', redirectTo: '/todos', pathMatch: 'full' },
  {
    path: 'sign-in',
    loadComponent: () => import('./auth/sign-in').then((m) => m.SignInComponent),
    canActivate: [guestGuard],
  },
  {
    path: 'sign-up',
    loadComponent: () => import('./auth/sign-up').then((m) => m.SignUpComponent),
    canActivate: [guestGuard],
  },
  {
    path: 'profile',
    loadComponent: () => import('./profile/profile').then((m) => m.ProfileComponent),
    canActivate: [authGuard],
  },
  {
    path: 'todos',
    loadComponent: () => import('./todos/todo-list').then((m) => m.TodoListComponent),
    canActivate: [authGuard],
  },
  {
    path: 'todos/:id',
    loadComponent: () => import('./todos/todo-edit').then((m) => m.TodoEditComponent),
    canActivate: [authGuard],
  },
  { path: '**', redirectTo: '/todos' },
];

app.routes.ts

Profile walkthrough

Load profile data

When the profile screen opens, the component first fetches the authenticated user, then loads the profile row from the profiles table.

Because of Row Level Security, users can only read their own profile row, so we can run a simple select query without specifying a where clause.

  profile() {
    return this.supabase.from('profiles').select('avatar_url').single();
  }

supabase.service.ts

This method returns an object with data and error properties. If the query is successful, data will contain the profile data and the properties have the same name as the columns in the profiles table.

  async ngOnInit() {
    const user = await this.supabase.getUser();
    if (!user) return;

    this.userEmail.set(user.email ?? '');

    const { data, error } = await this.supabase.profile();
    if (error) {
      this.errorMessage.set(error.message);
      return;
    }
    if (data) {
      this.avatarUrl.set(data.avatar_url ?? null);
    }
  }

profile.ts


Upload avatar (Storage)

The avatar image is uploaded to the avatars bucket using a path pattern like: {user_id}/{random-uuid}.{ext}

The application calls the uploadAvatar method in the service. This method uses the Supabase Storage API to upload the file to the avatars bucket and path. The policies we defined earlier ensure that users can only upload files to their own folder.

  uploadAvatar(filePath: string, file: File) {
    return this.supabase.storage.from('avatars').upload(filePath, file);
  }

supabase.service.ts

The application then stores the path to the image in the avatar_url column of the profiles table.

    const { error } = await this.supabase.updateProfile(
      {
        avatar_url: this.avatarUrl() ?? '',
      },
      user.id,
    );

profile.ts

  updateProfile(profile: Profile, userId: string) {
    return this.supabase.from('profiles').update(profile).eq('id', userId);
  }

supabase.service.ts

Todo CRUD walkthrough

Read (list todos)

Reading data from a table is straightforward with the select method. You can specify the table, the columns you want to select, and any filters or ordering.

Because of RLS, users can only read their own todos, so we don't need to specify a where clause to filter by user ID. Because Column Level Security is enabled, we need to make sure that we only select the columns users have access to; otherwise, the query will fail with a permission error.

  getTodos() {
    return this.supabase
      .from('todos')
      .select(TODO_READ_COLUMNS_SQL)
      .order('inserted_at', { ascending: false });
  }

supabase.service.ts


Create (add todo)

To insert data, the insert method is used. You specify the table and the data to insert. Columns that we don't specify here (like id, user_id, inserted_at, and updated_at) will be set by default values or triggers in the database.

  addTodo(todo: TodoInsertPayload) {
    return this.supabase.from('todos').insert(todo);
  }

supabase.service.ts


Update (edit or toggle completion)

The equivalent of a SQL UPDATE statement is the update method. You specify the table and pass an object with the columns you want to update and their new values. You also need to specify which row(s) to update with a filter (e.g., eq('id', id)).

Both operations can only update certain columns because of the Column Level Security policies we defined earlier. If you try to update a column that the user doesn't have permission to update, the query will fail with a permission error.

  updateTodo(id: number, changes: TodoUpdatePayload) {
    return this.supabase.from('todos').update(changes).eq('id', id);
  }

supabase.service.ts


Delete

Deleting a row is done with the delete method. Similar to update, you specify the table and a filter to specify which row(s) to delete. Users can only delete their own todos because of the RLS policies, so we just need to filter by the todo ID.

  deleteTodo(id: number) {
    return this.supabase.from('todos').delete().eq('id', id);
  }

supabase.service.ts

Production

Moving to production from your local development environment is straightforward with the Supabase CLI.

If you use the Supabase hosted service, link your local project and push your migration SQL to the cloud:

supabase link --project-ref <your-project-ref>
supabase db push

For a self-hosted Supabase on your own server, the flow is similar but you target your own Postgres and API endpoints instead of a Supabase Cloud project.

Get the connection URL for your Postgres database and use it to push your migrations. You can find the URL in Supabase Studio under the "Connect" button in the header.

supabase db push --db-url "postgresql://<user>:<password>@<host>:5432/<database>?sslmode=require"

Wrapping up

Supabase is a powerful and flexible backend-as-a-service that gives you a lot of control over your database schema, security policies, and API. It contains all the building blocks for a modern app backend: Postgres database, authentication, instant APIs, storage, edge functions, realtime subscriptions, and vector embeddings. You can use it for everything from simple CRUD apps to complex applications with custom business logic and real-time features.

The Supabase CLI makes it easy to get started locally. The hosted service allows you to deploy to production without managing infrastructure, but you still have the option to self-host if you have the infrastructure and want more control.