Blog post

Authentication in Ionic Angular with Supabase

2022-11-08

55 minute read

Authentication in Ionic Angular with Supabase

Authentication of your apps is one of the most important topics, and by combining Angular logic with Supabase authentication functionality we can build powerful and secure applications in no time.

In this tutorial, we will create an Ionic Angular application with Supabase backend to build a simple realtime messaging app.

Ionic Angular Authentication with Supabase

Along the way we will dive into:

  • User registration and login with email/password
  • Adding Row Level Security to protect our database
  • Angular guards and token handling logic
  • Magic link authentication for both web and native mobile apps

After going through this tutorial you will be able to create your own user authentication and secure your Ionic Angular app.

If you are not yet familiar with Ionic you can check out the Ionic Quickstart guide of the Ionic Academy or check out the Ionic Supabase integration video if you prefer video.

However, most of the logic is Angular based and therefore applies to any Angular web project as well.

You can find the full code of this tutorial on Github where you just need to insert your own Supabase instance and then create the tables with the included SQL file.

But before we dive into the app, let's set up our Supabase project!

Creating the Supabase Project

First of all, we need a new Supabase project. If you don't have a Supabase account yet, you can get started for free!

In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy of your database password!

By default Supabase has the standard email/password authentication enabled, which you can find under the menu element Authentication and by scrolling down to the Auth Provider section.

Supabase Auth Provider

Need to add another provider in the future? No problem!

Supabase offers a ton of providers that you can easily integrate so your users can sign up with their favorite provider.

On top of that, you can customize the auth settings and also the email templates that users see when they need to confirm their account, get a magic link or want to reset their password.

Supabase Email Templates

Feel free to play around with the settings, and once you're done let's continue with our database.

Defining your Tables with SQL

Supabase uses Postgres for the database, so we need to write some SQL to define our tables upfront (although you can easily change them later through the Supabase Web UI as well!)

We want to build a simple messaging app, so what we need is:

  • users: A table to keep track of all registered users
  • groups: Keep track of user-created chat groups
  • messages: All messages of our app

To create the tables, simply navigate to the SQL Editor menu item and click on + New query, paste in the SQL and hit RUN which hopefully executes without issues:


_19
create table users (
_19
id uuid not null primary key,
_19
email text
_19
);
_19
_19
create table groups (
_19
id bigint generated by default as identity primary key,
_19
creator uuid references public.users not null default auth.uid(),
_19
title text not null,
_19
created_at timestamp with time zone default timezone('utc'::text, now()) not null
_19
);
_19
_19
create table messages (
_19
id bigint generated by default as identity primary key,
_19
user_id uuid references public.users not null default auth.uid(),
_19
text text check (char_length(text) > 0),
_19
group_id bigint references groups on delete cascade not null,
_19
created_at timestamp with time zone default timezone('utc'::text, now()) not null
_19
);

After creating the tables we need to define policies to prevent unauthorized access to some of our data.

In this scenario we allow unauthenticated users to read group data - everything else like creating a group, or anything related to messages is only allowed for authenticated users.

Go ahead and also run the following query in the editor:


_25
-- Secure tables
_25
alter table users enable row level security;
_25
alter table groups enable row level security;
_25
alter table messages enable row level security;
_25
_25
-- User Policies
_25
create policy "Users can read the user email." on users
_25
for select using (true);
_25
_25
-- Group Policies
_25
create policy "Groups are viewable by everyone." on groups
_25
for select using (true);
_25
_25
create policy "Authenticated users can create groups." on groups for
_25
insert to authenticated with check (true);
_25
_25
create policy "The owner can delete a group." on groups for
_25
delete using (auth.uid() = creator);
_25
_25
-- Message Policies
_25
create policy "Authenticated users can read messages." on messages
_25
for select to authenticated using (true);
_25
_25
create policy "Authenticated users can create messages." on messages
_25
for insert to authenticated with check (true);

Now we also add a cool function that will automatically add user data after registration into our table. This is necessary if you want to keep track of some user information, because the Supabase auth table is an internal table that we can't access that easily.

Go ahead and run another SQL query in the editor now to create our trigger:


_13
-- Function for handling new users
_13
create or replace function public.handle_new_user()
_13
returns trigger as $$
_13
begin
_13
insert into public.users (id, email)
_13
values (new.id, new.email);
_13
return new;
_13
end;
_13
$$ language plpgsql security definer;
_13
_13
create trigger on_auth_user_created
_13
after insert on auth.users
_13
for each row execute procedure public.handle_new_user();

To wrap this up we want to enable realtime functionality of our database so we can get new messages instantly without another query.

For this we can activate the publication for our messages table by running one last query:


_10
begin;
_10
-- remove the supabase_realtime publication
_10
drop publication if exists supabase_realtime;
_10
_10
-- re-create the supabase_realtime publication with no tables and only for insert
_10
create publication supabase_realtime with (publish = 'insert');
_10
commit;
_10
_10
-- add a table to the publication
_10
alter publication supabase_realtime add table messages;

If you now open the Table Editor menu item you should see your three tables, and you can also check their RLS policies right from the web!

But enough SQL for today, let's write some Angular code.

Creating the Ionic Angular App

To get started with our Ionic app we can create a blank app without any additional pages and then install the Supabase Javascript client.

Besides that, we need some pages in our app for the different views, and services to keep our logic separated from the views. Finally we can also already generate a guard which we will use to protect internal pages later.

Go ahead now and run the following on your command line:


_15
ionic start supaAuth blank --type=angular
_15
npm install @supabase/supabase-js
_15
_15
# Add some pages
_15
ionic g page pages/login
_15
ionic g page pages/register
_15
ionic g page pages/groups
_15
ionic g page pages/messages
_15
_15
# Generate services
_15
ionic g service services/auth
_15
ionic g service services/data
_15
_15
# Add a guard to protect routes
_15
ionic g guard guards/auth --implements=CanActivate

Ionic (or the Angular CLI under the hood) will now create the routing entries for us, but we gonna fine tune them a bit.

First of all we want to pass a groupid to our messages page, and we also want to make sure that page is protected by the guard we created before.

Therefore bring up the src/app/app-routing.module.ts and change it to:


