Supabase and Angular: A Powerful Combination for Building Web Applications

Share on facebook
Share on google
Share on twitter
Share on linkedin

Supabase is a cloud-based backend as a service (BaaS) platform that provides developers with a set of tools and services for building scalable and secure web applications.
It’s much like Firebase but Supabase provides a PostgreSQL database which solves some of the inconveniences with a NoSQL database such as Firestore.
For that reason, Supabase has now become my favorite BaaS and I use them for my own projects.

In this blog post, we will learn how to get started with Supabase and how to create a todo app with Supabase, Angular, and Nx. In the end, we will further dive into how to get the app production ready.

SQL vs. NoSQL

The main question to ask whether to pick Supabase or Firebase comes down to the database technology SQL or NoSQL.

NoSQL comes with the promise of great performance and scalability as you would organize your data in a denormalized way to serve your read queries. This comes with a few downsides though:

  1. Duplicated data needs to stay in sync
  2. Populating a new denormalized collection requires some scripting
  3. Lack of flexibility with the data structure as it should serve the read queries

These things usually lead to much extra work that I often don’t see as worth it for smaller projects. And usually, bigger projects don’t rely solely on NoSQL either, they usually do a mix of SQL and NoSQL while having most of their core data stored in SQL and duplicated for read optimization in the NoSQL databases.

Creating a todo app with Supabase and Angular

To see it all in action, let’s build a to-do app with Supabase and Angular. We will start by setting up the Supabase project and database and then use it in an Angular app.

Getting started with Supabase

To get started with Supabase you first create a free account.

Then create an organization and a project:

We will just name this “Todo app” and keep everything else as default.

Creating the tables

We will go to the table editor and create a new table called “todos” by clicking “Create a new table”:

From here we want to create a simple table for todos:

This contains the id, which is a number, created_at which is automatically set, name (string) and is_completed (boolean).

Now we have a table to contain our todo items.

Build the Angular app

Let’s create a new Angular app with Nx. Even though this is not a monorepo Nx gives us a wide range of tools that can be helpful for development such as Cypress and Jest test runners and setup of TailwindCSS.

We will generate a new Nx workspace with:

create-nx-workspace todoapp

We will create set it up with an Angular app using scss:

Setting up Supabase in an Angular app

Let’s start by setting up the Supabase client in Angular.

Install the Supabase client library for javascript by running the following command in the terminal:

npm install @supabase/supabase-js

In the environment file we have:

export const environment = {
  production: false,
  supabaseUrl: 'YOUR_SUPABASE_URL',
  supabaseKey: 'YOUR_SUPABASE_KEY',
}

Replace ‘YOUR_SUPABASE_URL‘ and 'YOUR_SUPABASE_KEY' with your own Supabase URL and API key, which you can find in your Supabase dashboard.

Note, for the Supabase key we use the anon key and disable row level security (for now). Later we will look into how to make the app secure for a production setup.

We set up the supabase client:

export const supabase = createClient<Database>(
  environment.supabaseUrl,
  environment.supabaseKey
);

Now you can use the supabase client variable to interact with the database.

Show todo items

We can skip all styling concerns here (I created something simple with TailwindCSS) and just focus on the main concern: Show a list of todo items.

The HTML looks like this:

<div class="block mb-6">
  <app-todo-item
    class="mb-1"
    *ngFor="let todoItem of todoItems$ | async; trackBy: todoItemsTrackBy"
    [todoItem]="todoItem"
    (delete)="onDeleteTodo($event)"
    (edit)="onEdit($event)"
    (isCompletedChange)="onIsCompletedChange($event)"
  >
  </app-todo-item>
</div>

Nothing fancy here, we get the data from Supabase like:

  async fetchTodoItems() {
    const { data, error } = await supabase.from('todos').select();

    if (error) {
      throw error;
    }

    this.state.next({
      todoItems:
        data?.map(
          ({ id, name, is_completed }) =>
            ({ id: id, name, isCompleted: is_completed } as TodoItem)
        ) ?? [],
    });
  }

