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:
- Duplicated data needs to stay in sync
- Populating a new denormalized collection requires some scripting
- 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:
- Access control to the database
- A local environment for testing locally before applying changes to the production database
- A script for copying the production database to the local database
- 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:
- Create row level security rules
- 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:
- Create snapshot of prod
- 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.