_36
import { AuthGuard } from './guards/auth.guard'
_36
import { NgModule } from '@angular/core'
_36
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
_36
_36
const routes: Routes = [
_36
{
_36
path: '',
_36
loadChildren: () => import('./pages/login/login.module').then((m) => m.LoginPageModule),
_36
},
_36
{
_36
path: 'register',
_36
loadChildren: () =>
_36
import('./pages/register/register.module').then((m) => m.RegisterPageModule),
_36
},
_36
{
_36
path: 'groups',
_36
loadChildren: () => import('./pages/groups/groups.module').then((m) => m.GroupsPageModule),
_36
},
_36
{
_36
path: 'groups/:groupid',
_36
loadChildren: () =>
_36
import('./pages/messages/messages.module').then((m) => m.MessagesPageModule),
_36
canActivate: [AuthGuard],
_36
},
_36
{
_36
path: '',
_36
redirectTo: 'home',
_36
pathMatch: 'full',
_36
},
_36
]
_36
_36
@NgModule({
_36
imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
_36
exports: [RouterModule],
_36
})
_36
export class AppRoutingModule {}

Now all paths are correct and the app starts on the login page, and the messages page can only be activated if that guard returns true - which it does by default, so we will take care of its implementation later.

To connect our app properly to Supabase we now need to grab the project URL and the public anon key from the settings page of your Supabase project.

You can find those values in your Supabase project by clicking on the Settings icon and then navigating to API where it shows your Project API keys.

This information now goes straight into the src/environments/environment.ts of our Ionic project:


_10
export const environment = {
_10
production: false,
_10
supabaseUrl: 'https://YOUR-APP.supabase.co',
_10
supabaseKey: 'YOUR-ANON-KEY',
_10
}

By the way: The anon key is safe to use in a frontend project since we have enabled RLS on our database tables!

Building the Public Pages of our App

The big first step is to create the "outside" pages which allow a user to perform different operations:

Ionic Login Page

Before we dive into the UI of these pages we should define all the required logic in a service to easily inject it into our pages.

Preparing our Supabase authentication service

Our service should call expose all the functions for registration and login but also handle the state of the current user with a BehaviorSubject so we can easily emit new values later when the user session changes.

We are also loading the session once "by hand" using getUser() since the onAuthStateChange event is usually not broadcasted when the app loads, and we want to load a stored session right when the app starts.

The relevant functions for our user authentication are all part of the supabase.auth object, which makes it easy to find all relevant (and even some unknown) features.

Additionally, we expose our current user as an Observable to the outside and add some helper functions to get the current user ID synchronously.

Now move on by changing the src/app/services/auth.service.ts to this:


_79
/* eslint-disable @typescript-eslint/naming-convention */
_79
import { Injectable } from '@angular/core'
_79
import { Router } from '@angular/router'
_79
import { isPlatform } from '@ionic/angular'
_79
import { createClient, SupabaseClient, User } from '@supabase/supabase-js'
_79
import { BehaviorSubject, Observable } from 'rxjs'
_79
import { environment } from '../../environments/environment'
_79
_79
@Injectable({
_79
providedIn: 'root',
_79
})
_79
export class AuthService {
_79
private supabase: SupabaseClient
_79
private currentUser: BehaviorSubject<User | boolean> = new BehaviorSubject(null)
_79
_79
constructor(private router: Router) {
_79
this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)
_79
_79
this.supabase.auth.onAuthStateChange((event, sess) => {
_79
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
_79
console.log('SET USER')
_79
_79
this.currentUser.next(sess.user)
_79
} else {
_79
this.currentUser.next(false)
_79
}
_79
})
_79
_79
// Trigger initial session load
_79
this.loadUser()
_79
}
_79
_79
async loadUser() {
_79
if (this.currentUser.value) {
_79
// User is already set, no need to do anything else
_79
return
_79
}
_79
const user = await this.supabase.auth.getUser()
_79
_79
if (user.data.user) {
_79
this.currentUser.next(user.data.user)
_79
} else {
_79
this.currentUser.next(false)
_79
}
_79
}
_79
_79
signUp(credentials: { email; password }) {
_79
return this.supabase.auth.signUp(credentials)
_79
}
_79
_79
signIn(credentials: { email; password }) {
_79
return this.supabase.auth.signInWithPassword(credentials)
_79
}
_79
_79
sendPwReset(email) {
_79
return this.supabase.auth.resetPasswordForEmail(email)
_79
}
_79
_79
async signOut() {
_79
await this.supabase.auth.signOut()
_79
this.router.navigateByUrl('/', { replaceUrl: true })
_79
}
_79
_79
getCurrentUser(): Observable<User | boolean> {
_79
return this.currentUser.asObservable()
_79
}
_79
_79
getCurrentUserId(): string {
_79
if (this.currentUser.value) {
_79
return (this.currentUser.value as User).id
_79
} else {
_79
return null
_79
}
_79
}
_79
_79
signInWithEmail(email: string) {
_79
return this.supabase.auth.signInWithOtp({ email })
_79
}
_79
}

That's enough logic for our pages, so let's put that code to use.

Creating the Login Page

Although we first need to register a user, we begin with the login page. We can even "register" a user from here since we will offer the easiest sign-in option with magic link authentication that only requires an email, and a user entry will be added to our users table thanks to our trigger function.

To create a decent UX we will add a reactive form with Angular, for which we first need to import the ReactiveFormsModule into the src/app/pages/login/login.module.ts:


_15
import { NgModule } from '@angular/core'
_15
import { CommonModule } from '@angular/common'
_15
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
_15
_15
import { IonicModule } from '@ionic/angular'
_15
_15
import { LoginPageRoutingModule } from './login-routing.module'
_15
_15
import { LoginPage } from './login.page'
_15
_15
@NgModule({
_15
imports: [CommonModule, FormsModule, IonicModule, LoginPageRoutingModule, ReactiveFormsModule],
_15
declarations: [LoginPage],
_15
})
_15
export class LoginPageModule {}

Now we can define the form from code and add all required functions to our page, which becomes super easy thanks to our previous implementation of the service.

Right inside the constructor of the login page, we will also subscribe to the getCurrentUser() Observable and if we do have a valid user token, we can directly route the user forward to the groups overview page.

