Home

Build a User Management App with Ionic React

This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:

  • Supabase Database - a Postgres database for storing your user data and Row Level Security so data is protected and users can only access their own information.
  • Supabase Auth - users log in through magic links sent to their email (without having to set up passwords).
  • Supabase Storage - users can upload a profile photo.

Supabase User Management example

note

If you get stuck while working through this guide, refer to the full example on GitHub.

Project setup#

Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.

Create a project#

  1. Create a new project in the Supabase Dashboard.
  2. Enter your project details.
  3. Wait for the new database to launch.

Set up the database schema#

Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.

  1. Go to the SQL Editor page in the Dashboard.
  2. Click User Management Starter.
  3. Click Run.

note

You can easily pull the database schema down to your local project by running the following commands:


_10
supabase link
_10
supabase db pull

Get the API Keys#

Now that you've created some database tables, you are ready to insert data using the auto-generated API. We just need to get the Project URL and anon key from the API settings.

  1. Go to the API Settings page in the Dashboard.
  2. Find your Project URL, anon, and service_role keys on this page.

Building the App#

Let's start building the React app from scratch.

Initialize an Ionic React app#

We can use the Ionic CLI to initialize an app called supabase-ionic-react:


_10
npm install -g @ionic/cli
_10
ionic start supabase-ionic-react blank --type react
_10
cd supabase-ionic-react

Then let's install the only additional dependency: supabase-js


_10
npm install @supabase/supabase-js

And finally we want to save the environment variables in a .env. All we need are the API URL and the anon key that you copied earlier.

.env

_10
REACT_APP_SUPABASE_URL=YOUR_SUPABASE_URL
_10
REACT_APP_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed on the browser, and that's completely fine since we have Row Level Security enabled on our Database.

src/supabaseClient.js

_10
import { createClient } from '@supabase/supabase-js'
_10
_10
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
_10
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY
_10
_10
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

Set up a Login route#

Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.

/src/pages/Login.tsx

_70
import { useState } from 'react';
_70
import {
_70
IonButton,
_70
IonContent,
_70
IonHeader,
_70
IonInput,
_70
IonItem,
_70
IonLabel,
_70
IonList,
_70
IonPage,
_70
IonTitle,
_70
IonToolbar,
_70
useIonToast,
_70
useIonLoading,
_70
} from '@ionic/react';
_70
import { supabase } from '../supabaseClient';
_70
_70
export function LoginPage() {
_70
const [email, setEmail] = useState('');
_70
_70
const [showLoading, hideLoading] = useIonLoading();
_70
const [showToast ] = useIonToast();
_70
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
_70
console.log()
_70
e.preventDefault();
_70
await showLoading();
_70
try {
_70
await supabase.auth.signIn({ email });
_70
await showToast({ message: 'Check your email for the login link!' });
_70
} catch (e: any) {
_70
await showToast({ message: e.error_description || e.message , duration: 5000});
_70
} finally {
_70
await hideLoading();
_70
}
_70
};
_70
return (
_70
<IonPage>
_70
<IonHeader>
_70
<IonToolbar>
_70
<IonTitle>Login</IonTitle>
_70
</IonToolbar>
_70
</IonHeader>
_70
_70
<IonContent>
_70
<div className="ion-padding">
_70
<h1>Supabase + Ionic React</h1>
_70
<p>Sign in via magic link with your email below</p>
_70
</div>
_70
<IonList inset={true}>
_70
<form onSubmit={handleLogin}>
_70
<IonItem>
_70
<IonLabel position="stacked">Email</IonLabel>
_70
<IonInput
_70
value={email}
_70
name="email"
_70
onIonChange={(e) => setEmail(e.detail.value ?? '')}
_70
type="email"
_70
></IonInput>
_70
</IonItem>
_70
<div className="ion-text-center">
_70
<IonButton type="submit" fill="clear">
_70
Login
_70
</IonButton>
_70
</div>
_70
</form>
_70
</IonList>
_70
</IonContent>
_70
</IonPage>
_70
);
_70
}

Account page#

After a user is signed in we can allow them to edit their profile details and manage their account.

Let's create a new component for that called Account.tsx.

src/pages/Account.tsx