The data is then saved in our own RxJS based state management solution.

Create todo items in the Supabase database

We will go to the Supabase admin and create some todo items:

See them being shown:

Create todo items

Now we will create a simple form for creating the todo items:

    <form [formGroup]="formGroup" (submit)="onSaveTodo()">
      <mat-form-field class="example-form-field" appearance="fill">
        <mat-label>Todo name</mat-label>
        <input matInput type="text" formControlName="name" />
      </mat-form-field>

      <button
        mat-button
        color="primary"
        [disabled]="formGroup.invalid"
        type="submit"
      >
        Save
      </button>
    </form>

Then on submitting this form the component will handle the request:

  onSaveTodo() {
    const todoItem = {
      ...this.selectedTodo,
      name: this.formGroup.value.name,
    } as TodoItem;

    this.todoListService.saveTodo(todoItem);

    this.selectedTodo = null;
    this.formGroup.reset();
  }

And we will create it in Supabase in the todo-list.service.ts:

async saveTodo(todoItemToSave: TodoItem) {
      // create
      const newTodoItem = {
        ...todoItemToSave,
        id: crypto.getRandomValues(new Uint32Array(1))[0],
      } as TodoItem;
      this.state.next({
        ...this.state.value,
        todoItems: [...this.state.value.todoItems, newTodoItem],
      });

      const { error } = await supabase.from('todos').insert({
        id: newTodoItem.id,
        name: newTodoItem.name,
        is_completed: false,
      });

      if (error) {
        throw error;
      }
  }

Delete todo items

Now want to delete a todo item. We simply add a delete button to the template of the TodoItemComponent:

@Component({
  selector: 'app-todo-item',
  standalone: true,
  imports: [SharedModule, ReactiveFormsModule],
  template: ` <mat-card>
    <div class="flex justify-between items-center gap-4">
      <div [class.line-through]="todoItem?.isCompleted">
        {{ todoItem?.name }}
      </div>
      <div>
        <mat-checkbox color="primary" [formControl]="isCompletedFormControl">
        </mat-checkbox>
        <button mat-icon-button (click)="onDelete()">
          <mat-icon color="warn" fontIcon="delete"> </mat-icon>
        </button>
      </div>
    </div>
  </mat-card>`,
  styles: [
    `
      :host {
        @apply block;
      }
    `,
  ],
})
export class TodoItemComponent implements OnDestroy {

And then that will propagate through the handler in the TodoItemComponent (TodoItemComponent -> TodoListComponent -> TodoListService) and in the todo service the item is deleted with:

  async deleteTodo(todoItemId: number) {
    const { error } = await supabase
      .from('todos')
      .delete()
      .eq('id', todoItemId);
    if (error) {
      throw error;
    }
    const newTodoList = this.state.value.todoItems.filter(
      (todo) => todo.id !== todoItemId
    );
    this.state.next({ ...this.state.value, todoItems: newTodoList });
  }

Update todo items

Lastly, we will support updating todo items. To do this we will create an edit button that will populate the form with the selected todo item.

<mat-card>
  <div class="flex justify-between items-center gap-4">
    <div [class.line-through]="todoItem?.isCompleted">
      {{ todoItem?.name }}
    </div>
    <div>
      <mat-checkbox color="primary" [formControl]="isCompletedFormControl">
      </mat-checkbox>
      <button mat-icon-button (click)="onDelete()">
        <mat-icon color="warn" fontIcon="delete"> </mat-icon>
      </button>
      <button mat-icon-button (click)="onEdit()">
        <mat-icon color="accent" fontIcon="edit"> </mat-icon>
      </button>
    </div>
  </div>
</mat-card

In the component, when the edit button is triggered we will populate the form:

  onEdit(todoItem: TodoItem) {
    this.selectedTodo = todoItem;

    this.formGroup.setValue({
      name: this.selectedTodo.name,
    });
  }

Once the form is edited, we submit it like for the create todo item submit.

And then in the TodoService‘s saveTodo method we check if the saved todo has an id already and do an update in this case:

async saveTodo(todoItemToSave: TodoItem) {
    // update
    if (todoItemToSave.id) {
      const { error } = await supabase.from('todos').upsert({
        id: todoItemToSave.id,
        name: todoItemToSave.name,
        is_completed: todoItemToSave.isCompleted,
      });

      if (error) {
        throw error;
      }

      const updatedTodoList = this.state.value.todoItems.map((todoItem) => {
        if (todoItem.id === todoItemToSave.id) {
          return todoItemToSave;
        }
        return todoItem;
      });

      this.state.next({
        ...this.state.value,
        todoItems: [...updatedTodoList],
      });
    } else {
      // create
      const newTodoItem = {
        ...todoItemToSave,
        id: crypto.getRandomValues(new Uint32Array(1))[0],
      } as TodoItem;
      this.state.next({
        ...this.state.value,
        todoItems: [...this.state.value.todoItems, newTodoItem],
      });

      const { error } = await supabase.from('todos').insert({
        id: newTodoItem.id,
        name: newTodoItem.name,
        is_completed: false,
      });

      if (error) {
        throw error;
      }
    }
  }
}

Managing a production setup

This is fine for start but as this app goes into production we would also need:

  1. Access control to the database
  2. A local environment for testing locally before applying changes to the production database
  3. A script for copying the production database to the local database
  4. Updating the database through migration scripts

Let’s see how we can make the app more production ready.

Access control the database

We were pretty much shortcutting all security concerns when creating the simple todo app which of course wouldn’t be acceptable in a real production app. Currently, anybody would have full access to the Supabase database given the Supabase key which is accessible in the user’s browser.

Instead, we should in a production setup restrict access to data which can be done in two ways:

  1. Create row level security rules
  2. Use service_role token from a backend server which is allowed complete access to database

IMO, option 2 with the backend is the architectural “correct” solution as the backend API is abstracting away the database access and handling all the necessary logic but for simple projects a serverless architecture using the row level securty rules is just fine.
Let’s go with option 1 here.

We will assume we have Supabase auth setup with a login solution so we can identify the user the database request is coming from.

Given this info, we can add a user_id column to the todos table:

And then create the row level security rule for the table for only allowing read/writes to todos where the authenticated user id matches the user_id column:

A local environment for testing locally before applying changes to the production database

As our app goes into production, we would not want to work directly with the production database when developing. Instead, we would use a local copy of the database so we can modify it without any production impact.

We can run the local database by first logging in to Supabase with the CLI:

We paste the access token from https://app.supabase.com/account/tokens:

We can then run supabase locally with npx supabase start. Make sure to have Docker running and run it from the apps/web location.

And we can update environemnt.ts to target this local database:

export const environment = {
  production: false,
  supabaseUrl: 'http://localhost:54321',
  supabaseKey:
    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0',
};

We can then run the app against the local environment with

npm start

A script for copying the production database to the local database

When we work with the local database we want to be able to set it up as a copy of the prod schema and data. We do this using pg_dump which is a PostgreSQL CLI tool for cloning a database.

We will create two scripts:

  1. Create snapshot of prod
  2. Restore localdb from snapshot

Doing that we can create setup a localdb that is cloned from prod.

Create snapshot of prod

The script will use pg_dump to create an SQL dump file of the prod database.

createSnapshotOfProd();

async function createSnapshotOfProd() {
  // eslint-disable-next-line
  const { execSync } = require('child_process');

  console.log('========== Create Snapshot ==========');

  const name = new Date().toISOString();
  execSync(
    `PGPASSWORD=$TODOAPP_DB_POSTGRES_PASSWORD pg_dump -h $TODOAPP_DB_POSTGRES_HOST -p 5432 -U postgres -f apps/web/supabase/snapshots/prod-${name}.sql`
  );
}