On login, we now only need to show some loading spinner and call the according function of our service, and since we already listen to the user in our constructor we don't even need to add any more logic for routing at this point and only show an alert in case something goes wrong.

Go ahead by changing the src/app/pages/login/login.page.ts to this now:


_61
import { AuthService } from './../../services/auth.service'
_61
import { Component } from '@angular/core'
_61
import { FormBuilder, Validators } from '@angular/forms'
_61
import { Router } from '@angular/router'
_61
import { LoadingController, AlertController } from '@ionic/angular'
_61
_61
@Component({
_61
selector: 'app-login',
_61
templateUrl: './login.page.html',
_61
styleUrls: ['./login.page.scss'],
_61
})
_61
export class LoginPage {
_61
credentials = this.fb.nonNullable.group({
_61
email: ['', Validators.required],
_61
password: ['', Validators.required],
_61
})
_61
_61
constructor(
_61
private fb: FormBuilder,
_61
private authService: AuthService,
_61
private loadingController: LoadingController,
_61
private alertController: AlertController,
_61
private router: Router
_61
) {
_61
this.authService.getCurrentUser().subscribe((user) => {
_61
if (user) {
_61
this.router.navigateByUrl('/groups', { replaceUrl: true })
_61
}
_61
})
_61
}
_61
_61
get email() {
_61
return this.credentials.controls.email
_61
}
_61
_61
get password() {
_61
return this.credentials.controls.password
_61
}
_61
_61
async login() {
_61
const loading = await this.loadingController.create()
_61
await loading.present()
_61
_61
this.authService.signIn(this.credentials.getRawValue()).then(async (data) => {
_61
await loading.dismiss()
_61
_61
if (data.error) {
_61
this.showAlert('Login failed', data.error.message)
_61
}
_61
})
_61
}
_61
_61
async showAlert(title, msg) {
_61
const alert = await this.alertController.create({
_61
header: title,
_61
message: msg,
_61
buttons: ['OK'],
_61
})
_61
await alert.present()
_61
}
_61
}

Additionally, we need a function to reset the password and trigger the magic link authentication.

In both cases, we can use an Ionic alert with one input field. This field can be accessed inside the handler of a button, and so we pass the value to the according function of our service and show another message after successfully submitting the request.

Go ahead with our login page and now also add these two functions:


_79
async forgotPw() {
_79
const alert = await this.alertController.create({
_79
header: "Receive a new password",
_79
message: "Please insert your email",
_79
inputs: [
_79
{
_79
type: "email",
_79
name: "email",
_79
},
_79
],
_79
buttons: [
_79
{
_79
text: "Cancel",
_79
role: "cancel",
_79
},
_79
{
_79
text: "Reset password",
_79
handler: async (result) => {
_79
const loading = await this.loadingController.create();
_79
await loading.present();
_79
const { data, error } = await this.authService.sendPwReset(
_79
result.email
_79
);
_79
await loading.dismiss();
_79
_79
if (error) {
_79
this.showAlert("Failed", error.message);
_79
} else {
_79
this.showAlert(
_79
"Success",
_79
"Please check your emails for further instructions!"
_79
);
_79
}
_79
},
_79
},
_79
],
_79
});
_79
await alert.present();
_79
}
_79
_79
async getMagicLink() {
_79
const alert = await this.alertController.create({
_79
header: "Get a Magic Link",
_79
message: "We will send you a link to magically log in!",
_79
inputs: [
_79
{
_79
type: "email",
_79
name: "email",
_79
},
_79
],
_79
buttons: [
_79
{
_79
text: "Cancel",
_79
role: "cancel",
_79
},
_79
{
_79
text: "Get Magic Link",
_79
handler: async (result) => {
_79
const loading = await this.loadingController.create();
_79
await loading.present();
_79
const { data, error } = await this.authService.signInWithEmail(
_79
result.email
_79
);
_79
await loading.dismiss();
_79
_79
if (error) {
_79
this.showAlert("Failed", error.message);
_79
} else {
_79
this.showAlert(
_79
"Success",
_79
"Please check your emails for further instructions!"
_79
);
_79
}
_79
},
_79
},
_79
],
_79
});
_79
await alert.present();
_79
}

That's enough to handle everything, so now we just need a simple UI for our form and buttons.

Since recent Ionic versions, we can use the new error slot of the Ionic item, which we can use to present specific error messages in case one field of our reactive form is invalid.

We can easily access the email and password control since we exposed them with their own get function in our class before!

Below the form, we simply stack all of our buttons to trigger the actions and give them different colors.

Bring up the src/app/pages/login/login.page.html now and change it to:


_63
<ion-header>
_63
<ion-toolbar color="primary">
_63
<ion-title>Supa Chat</ion-title>
_63
</ion-toolbar>
_63
</ion-header>
_63
_63
<ion-content scrollY="false">
_63
<ion-card>
_63
<ion-card-content>
_63
<form (ngSubmit)="login()" [formGroup]="credentials">
_63
<ion-item>
_63
<ion-label position="stacked">Your Email</ion-label>
_63
<ion-input
_63
type="email"
_63
inputmode="email"
_63
placeholder="Email"
_63
formControlName="email"
_63
></ion-input>
_63
<ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors"
_63
>Please insert your email</ion-note
_63
>
_63
</ion-item>
_63
<ion-item>
_63
<ion-label position="stacked">Password</ion-label>
_63
<ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
_63
<ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors"
_63
>Please insert your password</ion-note
_63
>
_63
</ion-item>
_63
<ion-button type="submit" expand="block" strong="true" [disabled]="!credentials.valid"
_63
>Sign in</ion-button
_63
>
_63
_63
<div class="ion-margin-top">
_63
<ion-button
_63
type="button"
_63
expand="block"
_63
color="primary"
_63
fill="outline"
_63
routerLink="register"
_63
>
_63
<ion-icon name="person-outline" slot="start"></ion-icon>
_63
Create Account
_63
</ion-button>
_63
_63
<ion-button type="button" expand="block" color="secondary" (click)="forgotPw()">
_63
<ion-icon name="key-outline" slot="start"></ion-icon>
_63
Forgot password?
_63
</ion-button>
_63
_63
<ion-button type="button" expand="block" color="tertiary" (click)="getMagicLink()">
_63
<ion-icon name="mail-outline" slot="start"></ion-icon>
_63
Get a Magic Link
_63
</ion-button>
_63
<ion-button type="button" expand="block" color="warning" routerLink="groups">
_63
<ion-icon name="arrow-forward" slot="start"></ion-icon>
_63
Start without account
_63
</ion-button>
_63
</div>
_63
</form>
_63
</ion-card-content>
_63
</ion-card>
_63
</ion-content>