_147
import {
_147
IonButton,
_147
IonContent,
_147
IonHeader,
_147
IonInput,
_147
IonItem,
_147
IonLabel,
_147
IonPage,
_147
IonTitle,
_147
IonToolbar,
_147
useIonLoading,
_147
useIonToast,
_147
useIonRouter
_147
} from '@ionic/react';
_147
import { useEffect, useState } from 'react';
_147
import { supabase } from '../supabaseClient';
_147
_147
export function AccountPage() {
_147
const [showLoading, hideLoading] = useIonLoading();
_147
const [showToast] = useIonToast();
_147
const [session] = useState(() => supabase.auth.session());
_147
const router = useIonRouter();
_147
const [profile, setProfile] = useState({
_147
username: '',
_147
website: '',
_147
avatar_url: '',
_147
});
_147
useEffect(() => {
_147
getProfile();
_147
}, [session]);
_147
const getProfile = async () => {
_147
console.log('get');
_147
await showLoading();
_147
try {
_147
const user = supabase.auth.user();
_147
let { data, error, status } = await supabase
_147
.from('profiles')
_147
.select(`username, website, avatar_url`)
_147
.eq('id', user!.id)
_147
.single();
_147
_147
if (error && status !== 406) {
_147
throw error;
_147
}
_147
_147
if (data) {
_147
setProfile({
_147
username: data.username,
_147
website: data.website,
_147
avatar_url: data.avatar_url,
_147
});
_147
}
_147
} catch (error: any) {
_147
showToast({ message: error.message, duration: 5000 });
_147
} finally {
_147
await hideLoading();
_147
}
_147
};
_147
const signOut = async () => {
_147
await supabase.auth.signOut();
_147
router.push('/', 'forward', 'replace');
_147
}
_147
const updateProfile = async (e?: any, avatar_url: string = '') => {
_147
e?.preventDefault();
_147
_147
console.log('update ');
_147
await showLoading();
_147
_147
try {
_147
const user = supabase.auth.user();
_147
_147
const updates = {
_147
id: user!.id,
_147
...profile,
_147
avatar_url: avatar_url,
_147
updated_at: new Date(),
_147
};
_147
_147
let { error } = await supabase.from('profiles').upsert(updates, {
_147
returning: 'minimal', // Don't return the value after inserting
_147
});
_147
_147
if (error) {
_147
throw error;
_147
}
_147
} catch (error: any) {
_147
showToast({ message: error.message, duration: 5000 });
_147
} finally {
_147
await hideLoading();
_147
}
_147
};
_147
return (
_147
<IonPage>
_147
<IonHeader>
_147
<IonToolbar>
_147
<IonTitle>Account</IonTitle>
_147
</IonToolbar>
_147
</IonHeader>
_147
_147
<IonContent>
_147
<form onSubmit={updateProfile}>
_147
<IonItem>
_147
<IonLabel>
_147
<p>Email</p>
_147
<p>{session?.user?.email}</p>
_147
</IonLabel>
_147
</IonItem>
_147
_147
<IonItem>
_147
<IonLabel position="stacked">Name</IonLabel>
_147
<IonInput
_147
type="text"
_147
name="username"
_147
value={profile.username}
_147
onIonChange={(e) =>
_147
setProfile({ ...profile, username: e.detail.value ?? '' })
_147
}
_147
></IonInput>
_147
</IonItem>
_147
_147
<IonItem>
_147
<IonLabel position="stacked">Website</IonLabel>
_147
<IonInput
_147
type="url"
_147
name="website"
_147
value={profile.website}
_147
onIonChange={(e) =>
_147
setProfile({ ...profile, website: e.detail.value ?? '' })
_147
}
_147
></IonInput>
_147
</IonItem>
_147
<div className="ion-text-center">
_147
<IonButton fill="clear" type="submit">
_147
Update Profile
_147
</IonButton>
_147
</div>
_147
</form>
_147
_147
<div className="ion-text-center">
_147
<IonButton fill="clear" onClick={signOut}>
_147
Log Out
_147
</IonButton>
_147
</div>
_147
</IonContent>
_147
</IonPage>
_147
);
_147
}

Launch!#

Now that we have all the components in place, let's update App.tsx:

src/App.tsx