Restore localdb from snapshot

The script will restore the local db from the snapshot.

restoreSnapshotToLocalDb();

async function restoreSnapshotToLocalDb() {
  // eslint-disable-next-line
  const { execSync } = require('child_process');
  // eslint-disable-next-line
  const { readdirSync } = require('fs');
  const inquirer = await import('inquirer');
  // eslint-disable-next-line
  const path = require('path');

  console.log('======== Restore Snapshot =========');
  const snapshotsPath = path.join(__dirname, '../supabase/snapshots');
  const snapshots = readdirSync(snapshotsPath);

  const { snapshot } = await (inquirer as any).default.prompt({
    type: 'list',
    choices: snapshots,
    message: 'select a snapshot to restore',
    name: 'snapshot',
  });

  console.log(`Restoring snapshot ${snapshot}...`);
  execSync(
    `PGPASSWORD=postgres psql -U postgres -h 127.0.0.1 -p 54322 -f apps/web/supabase/snapshots/${snapshot}`
  );
}

For doing the prod db clone to local db in one command we can create a script in project.json combining those:

nx run web:create-prod-snapshot && nx run web:restore-localdb-from-prod

Updating the remote adatabase through migration scripts

Like other ORM (object relational mapping) tools Supabase supports migration scripts for managing database updates.

It allows us to make changes to our local db, test them locally and generate a migration script to update the prod database with the change.

After doing some changes to the local database we can generate the migrations using:

supabase db diff -f "supabase/migrations/some_migration"

The migration script can then be applied to the prod database like:

supabase db push

Github example

The full example can be found on my Github here.

Conclusion

In this post, we saw why I now prefer Supabase for BaaS and how you use it to easily create full-stack app. There are many more features in Supabase such as Authentication that are not covered here but you can check them out here.

Free Angular community

If you want to join a free supportive community for Angular developers you can get a personal invitation for an invite here:

Free blogging club

I also have a free blogging club where we keep ourselves accountable to create regular content for advancing our tech careers and giving back to the community.

You can join here.

Angular Architect Accelerator

If you want to take a shortcut altogether you can sign up for the free warmup workshop before the next cohort of Angular Architect Accelerator. Check it out here.

Do you want to become an Angular architect? Check out Angular Architect Accelerator.

Related Posts and Comments

How to Set up a CI pipeline with Azure Pipelines and Nx

It goes without saying that having a CI pipeline for your Angular apps is a must. Setting one up for regular Angular apps is fairly straightforward but when you have an Nx monorepo there are certain other challenges that you have to overcome to successfully orchestrate a “build once, deploy many” pipeline. This post will

Read More »

How to Set Up Git Hooks in an Nx Repo

Git hooks can be used to automate tasks in your development workflow. The earlier a bug is discovered, the cheaper it is to fix (and the less impact it has). Therefore it can be helpful to run tasks such as linting, formatting, and tests when you are e.g. committing and pushing your code, so any

Read More »

The Stages of an Angular Architecture with Nx

Long gone are the times when the frontend was just a dumb static website. Frontend apps have gotten increasingly complex since the rise of single-page application frameworks like Angular. It comes with the price of increased complexity and the ever-changing frontend landscape requires you to have an architecture that allows you to scale and adapt

Read More »

The Best Way to Use Signals in Angular Apps

Since Angular 16, Angular now has experimental support for signals and there is a lot of confusion in the community about whether this is going to replace RxJS or how it should be used in an app in combination with RxJS. This blog post sheds some light on what I think is the best way

Read More »

High ROI Testing with Cypress Component Testing

Testing is one of the most struggled topics in Angular development and many developers are either giving up testing altogether or applying inefficient testing practices consuming all their precious time while giving few results in return. This blog post will change all this as we will cover how I overcame these struggles the hard way

Read More »