To give our login a bit nicer touch, let's also add a background image and some additional padding by adding the following to the src/app/pages/login/login.page.scss:


_10
ion-content {
_10
--padding-top: 20%;
_10
--padding-start: 5%;
_10
--padding-end: 5%;
_10
--background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.7)),
_10
url('https://images.unsplash.com/photo-1508964942454-1a56651d54ac?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80')
_10
no-repeat;
_10
}

At this point you can already try our authentication by using the magic link button, so run the Ionic app with ionic serve for the web preview and then enter your email.

Magic Link Alert

Make sure you use a valid email since you do need to click on the link in the email. If everything works correctly you should receive an email like this quickly:

Magic Link Email

Keep in mind that you can easily change those email templates inside the settings of your Supabase project.

If you now inspect the link and copy the URL, you should see an URL that points to your Supabase project and after some tokens there is a query param &redirect_to=http://localhost:8100 which directly brings a user back into our local running app!

This will be even more important later when we implement magic link authentication for native apps so stick around until the end.

Creating the Registration Page

Some users will still prefer the good old registration, so let's provide them with a decent page for that.

The setup is almost the same as for the login, so we start again by adding the ReactiveFormsModule to the src/app/pages/register/register.module.ts:


_15
import { NgModule } from '@angular/core'
_15
import { CommonModule } from '@angular/common'
_15
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
_15
_15
import { IonicModule } from '@ionic/angular'
_15
_15
import { RegisterPageRoutingModule } from './register-routing.module'
_15
_15
import { RegisterPage } from './register.page'
_15
_15
@NgModule({
_15
imports: [CommonModule, FormsModule, IonicModule, RegisterPageRoutingModule, ReactiveFormsModule],
_15
declarations: [RegisterPage],
_15
})
_15
export class RegisterPageModule {}

Now we define our form just like before, and in the createAccount() we use the functionality of our initially created service to sign up a user.

Bring up the src/app/pages/register/register.page.ts and change it to:


_57
import { Component } from '@angular/core'
_57
import { Validators, FormBuilder } from '@angular/forms'
_57
import { LoadingController, AlertController, NavController } from '@ionic/angular'
_57
import { AuthService } from 'src/app/services/auth.service'
_57
_57
@Component({
_57
selector: 'app-register',
_57
templateUrl: './register.page.html',
_57
styleUrls: ['./register.page.scss'],
_57
})
_57
export class RegisterPage {
_57
credentials = this.fb.nonNullable.group({
_57
email: ['', [Validators.required, Validators.email]],
_57
password: ['', [Validators.required, Validators.minLength(6)]],
_57
})
_57
_57
constructor(
_57
private fb: FormBuilder,
_57
private authService: AuthService,
_57
private loadingController: LoadingController,
_57
private alertController: AlertController,
_57
private navCtrl: NavController
_57
) {}
_57
_57
get email() {
_57
return this.credentials.controls.email
_57
}
_57
_57
get password() {
_57
return this.credentials.controls.password
_57
}
_57
_57
async createAccount() {
_57
const loading = await this.loadingController.create()
_57
await loading.present()
_57
_57
this.authService.signUp(this.credentials.getRawValue()).then(async (data) => {
_57
await loading.dismiss()
_57
_57
if (data.error) {
_57
this.showAlert('Registration failed', data.error.message)
_57
} else {
_57
this.showAlert('Signup success', 'Please confirm your email now!')
_57
this.navCtrl.navigateBack('')
_57
}
_57
})
_57
}
_57
_57
async showAlert(title, msg) {
_57
const alert = await this.alertController.create({
_57
header: title,
_57
message: msg,
_57
buttons: ['OK'],
_57
})
_57
await alert.present()
_57
}
_57
}

The view for that page follows the same structure as the login, so let's continue with the src/app/pages/register/register.page.html now:


_46
<ion-header>
_46
<ion-toolbar color="primary">
_46
<ion-buttons slot="start">
_46
<ion-back-button defaultHref="/"></ion-back-button>
_46
</ion-buttons>
_46
<ion-title>Supa Chat</ion-title>
_46
</ion-toolbar>
_46
</ion-header>
_46
_46
<ion-content scrollY="false">
_46
<ion-card>
_46
<ion-card-content>
_46
<form (ngSubmit)="createAccount()" [formGroup]="credentials">
_46
<ion-item>
_46
<ion-label position="stacked">Your Email</ion-label>
_46
<ion-input
_46
type="email"
_46
inputmode="email"
_46
placeholder="Email"
_46
formControlName="email"
_46
></ion-input>
_46
<ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors"
_46
>Please insert a valid email</ion-note
_46
>
_46
</ion-item>
_46
<ion-item>
_46
<ion-label position="stacked">Password</ion-label>
_46
<ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
_46
<ion-note
_46
slot="error"
_46
*ngIf="(password.dirty || password.touched) && password.errors?.required"
_46
>Please insert a password</ion-note
_46
>
_46
<ion-note
_46
slot="error"
_46
*ngIf="(password.dirty || password.touched) && password.errors?.minlength"
_46
>Minlength 6 characters</ion-note
_46
>
_46
</ion-item>
_46
<ion-button type="submit" expand="block" strong="true" [disabled]="!credentials.valid"
_46
>Create my account</ion-button
_46
>
_46
</form>
_46
</ion-card-content>
_46
</ion-card>
_46
</ion-content>

And just like before we want to have the background image so also bring in the same snippet for styling the page into the src/app/pages/register/register.page.scss:


_10
ion-content {
_10
--padding-top: 20%;
_10
--padding-start: 5%;
_10
--padding-end: 5%;
_10
--background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.7)),
_10
url('https://images.unsplash.com/photo-1508964942454-1a56651d54ac?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80')
_10
no-repeat;
_10
}