_45
import { Redirect, Route } from 'react-router-dom'
_45
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'
_45
import { IonReactRouter } from '@ionic/react-router'
_45
import { supabase } from './supabaseClient'
_45
_45
import '@ionic/react/css/ionic.bundle.css'
_45
_45
/* Theme variables */
_45
import './theme/variables.css'
_45
import { LoginPage } from './pages/Login'
_45
import { AccountPage } from './pages/Account'
_45
import { useEffect, useState } from 'react'
_45
import { Session } from '@supabase/supabase-js'
_45
_45
setupIonicReact()
_45
_45
const App: React.FC = () => {
_45
const [session, setSession] = useState < Session > null
_45
useEffect(() => {
_45
setSession(supabase.auth.session())
_45
supabase.auth.onAuthStateChange((_event, session) => {
_45
setSession(session)
_45
})
_45
}, [])
_45
return (
_45
<IonApp>
_45
<IonReactRouter>
_45
<IonRouterOutlet>
_45
<Route
_45
exact
_45
path="/"
_45
render={() => {
_45
return session ? <Redirect to="/account" /> : <LoginPage />
_45
}}
_45
/>
_45
<Route exact path="/account">
_45
<AccountPage />
_45
</Route>
_45
</IonRouterOutlet>
_45
</IonReactRouter>
_45
</IonApp>
_45
)
_45
}
_45
_45
export default App

Once that's done, run this in a terminal window:


_10
ionic serve

And then open the browser to localhost:3000 and you should see the completed app.

Supabase Ionic React

Bonus: Profile photos#

Every Supabase project is configured with Storage for managing large files like photos and videos.

Create an upload widget#

First install two packages in order to interact with the user's camera.


_10
npm install @ionic/pwa-elements @capacitor/camera

CapacitorJS is a cross platform native runtime from Ionic that enables web apps to be deployed through the app store and provides access to native deavice API.

Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.

With those packages installed we can update our index.tsx to include an additional bootstapping call for the Ionic PWA Elements.

src/index.tsx

_18
import React from 'react'
_18
import ReactDOM from 'react-dom'
_18
import App from './App'
_18
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
_18
import reportWebVitals from './reportWebVitals'
_18
_18
import { defineCustomElements } from '@ionic/pwa-elements/loader'
_18
defineCustomElements(window)
_18
_18
ReactDOM.render(
_18
<React.StrictMode>
_18
<App />
_18
</React.StrictMode>,
_18
document.getElementById('root')
_18
)
_18
_18
serviceWorkerRegistration.unregister()
_18
reportWebVitals()

Then create an AvatarComponent.

src/components/Avatar.tsx

_76
import { IonIcon } from '@ionic/react';
_76
import { person } from 'ionicons/icons';
_76
import { Camera, CameraResultType } from '@capacitor/camera';
_76
import { useEffect, useState } from 'react';
_76
import { supabase } from '../supabaseClient';
_76
import './Avatar.css'
_76
export function Avatar({
_76
url,
_76
onUpload,
_76
}: {
_76
url: string;
_76
onUpload: (e: any, file: string) => Promise<void>;
_76
}) {
_76
const [avatarUrl, setAvatarUrl] = useState<string | undefined>();
_76
_76
useEffect(() => {
_76
if (url) {
_76
downloadImage(url);
_76
}
_76
}, [url]);
_76
const uploadAvatar = async () => {
_76
try {
_76
const photo = await Camera.getPhoto({
_76
resultType: CameraResultType.DataUrl,
_76
});
_76
_76
const file = await fetch(photo.dataUrl!)
_76
.then((res) => res.blob())
_76
.then(
_76
(blob) =>
_76
new File([blob], 'my-file', { type: `image/${photo.format}` })
_76
);
_76
_76
const fileName = `${Math.random()}-${new Date().getTime()}.${
_76
photo.format
_76
}`;
_76
let { error: uploadError } = await supabase.storage
_76
.from('avatars')
_76
.upload(fileName, file);
_76
if (uploadError) {
_76
throw uploadError;
_76
}
_76
onUpload(null, fileName);
_76
} catch (error) {
_76
console.log(error);
_76
}
_76
};
_76
_76
const downloadImage = async (path: string) => {
_76
try {
_76
const { data, error } = await supabase.storage
_76
.from('avatars')
_76
.download(path);
_76
if (error) {
_76
throw error;
_76
}
_76
const url = URL.createObjectURL(data!);
_76
setAvatarUrl(url);
_76
} catch (error: any) {
_76
console.log('Error downloading image: ', error.message);
_76
}
_76
};
_76
_76
return (
_76
<div className="avatar">
_76
<div className="avatar_wrapper" onClick={uploadAvatar}>
_76
{avatarUrl ? (
_76
<img src={avatarUrl} />
_76
) : (
_76
<IonIcon icon={person} className="no-avatar" />
_76
)}
_76
</div>
_76
_76
</div>
_76
);
_76
}

