Supabase for Angular: a step-by-step guide, from basics to RAG
Supabase in one sentence: Postgres with superpowers. A real SQL database, plus auth, file storage, serverless functions and realtime, all open source, all consumed directly from your frontend. If the Firebase guide taught the "no backend of your own" model, this one teaches the same model with a twist that changes everything: underneath, it's just PostgreSQL, the most battle-tested relational database in the world. Everything you know about SQL applies, and everything you store can be queried, joined and secured with it.
This guide goes from zero to advanced, and closes with a full section on the feature set that makes Supabase special in 2026: its AI stack: vectors, embeddings and RAG living in the same database as your data.
The mental model: everything is Postgres
Like any BaaS (backend as a service), Supabase removes the server between your frontend and your data: the SDK talks to the database directly. The immediate question is the same one Firebase raises (what stops a user from reading everyone else's rows?) and Supabase's answer is more elegant, because it isn't a proprietary rules language. It's SQL:
In Supabase, your authorization layer is RLS (row-level security): policies written in SQL that decide, row by row, who can read or write what. Without RLS enabled on a table, the public key can read all of it.
Two keys come with every project, and confusing them is the classic beginner incident. The anon key is public: it ships in your frontend and is safe because RLS constrains it. The service_role key bypasses RLS entirely: it belongs on a server (an Edge Function), never in client code, never in the repo.
Setup: one project, one client, typed
Create a project at supabase.com, grab the URL and anon key, and install the SDK:
npm install @supabase/supabase-jsCreate the client once, in a root service, and inject it everywhere else:
// supabase.service.ts: one client for the whole app
import { Injectable } from '@angular/core';
import { createClient } from '@supabase/supabase-js';
@Injectable({ providedIn: 'root' })
export class SupabaseService {
// The anon key is public by design; RLS is what protects the data
readonly client = createClient('https://YOUR-PROJECT.supabase.co', 'YOUR_ANON_KEY');
}One habit worth adopting on day one: generated types. The CLI reads your database schema and
produces TypeScript types for every table (supabase gen types typescript), so
client.from('notes') autocompletes columns and catches typos at compile time. It's the Postgres
advantage showing up in your editor.
Step 1 · The database: real tables, real joins
Create tables in the dashboard's SQL editor: actual SQL, no console wizardry required. Reading and writing from Angular is one call:
// notes.service.ts: CRUD with a real relational database behind it
import { inject, Injectable, signal } from '@angular/core';
import { SupabaseService } from './supabase.service';
@Injectable({ providedIn: 'root' })
export class NotesService {
private readonly db = inject(SupabaseService).client;
readonly notes = signal<Note[]>([]);
async load() {
// A real join: each note arrives with its author's profile embedded
const { data, error } = await this.db
.from('notes')
.select('id, title, created_at, author:profiles(name)')
.order('created_at', { ascending: false });
if (!error) this.notes.set(data as Note[]);
}
async create(title: string) {
const { data, error } = await this.db.from('notes').insert({ title }).select().single();
if (!error) this.notes.update((list) => [data as Note, ...list]);
}
}That author:profiles(name) line is the moment Supabase differentiates itself from any document
database: relationships are native. No duplicating the author's name into every note, no
second query, no sync bugs when the name changes. If your data has relationships (and almost all
product data does), this is the argument.
Step 2 · Auth + RLS: the security backbone
Supabase Auth covers email/password, magic links (passwordless sign-in links sent by email) and OAuth providers (Google, GitHub…), with session persistence and refresh handled for you. The Angular pattern mirrors the one from the Firebase guide: turn the session into a signal:
// auth.service.ts: the session as a signal, ready before guards decide
import { inject, Injectable, signal, computed } from '@angular/core';
import type { Session } from '@supabase/supabase-js';
import { SupabaseService } from './supabase.service';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly db = inject(SupabaseService).client;
// undefined = still restoring; null = signed out
readonly session = signal<Session | null | undefined>(undefined);
readonly isLoggedIn = computed(() => !!this.session());
readonly ready = this.db.auth.getSession().then(({ data }) => {
this.session.set(data.session);
this.db.auth.onAuthStateChange((_event, session) => this.session.set(session));
});
signIn(email: string, password: string) {
return this.db.auth.signInWithPassword({ email, password });
}
signInWithGoogle() {
return this.db.auth.signInWithOAuth({ provider: 'google' });
}
signOut() {
return this.db.auth.signOut();
}
}Now the part that actually secures the product. RLS (row-level security) is a Postgres
feature: you enable it per table and write policies: small SQL predicates evaluated on every
query, on the server, for every row. auth.uid() inside a policy resolves to the id of the user
making the request. The pattern that covers most apps, each user owns their rows:
-- Lock the table down, then grant precisely what each verb allows
alter table notes enable row level security;
create policy "read own notes" on notes
for select using ((select auth.uid()) = owner_id);
create policy "create as yourself" on notes
for insert with check ((select auth.uid()) = owner_id);
create policy "update own notes, ownership never changes" on notes
for update using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "delete own notes" on notes
for delete using ((select auth.uid()) = owner_id);using filters the rows a query can touch; with check validates the rows a write tries to
produce: the insert/update distinction that prevents planting rows owned by someone else. The
(select auth.uid()) wrapper is the form the official docs recommend: Postgres evaluates it once
per query instead of once per row. One
practical detail that makes the earlier create() snippet work as written: declare the column as
owner_id uuid not null default auth.uid(), so every insert is stamped with its author
automatically and the policy check always passes for the rightful owner. Note what just happened:
your entire authorization layer is versionable, testable SQL, in the language you already use
for the data itself. Read the
RLS guide before shipping
anything. It's the section of Supabase that pays the highest interest.
Enable RLS on every table, from day one. A table without RLS is readable by anyone holding your public key, which is everyone who opens DevTools.
Step 3 · Storage: user files with SQL policies
Storage organizes files in buckets (top-level folders with their own access rules). Upload, then either serve publicly or issue temporary links:
// Upload a photo under the user's folder and get a URL for it
async uploadPhoto(uid: string, file: File) {
const path = `${uid}/${crypto.randomUUID()}-${file.name}`;
const { error } = await this.db.storage.from('photos').upload(path, file);
if (error) throw error;
// Public bucket → permanent URL; private bucket → signed URL that expires
const { data } = await this.db.storage.from('photos').createSignedUrl(path, 3600);
return data?.signedUrl;
}Two decisions that keep Storage sane: namespace paths by user id ({uid}/…), and default to
private buckets + signed URLs (temporary, expiring links) unless the content is genuinely
public. Access control is, again, RLS: storage policies are SQL over the storage.objects
table, same mental model as your data tables. One security language for everything.
Step 4 · Edge Functions: server code when you need it
The same rule from the Firebase guide applies here: sooner or later something can't run in the
browser: a third-party API key, a payment, work that outlives the tab, anything needing the
service_role key. Edge Functions are TypeScript
functions running on Deno (a modern, security-first JavaScript runtime) deployed globally:
supabase functions new enrich-note # scaffold
supabase secrets set SOME_API_KEY=... # secrets live server-side, never in the repo
supabase functions deploy enrich-note # ship itCalling one from Angular is a single line: auth headers included automatically:
const { data, error } = await this.db.functions.invoke('enrich-note', {
body: { noteId },
});The division of labor is the same as with Cloud Functions in Firebase: the frontend talks to the database directly for CRUD (RLS protecting it), and Edge Functions handle the privileged, secret-holding, third-party-calling work. This is also where AI calls live. More on that below.
Step 5 · Realtime, briefly
Supabase can push database changes to the browser as they happen, useful for collaborative screens and live dashboards:
// Refresh the list whenever any note changes (cleanup included)
const channel = this.db
.channel('notes-changes')
.on('postgres_changes', { event: '*', schema: 'public', table: 'notes' }, () => this.load())
.subscribe();
inject(DestroyRef).onDestroy(() => this.db.removeChannel(channel));Know that it exists, wire it where liveness genuinely improves the product, and don't subscribe to everything by default; most screens are fine with a fetch.
The AI section: your database is already a vector store
Here is where Supabase earns its 2026 reputation. Building AI features like semantic search, recommendations, RAG (retrieval-augmented generation: giving a model your own data as context; the concepts are covered in the frontend+AI guide) normally requires adding a separate vector database to your stack. With Supabase you don't add anything: Postgres itself stores and searches vectors, next to the rest of your data.
pgvector: vectors as a column type
pgvector is a Postgres extension
(a plugin that adds capabilities to the database) that ships with every Supabase project. It adds
a vector column type, and with it, similarity search in plain SQL:
-- Enable the extension, store content next to its embedding
create extension if not exists vector;
create table documents (
id bigint primary key generated always as identity,
content text not null,
embedding vector(384) -- 384 numbers describing the content's meaning
);
-- HNSW: a graph index that keeps similarity queries fast as the table grows
create index on documents using hnsw (embedding vector_cosine_ops);An embedding is a list of numbers that captures a text's meaning, so texts about the same
thing end up numerically close. The vector(384) column stores one per row; the HNSW index
is the 2026 default for searching millions of them in milliseconds.
Generating embeddings
You need a model to turn text into vectors. Two good options, both server-side: call OpenAI's embeddings API from an Edge Function, or use the model built into Edge Functions, no external API at all:
// Inside an Edge Function: Supabase ships an embeddings model out of the box
const model = new Supabase.ai.Session('gte-small');
const embedding = await model.run(text, { mean_pool: true, normalize: true });
// → number[384], ready to insert into the vector columnThe flow is always: content arrives → Edge Function generates its embedding → both are stored in the same row. The AI & Vectors docs walk through automating this on every insert.
Semantic search: one SQL function + one RPC
The retrieval half lives in a SQL function, called from Angular by name via RPC (remote procedure call: invoking a database function like an API endpoint):
-- Return the most similar documents to a query embedding
create or replace function match_documents(
query_embedding vector(384),
match_count int default 5
)
returns table (id bigint, content text, similarity float)
language sql stable
as $$
select id, content,
1 - (embedding <=> query_embedding) as similarity -- <=> is "distance between vectors"
from documents
order by embedding <=> query_embedding
limit match_count;
$$;// Angular side: embed the user's question (Edge Function), then match
const { data: matches } = await this.db.rpc('match_documents', {
query_embedding: questionEmbedding,
match_count: 5,
});That pair is the engine of every semantic feature. Search that understands meaning ("laptop won't turn on" finds the doc titled "computer power issues"), recommendations ("similar articles"), deduplication: all the same query. The semantic search guide covers the details.
RAG on Supabase: the assembled flow
With those pieces, RAG is composition rather than new machinery:
- Ingest: split documents into fragments, embed each one (Edge Function), store content +
vector in
documents. - Retrieve: embed the user's question,
match_documentsreturns the most relevant fragments. - Generate: paste those fragments into the LLM prompt as context; the model answers from your data, citing its sources.
And the differentiated advantage of doing this in Postgres: hybrid queries. Because vectors live next to your relational data, one SQL statement can combine similarity with real filters: "the most relevant fragments among this user's documents, in Spanish, from the last month". With a separate vector database, that cross-filtering is exactly the painful part.
Supabase or Firebase?
The Firebase guide closes with a full comparison table; the summary holds: Firebase when mobile-grade realtime and offline sync lead, Supabase when your data is relational, your team thinks in SQL, or AI features are on the roadmap, having your vector store, your data and your authorization in one open-source Postgres is a structural advantage, not a convenience.
Going deeper
Every claim and snippet in this guide was checked against the official documentation: these are the pages to keep open while you build:
Getting started
- Supabase + Angular tutorial: the official starter: auth, profiles and storage, built in Angular.
- Supabase docs: genuinely excellent; the Auth and RLS guides are the two to read before shipping.
- supabase-js reference: the whole client API, searchable.
- Local development with the CLI: run the entire stack on your machine, migrations included.
Server code
- Edge Functions: runtime, secrets, deployment.
- AI models in Edge Functions: the built-in embeddings model used in this guide, documented.
The AI stack
- AI & Vectors: the hub: pgvector, embeddings, semantic search and RAG, with runnable examples.
- Vector columns: the
match_documentspattern, step by step. - Automatic embeddings: keep vectors in sync on every insert/update, without manual work.
- pgvector extension and vector indexes: distance operators and the HNSW vs IVFFlat trade-offs.
Angular and beyond
- Angular signals guide: the reactivity model every snippet here builds on.
- Supabase on YouTube: short official walkthroughs of every feature.
- The frontend+AI field guide on this blog: the concepts (LLMs, embeddings, RAG, evals) that this stack implements.
The checklist
The guide, compressed:
- One client, one root service: anon key in the frontend,
service_rolenever. - Generate TypeScript types from the schema and keep them updated.
- Model relationally: real joins instead of duplicated data.
- Enable RLS on every table from day one: policies per verb,
using+with check. - Private buckets + signed URLs by default; paths namespaced by user id.
- Edge Functions for anything privileged: secrets, third-party APIs, AI calls.
- pgvector + HNSW for anything semantic: embeddings next to your data.
match_documents+rpc(): the reusable engine for search, recommendations and RAG.
Postgres carried the last thirty years of applications. With vectors in the same database as your data and your security, it's positioned to carry the next stretch too, and now you know how to drive it from Angular.