As a result, we have a clean registration page with decent error messages!

Ionic Registration Page

Give the default registration process a try with another email, and you should see another user inside the Authentication area of your Supabase project as well as inside the users table inside the Table Editor.

Before we make the magic link authentication work on mobile devices, let's focus on the internal pages and functionality of our app.

Implementing the Groups Page

The groups screen is the first "inside" screen, but it's not protected by default: On the login page we have the option to start without an account, so unauthorized users can enter this page - but they should only be allowed to see the chat groups, nothing more.

App Groups Page

Authenticated users should have controls to create a new group and to sign out, but before we get to the UI we need a way to interact with our Supabase tables.

Adding a Data Service

We already generated a service in the beginning, and here we can add the logic to create a connection to Supabase and a first function to create a new row in our groups table and to load all groups.

Creating a group requires just a title, and we can gather the user ID from our authentication service to then call the insert() function from the Supabase client to create a new record that we then return to the caller.

When we want to get a list of groups, we can use select() but since we have a foreign key that references the users table, we need to join that information so instead of just having the creator field we end up getting the actual email for that ID instead!

Go ahead now and start the src/app/services/data.service.ts like this:


_43
/* eslint-disable @typescript-eslint/naming-convention */
_43
import { Injectable } from '@angular/core'
_43
import { SupabaseClient, createClient } from '@supabase/supabase-js'
_43
import { Subject } from 'rxjs'
_43
import { environment } from 'src/environments/environment'
_43
_43
const GROUPS_DB = 'groups'
_43
const MESSAGES_DB = 'messages'
_43
_43
export interface Message {
_43
created_at: string
_43
group_id: number
_43
id: number
_43
text: string
_43
user_id: string
_43
}
_43
_43
@Injectable({
_43
providedIn: 'root',
_43
})
_43
export class DataService {
_43
private supabase: SupabaseClient
_43
_43
constructor() {
_43
this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)
_43
}
_43
_43
getGroups() {
_43
return this.supabase
_43
.from(GROUPS_DB)
_43
.select(`title,id, users:creator ( email )`)
_43
.then((result) => result.data)
_43
}
_43
_43
async createGroup(title) {
_43
const newgroup = {
_43
creator: (await this.supabase.auth.getUser()).data.user.id,
_43
title,
_43
}
_43
_43
return this.supabase.from(GROUPS_DB).insert(newgroup).select().single()
_43
}
_43
}

Nothing fancy, so let's move on to the UI of the groups page.

Creating the Groups Page

When we enter our page, we first load all groups through our service using the ionViewWillEnter Ionic lifecycle event.

The function to create a group follows the same logic as our alerts before where we have one input field that can be accessed in the handler of a button.

At that point, we will create a new group with the information, but also reload our list of groups and then navigate a user directly into the new group by using the ID of that created record.

This will then bring a user to the messages page since we defined the route "/groups/:groupid" initially in our routing!

Now go ahead and bring up the src/app/pages/groups/groups.page.ts and change it to:


_75
import { Router } from '@angular/router'
_75
import { AuthService } from './../../services/auth.service'
_75
import { AlertController, NavController, LoadingController } from '@ionic/angular'
_75
import { DataService } from './../../services/data.service'
_75
import { Component, OnInit } from '@angular/core'
_75
_75
@Component({
_75
selector: 'app-groups',
_75
templateUrl: './groups.page.html',
_75
styleUrls: ['./groups.page.scss'],
_75
})
_75
export class GroupsPage implements OnInit {
_75
user = this.authService.getCurrentUser()
_75
groups = []
_75
_75
constructor(
_75
private authService: AuthService,
_75
private data: DataService,
_75
private alertController: AlertController,
_75
private loadingController: LoadingController,
_75
private navController: NavController,
_75
private router: Router
_75
) {}
_75
_75
ngOnInit() {}
_75
_75
async ionViewWillEnter() {
_75
this.groups = await this.data.getGroups()
_75
}
_75
_75
async createGroup() {
_75
const alert = await this.alertController.create({
_75
header: 'Start Chat Group',
_75
message: 'Enter a name for your group. Note that all groups are public in this app!',
_75
inputs: [
_75
{
_75
type: 'text',
_75
name: 'title',
_75
placeholder: 'My cool group',
_75
},
_75
],
_75
buttons: [
_75
{
_75
text: 'Cancel',
_75
role: 'cancel',
_75
},
_75
{
_75
text: 'Create group',
_75
handler: async (data) => {
_75
const loading = await this.loadingController.create()
_75
await loading.present()
_75
_75
const newGroup = await this.data.createGroup(data.title)
_75
if (newGroup) {
_75
this.groups = await this.data.getGroups()
_75
await loading.dismiss()
_75
_75
this.router.navigateByUrl(`/groups/${newGroup.data.id}`)
_75
}
_75
},
_75
},
_75
],
_75
})
_75
_75
await alert.present()
_75
}
_75
_75
signOut() {
_75
this.authService.signOut()
_75
}
_75
_75
openLogin() {
_75
this.navController.navigateBack('/')
_75
}
_75
}

The UI of that page is rather simple since we can iterate those groups in a list and create an item with their title and the creator email easily.

Because unauthenticated users can enter this page as well we add checks to the user Observable to the buttons so only authenticated users see the FAB at the bottom and have the option to sign out!

Remember that protecting the UI of our page is just one piece of the puzzle, real security is implemented at the server level!

In our case, we did this through the RLS we defined in the beginning.

Continue with the src/app/pages/groups/groups.page.html now and change it to:


_28
<ion-header>
_28
<ion-toolbar color="primary">
_28
<ion-title>Supa Chat Groups</ion-title>
_28
<ion-buttons slot="end">
_28
<ion-button (click)="signOut()" *ngIf="user | async">
_28
<ion-icon name="log-out-outline" slot="icon-only"></ion-icon>
_28
</ion-button>
_28
_28
<ion-button (click)="openLogin()" *ngIf="(user | async) === false"> Sign in </ion-button>
_28
</ion-buttons>
_28
</ion-toolbar>
_28
</ion-header>
_28
_28
<ion-content>
_28
<ion-list>
_28
<ion-item *ngFor="let group of groups" [routerLink]="[group.id]" button>
_28
<ion-label
_28
>{{group.title }}
_28
<p>By {{group.users.email}}</p>
_28
</ion-label>
_28
</ion-item>
_28
</ion-list>
_28
<ion-fab vertical="bottom" horizontal="end" slot="fixed" *ngIf="user | async">
_28
<ion-fab-button (click)="createGroup()">
_28
<ion-icon name="add"></ion-icon>
_28
</ion-fab-button>
_28
</ion-fab>
_28
</ion-content>