Add the new widget#

And then we can add the widget to the Account page:

src/pages/Account.tsx

_15
// Import the new component
_15
_15
import { Avatar } from '../components/Avatar';
_15
_15
// ...
_15
return (
_15
<IonPage>
_15
<IonHeader>
_15
<IonToolbar>
_15
<IonTitle>Account</IonTitle>
_15
</IonToolbar>
_15
</IonHeader>
_15
_15
<IonContent>
_15
<Avatar url={profile.avatar_url} onUpload={updateProfile}></Avatar>

Storage management#

If you upload additional profile photos, they'll accumulate in the avatars bucket because of their random names with only the latest being referenced from public.profiles and the older versions getting orphaned.

To automatically remove obsolete storage objects, extend the database triggers. Note that it is not sufficient to delete the objects from the storage.objects table because that would orphan and leak the actual storage objects in the S3 backend. Instead, invoke the storage API within Postgres via the http extension.

Enable the http extension for the extensions schema in the Dashboard. Then, define the following SQL functions in the SQL Editor to delete storage objects via the API:


_34
create or replace function delete_storage_object(bucket text, object text, out status int, out content text)
_34
returns record
_34
language 'plpgsql'
_34
security definer
_34
as $$
_34
declare
_34
project_url text := '<YOURPROJECTURL>';
_34
service_role_key text := '<YOURSERVICEROLEKEY>'; -- full access needed
_34
url text := project_url||'/storage/v1/object/'||bucket||'/'||object;
_34
begin
_34
select
_34
into status, content
_34
result.status::int, result.content::text
_34
FROM extensions.http((
_34
'DELETE',
_34
url,
_34
ARRAY[extensions.http_header('authorization','Bearer '||service_role_key)],
_34
NULL,
_34
NULL)::extensions.http_request) as result;
_34
end;
_34
$$;
_34
_34
create or replace function delete_avatar(avatar_url text, out status int, out content text)
_34
returns record
_34
language 'plpgsql'
_34
security definer
_34
as $$
_34
begin
_34
select
_34
into status, content
_34
result.status, result.content
_34
from public.delete_storage_object('avatars', avatar_url) as result;
_34
end;
_34
$$;

Next, add a trigger that removes any obsolete avatar whenever the profile is updated or deleted:


_32
create or replace function delete_old_avatar()
_32
returns trigger
_32
language 'plpgsql'
_32
security definer
_32
as $$
_32
declare
_32
status int;
_32
content text;
_32
avatar_name text;
_32
begin
_32
if coalesce(old.avatar_url, '') <> ''
_32
and (tg_op = 'DELETE' or (old.avatar_url <> new.avatar_url)) then
_32
-- extract avatar name
_32
avatar_name := old.avatar_url;
_32
select
_32
into status, content
_32
result.status, result.content
_32
from public.delete_avatar(avatar_name) as result;
_32
if status <> 200 then
_32
raise warning 'Could not delete avatar: % %', status, content;
_32
end if;
_32
end if;
_32
if tg_op = 'DELETE' then
_32
return old;
_32
end if;
_32
return new;
_32
end;
_32
$$;
_32
_32
create trigger before_profile_changes
_32
before update of avatar_url or delete on public.profiles
_32
for each row execute function public.delete_old_avatar();

Finally, delete the public.profile row before a user is deleted. If this step is omitted, you won't be able to delete users without first manually deleting their avatar image.


_14
create or replace function delete_old_profile()
_14
returns trigger
_14
language 'plpgsql'
_14
security definer
_14
as $$
_14
begin
_14
delete from public.profiles where id = old.id;
_14
return old;
_14
end;
_14
$$;
_14
_14
create trigger before_delete_user
_14
before delete on auth.users
_14
for each row execute function public.delete_old_profile();

At this stage you have a fully functional application!