At this point, you should be able to create your own chat groups, and you should be brought to the page automatically or also enter it from the list manually afterward.

Inside the ULR you should see the ID of that group - and that's all we need to retrieve information about it and build a powerful chat view now!

Building the Chat Page with Realtime Feature

We left out realtime features on the groups page so we manually need to load the lists again, but only to keep the tutorial a bit shorter.

Because now on our messages page we want to have that functionality, and because we enabled the publication for the messages table through SQL in the beginning we are already prepared.

To get started we need some more functions in our service, and we add another realtimeChannel variable.

Additionally, we now want to retrieve group information by ID (from the URL!), add messages to the messages table and retrieve the last 25 messages.

All of that is pretty straightforward, and the only fancy function is listenToGroup() which returns an Observable of changes.

We can create this on our own by handling postgres_changes events on the messages table. Inside the callback function, we can handle all CRUD events, but in our case, we will (for simplicity) only handle the case of an added record.

That means when a message is added to the table, we want to return that new record to whoever is subscribed to this channel - but because a message has the user as a foreign key we first need to make another call to the messages table to retrieve the right information and then emit the new value on our Subject.

For all of that bring up the src/app/services/data.service.ts and change it to:


_109
/* eslint-disable @typescript-eslint/naming-convention */
_109
import { Injectable } from '@angular/core'
_109
import { SupabaseClient, createClient, RealtimeChannel } from '@supabase/supabase-js'
_109
import { Subject } from 'rxjs'
_109
import { environment } from 'src/environments/environment'
_109
_109
const GROUPS_DB = 'groups'
_109
const MESSAGES_DB = 'messages'
_109
_109
export interface Message {
_109
created_at: string
_109
group_id: number
_109
id: number
_109
text: string
_109
user_id: string
_109
}
_109
_109
@Injectable({
_109
providedIn: 'root',
_109
})
_109
export class DataService {
_109
private supabase: SupabaseClient
_109
// ADD
_109
private realtimeChannel: RealtimeChannel
_109
_109
constructor() {
_109
this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)
_109
}
_109
_109
getGroups() {
_109
return this.supabase
_109
.from(GROUPS_DB)
_109
.select(`title,id, users:creator ( email )`)
_109
.then((result) => result.data)
_109
}
_109
_109
async createGroup(title) {
_109
const newgroup = {
_109
creator: (await this.supabase.auth.getUser()).data.user.id,
_109
title,
_109
}
_109
_109
return this.supabase.from(GROUPS_DB).insert(newgroup).select().single()
_109
}
_109
_109
// ADD NEW FUNCTIONS
_109
getGroupById(id) {
_109
return this.supabase
_109
.from(GROUPS_DB)
_109
.select(`created_at, title, id, users:creator ( email, id )`)
_109
.match({ id })
_109
.single()
_109
.then((result) => result.data)
_109
}
_109
_109
async addGroupMessage(groupId, message) {
_109
const newMessage = {
_109
text: message,
_109
user_id: (await this.supabase.auth.getUser()).data.user.id,
_109
group_id: groupId,
_109
}
_109
_109
return this.supabase.from(MESSAGES_DB).insert(newMessage)
_109
}
_109
_109
getGroupMessages(groupId) {
_109
return this.supabase
_109
.from(MESSAGES_DB)
_109
.select(`created_at, text, id, users:user_id ( email, id )`)
_109
.match({ group_id: groupId })
_109
.limit(25) // Limit to 25 messages for our app
_109
.then((result) => result.data)
_109
}
_109
_109
listenToGroup(groupId) {
_109
const changes = new Subject()
_109
_109
this.realtimeChannel = this.supabase
_109
.channel('public:messages')
_109
.on(
_109
'postgres_changes',
_109
{ event: '*', schema: 'public', table: 'messages' },
_109
async (payload) => {
_109
console.log('DB CHANGE: ', payload)
_109
_109
if (payload.new && (payload.new as Message).group_id === +groupId) {
_109
const msgId = (payload.new as any).id
_109
_109
const msg = await this.supabase
_109
.from(MESSAGES_DB)
_109
.select(`created_at, text, id, users:user_id ( email, id )`)
_109
.match({ id: msgId })
_109
.single()
_109
.then((result) => result.data)
_109
changes.next(msg)
_109
}
_109
}
_109
)
_109
.subscribe()
_109
_109
return changes.asObservable()
_109
}
_109
_109
unsubscribeGroupChanges() {
_109
if (this.realtimeChannel) {
_109
this.supabase.removeChannel(this.realtimeChannel)
_109
}
_109
}
_109
}

By handling the realtime logic here and only returning an Observable we make it super easy for our view.

The next step is to load the group information by accessing the groupid from the URL, then getting the last 25 messages and finally subscribing to listenToGroup() and pushing every new message into our local messages array.

After the view is initialized we can also scroll to the bottom of our ion-content to show the latest message.

Finally, we need to make sure we end our realtime listening when we leave the page or the page is destroyed.

Bring up the src/app/pages/messages/messages.page.ts and change it to:


_54
import { AuthService } from './../../services/auth.service'
_54
import { DataService } from './../../services/data.service'
_54
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
_54
import { ActivatedRoute } from '@angular/router'
_54
import { IonContent } from '@ionic/angular'
_54
_54
@Component({
_54
selector: 'app-messages',
_54
templateUrl: './messages.page.html',
_54
styleUrls: ['./messages.page.scss'],
_54
})
_54
export class MessagesPage implements OnInit, AfterViewInit, OnDestroy {
_54
@ViewChild(IonContent) content: IonContent
_54
group = null
_54
messages = []
_54
currentUserId = null
_54
messageText = ''
_54
_54
constructor(
_54
private route: ActivatedRoute,
_54
private data: DataService,
_54
private authService: AuthService
_54
) {}
_54
_54
async ngOnInit() {
_54
const groupid = this.route.snapshot.paramMap.get('groupid')
_54
this.group = await this.data.getGroupById(groupid)
_54
this.currentUserId = this.authService.getCurrentUserId()
_54
this.messages = await this.data.getGroupMessages(groupid)
_54
this.data.listenToGroup(groupid).subscribe((msg) => {
_54
this.messages.push(msg)
_54
setTimeout(() => {
_54
this.content.scrollToBottom(200)
_54
}, 100)
_54
})
_54
}
_54
_54
ngAfterViewInit(): void {
_54
setTimeout(() => {
_54
this.content.scrollToBottom(200)
_54
}, 300)
_54
}
_54
_54
loadMessages() {}
_54
_54
async sendMessage() {
_54
await this.data.addGroupMessage(this.group.id, this.messageText)
_54
this.messageText = ''
_54
}
_54
_54
ngOnDestroy(): void {
_54
this.data.unsubscribeGroupChanges()
_54
}
_54
}

That's all we need to handle realtime logic and add new messages to our Supabase table!

Sometimes life can be that easy.

For the view of that page, we need to distinguish between messages we sent, and messages sent from other users.

We can achieve this by comparing the user ID of a message with our currently authenticated user id, and we position our messages with an offset of 2 so they appear on the right hand side of the screen with a slightly different styling.

For this, open up the src/app/pages/messages/messages.page.html and change it to:


_48
<ion-header>
_48
<ion-toolbar color="primary">
_48
<ion-buttons slot="start">
_48
<ion-back-button defaultHref="/groups"></ion-back-button>
_48
</ion-buttons>
_48
<ion-title>{{ group?.title}}</ion-title>
_48
</ion-toolbar>
_48
</ion-header>
_48
_48
<ion-content class="ion-padding">
_48
<ion-row *ngFor="let message of messages">
_48
<ion-col size="10" *ngIf="message.users.id !== currentUserId" class="message other-message">
_48
<span>{{ message.text }} </span>
_48
_48
<div class="time ion-text-right"><br />{{ message.created_at | date:'shortTime' }}</div>
_48
</ion-col>
_48
_48
<ion-col
_48
offset="2"
_48
size="10"
_48
*ngIf="message.users.id === currentUserId"
_48
class="message my-message"
_48
>
_48
<span>{{ message.text }} </span>
_48
<div class="time ion-text-right"><br />{{ message.created_at | date:'shortTime' }}</div>
_48
</ion-col>
_48
</ion-row>
_48
</ion-content>
_48
_48
<ion-footer>
_48
<ion-toolbar color="light">
_48
<ion-row class="ion-align-items-center">
_48
<ion-col size="10">
_48
<ion-textarea
_48
class="message-input"
_48
autoGrow="true"
_48
rows="1"
_48
[(ngModel)]="messageText"
_48
></ion-textarea>
_48
</ion-col>
_48
<ion-col size="2" class="ion-text-center">
_48
<ion-button fill="clear" (click)="sendMessage()">
_48
<ion-icon slot="icon-only" name="send-outline" color="primary" size="large"></ion-icon>
_48
</ion-button>
_48
</ion-col>
_48
</ion-row>
_48
</ion-toolbar>
_48
</ion-footer>

For an even more advanced chat UI chat out the examples of Built with Ionic!

Now we can add the finishing touches to that screen with some CSS to give the page a background pattern image and styling for the messages inside the src/app/pages/messages/messages.page.scss:


_44
ion-content {
_44
--background: url('../../../assets/pattern.png') no-repeat;
_44
}
_44
_44
.message-input {
_44
border: 1px solid #c3c3c3;
_44
border-radius: 20px;
_44
background: #fff;
_44
box-shadow: 2px 2px 5px 0px rgb(0 0 0 / 5%);
_44
}
_44
_44
ion-textarea {
_44
--padding-start: 20px;
_44
--padding-top: 4px;
_44
--padding-bottom: 4px;
_44
_44
min-height: 30px;
_44
}
_44
_44
.message {
_44
padding: 10px !important;
_44
border-radius: 10px !important;
_44
margin-bottom: 8px !important;
_44
_44
img {
_44
width: 100%;
_44
}
_44
}
_44
_44
.my-message {
_44
background: #dbf7c5;
_44
color: #000;
_44
}
_44
_44
.other-message {
_44
background: #fff;
_44
color: #000;
_44
}
_44
_44
.time {
_44
color: #cacaca;
_44
float: right;
_44
font-size: small;
_44
}

You can find the full code of this tutorial on Github including the file for that pattern so your page looks almost like WhatsApp!

Ionic Supabase Chat Page

Now we have a well-working Ionic app with Supabase authentication and database integration, but there are two small but important additions we still need to make.

Protecting internal Pages

Right now everyone could access the messages page, but we wanted to make this page only available for authenticated users.

To protect the page (and all other pages you might want to protect) we now implement the guard that we generated in the beginning.

That guard will check the Observable of our service, filter out the initial state and then see if a user is allowed to access a page or not.

Bring up our src/app/guards/auth.guard.ts and change it to this:


_38
import { AuthService } from './../services/auth.service'
_38
import { Injectable } from '@angular/core'
_38
import { ActivatedRouteSnapshot, CanActivate, Router, UrlTree } from '@angular/router'
_38
import { Observable } from 'rxjs'
_38
import { filter, map, take } from 'rxjs/operators'
_38
import { ToastController } from '@ionic/angular'
_38
_38
@Injectable({
_38
providedIn: 'root',
_38
})
_38
export class AuthGuard implements CanActivate {
_38
constructor(
_38
private auth: AuthService,
_38
private router: Router,
_38
private toastController: ToastController
_38
) {}
_38
_38
canActivate(route: ActivatedRouteSnapshot): Observable<boolean | UrlTree> {
_38
return this.auth.getCurrentUser().pipe(
_38
filter((val) => val !== null), // Filter out initial Behavior subject value
_38
take(1), // Otherwise the Observable doesn't complete!
_38
map((isAuthenticated) => {
_38
if (isAuthenticated) {
_38
return true
_38
} else {
_38
this.toastController
_38
.create({
_38
message: 'You are not allowed to access this!',
_38
duration: 2000,
_38
})
_38
.then((toast) => toast.present())
_38
_38
return this.router.createUrlTree(['/groups'])
_38
}
_38
})
_38
)
_38
}
_38
}

In case the user is not allowed to activate a page, we display a toast and at the same time route to the groups page since that page is visible to everyone. Normally you might even bring users simply back to the login screen if you wanted to protect all internal pages of your Ionic app.

Ionic unauthorized message

We already applied this guard to our routing in the beginning, but now it finally serves the real purpose!

At last, we come to a challenging topic, which is handling the magic link on a mobile phone.

The problem is, that the link that a user receives has a callback to a URL, but if you open that link in your email client on a phone it's not opening your native app!

But we can change this by defining a custom URL scheme for our app like "supachat://", and then use that URL as the callback URL for magic link authentication.

First, make sure you add the native platforms with Capacitor to your project:


_10
ionic build
_10
ionic cap add ios
_10
ionic cap add android

Inside the new native projects we need to define the URL scheme, so for iOS bring up the ios/App/App/Info.plist and insert another block:


_10
<key>CFBundleURLTypes</key>
_10
<array>
_10
<dict>
_10
<key>CFBundleURLSchemes</key>
_10
<array>
_10
<string>supachat</string>
_10
</array>
_10
</dict>
_10
</array>

For Android, we first define a new string inside the android/app/src/main/res/values/strings.xml:


_10
<string name="custom_url_scheme">supachat</string>

Now we can update the android/app/src/main/AndroidManifest.xml and add an intent-filter inside which uses the custom_url_scheme value:


_10
<intent-filter android:autoVerify="true">
_10
<action android:name="android.intent.action.VIEW" />
_10
<category android:name="android.intent.category.DEFAULT" />
_10
<category android:name="android.intent.category.BROWSABLE" />
_10
<data android:scheme="@string/custom_url_scheme" />
_10
</intent-filter>

By default, Supabase will use the host for the redirect URL, which works great if the request comes from a website.

That means we only want to change the behavior for native apps, so we can use the isPlatform() check in our app and use "supachat://login"as the redirect URL instead.

For this, bring up the src/app/services/auth.service.ts and update our signInWithEmail and add another new function:


_14
signInWithEmail(email: string) {
_14
const redirectTo = isPlatform("capacitor")
_14
? "supachat://login"
_14
: `${window.location.origin}/groups`;
_14
_14
return this.supabase.auth.signInWithOtp({
_14
email,
_14
options: { emailRedirectTo: redirectTo },
_14
});
_14
}
_14
_14
async setSession(access_token, refresh_token) {
_14
return this.supabase.auth.setSession({ access_token, refresh_token });
_14
}

This second function is required since we need to manually set our session based on the other tokens of the magic link URL.

If we now click on the link in an email, it will open the browser the first time but then asks if we want to open our native app.

This is cool, but it's not loading the user information correctly, but we can easily do this manually!

Eventually, our app is opened with an URL that looks like this:


_10
supachat://login#access_token=A-TOKEN&expires_in=3600&refresh_token=REF-TOKEN&token_type=bearer&type=magiclink

To set our session we now simply need to extract the access_token and refresh_token from that URL, and we can do this by adding a listener to the appUrlOpen event of the Capacitor app plugin.

Once we got that information we can call the setSession function that we just added to our service, and then route the user forward to the groups page!

To achieve this, bring up the src/app/app.component.ts and change it to:


_31
import { AuthService } from 'src/app/services/auth.service'
_31
import { Router } from '@angular/router'
_31
import { Component, NgZone } from '@angular/core'
_31
import { App, URLOpenListenerEvent } from '@capacitor/app'
_31
_31
@Component({
_31
selector: 'app-root',
_31
templateUrl: 'app.component.html',
_31
styleUrls: ['app.component.scss'],
_31
})
_31
export class AppComponent {
_31
constructor(private zone: NgZone, private router: Router, private authService: AuthService) {
_31
this.setupListener()
_31
}
_31
_31
setupListener() {
_31
App.addListener('appUrlOpen', async (data: URLOpenListenerEvent) => {
_31
console.log('app opened with URL: ', data)
_31
_31
const openUrl = data.url
_31
const access = openUrl.split('#access_token=').pop().split('&')[0]
_31
const refresh = openUrl.split('&refresh_token=').pop().split('&')[0]
_31
_31
await this.authService.setSession(access, refresh)
_31
_31
this.zone.run(() => {
_31
this.router.navigateByUrl('/groups', { replaceUrl: true })
_31
})
_31
})
_31
}
_31
}

If you would run the app now it still wouldn't work, because we haven't added our custom URL scheme as an allowed URL for redirecting to!

To finish this open your Supabase project again and go to the Settings of the Authentication menu entry where you can add a domain under Redirect URLs:

Supabase Auth Redirect URL

This was the last missing piece, and now you even got seamless Supabase authentication with magic links working inside your iOS and Android app!

Conclusion

We've come a long way and covered everything from setting up tables, to defining policies to protect data, and handling authentication in Ionic Angular applications.

You can find the full code of this tutorial on Github where you just need to insert your own Supabase instance and then create the tables with the included SQL file, plus updating the authentication settings as we did in the tutorial

Although we can now use magic link auth, something probably even better fitting for native apps would be phone auth with Twilio that's also easily possible with Supabase - just like tons of other authentication providers!

Protecting your Ionic Angular app with Supabase is a breeze, and through the security rules, you can make sure your data and tables are protected in the best possible way.

If you enjoyed the tutorial, you can find many more tutorials on my YouTube channel where I help web developers build awesome mobile apps.

Until next time and happy coding with Supabase!

Share this article

Last post

Fetching and caching Supabase data in Next.js 13 Server Components

17 November 2022

Next post

Supabase Beta October 2022

2 November 2022

Related articles

Supabase Beta May 2023

Supabase Vecs: a vector client for Postgres

Flutter Hackathon Winners

ChatGPT plugins now support Postgres & Supabase

Building ChatGPT Plugins with Supabase Edge Runtime

Build in a weekend, scale to millions