Back to blog

Firebase for Angular: a step-by-step guide to shipping complete apps

I learned Firebase because I had to. I was building OurMemory — a private photo-journal app — as a frontend engineer with no backend, no server, and no interest in running one. Firebase was the shortcut that turned out to be a whole road. This is the map I wish I'd had: what Firebase actually is, the five services you really need, how each one plugs into Angular, and where to go deeper when a section earns your attention.

What Firebase is (and the mental shift it demands)

Firebase is a BaaS — backend as a service. Authentication, a database, file storage, serverless functions and hosting, all managed by Google, all consumed directly from your frontend through an SDK. You ship a complete product without provisioning a single server.

The part that costs the most when you come from REST APIs: there is no backend of yours in the middle. No Express, no controllers, no middleware chain. Your Angular app talks straight to the database. Which immediately raises the right question — then what stops a user from reading everyone else's data? — and the answer is the single most important concept in Firebase:

In Firebase, security does not live in your code. It lives in security rules, evaluated by Google's servers on every request. The client is untrusted by design.

Internalize that and everything else is mechanics. Skip it and you'll ship an open database.

Setup: one project, one config, one service

Create a project in the Firebase console, add a web app, and it hands you a config object. Install the SDK:

npm install firebase

That config (apiKey, projectId, …) is public by design — it identifies your project, it doesn't authorize anything. Committing it is fine; the rules are your lock, not the key's secrecy.

In Angular, initialize once inside a providedIn: 'root' service, and keep every Firebase import in lazy-loaded code so the SDK never bloats routes that don't use it:

// firebase.ts — one place initializes the app, everyone else asks for it
import { initializeApp, getApp, getApps } from 'firebase/app';

const config = {
  apiKey: '…',
  authDomain: 'my-app.firebaseapp.com',
  projectId: 'my-app',
  storageBucket: 'my-app.firebasestorage.app',
  appId: '…',
};

export function firebaseApp() {
  // Reuse the app across services — initializeApp twice throws
  return getApps().length ? getApp() : initializeApp(config);
}

Worth knowing: AngularFire is the official Angular wrapper (DI providers, observables). It's good — but this guide uses the raw modular SDK, because it's the vocabulary every Firebase doc, answer and example speaks, and it pairs naturally with signals.

Step 1 · Auth: who is this user?

Firebase Auth gives you email/password, Google, Apple, magic links and more, with session persistence handled for you. The pattern that matters in Angular: turn the auth callback into a signal, and expose a promise that resolves once the session is restored.

// auth.service.ts
import { inject, Injectable, signal, computed } from '@angular/core';
import {
  getAuth,
  onAuthStateChanged,
  signInWithPopup,
  GoogleAuthProvider,
  signOut,
  type User,
} from 'firebase/auth';
import { firebaseApp } from './firebase';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly auth = getAuth(firebaseApp());

  // undefined = still restoring the session; null = signed out
  readonly user = signal<User | null | undefined>(undefined);
  readonly isLoggedIn = computed(() => !!this.user());

  /** Resolves after the first auth emission — guards await this. */
  readonly ready = new Promise<void>((resolve) => {
    onAuthStateChanged(this.auth, (user) => {
      this.user.set(user);
      resolve();
    });
  });

  signIn() {
    return signInWithPopup(this.auth, new GoogleAuthProvider());
  }
  signOut() {
    return signOut(this.auth);
  }
}

The undefined → null | User distinction is the detail that prevents the classic bug: redirecting to login while Firebase is still restoring a perfectly valid session. The route guard waits:

// auth.guard.ts
export const authGuard: CanActivateFn = async () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  await auth.ready; // don't decide until the session is restored
  return auth.isLoggedIn() ? true : router.parseUrl('/login');
};

One nuance from shipping this: prefer signInWithPopup over signInWithRedirect on static hosting — redirect flows are increasingly unreliable under browser third-party-storage partitioning.

Step 2 · Firestore: your database, live

Firestore is a NoSQL database of documents (JSON-ish objects) grouped in collections. No tables, no joins, no schema. Two reading modes — and choosing between them is the main design decision:

  • One-shot (getDocs): fetch now, done. For data that rarely changes.
  • Real-time (onSnapshot): a subscription that pushes every change to you. This is the feature that makes Firestore feel magic — two devices see the same update instantly, offline writes sync when the connection returns.

The Angular integration in one service — real-time query feeding a signal, cleaned up with DestroyRef:

// memories.service.ts
import { inject, Injectable, signal, DestroyRef } from '@angular/core';
import {
  getFirestore,
  collection,
  query,
  where,
  orderBy,
  onSnapshot,
  addDoc,
  serverTimestamp,
} from 'firebase/firestore';
import { firebaseApp } from './firebase';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class MemoriesService {
  private readonly db = getFirestore(firebaseApp());
  private readonly auth = inject(AuthService);
  private readonly col = collection(this.db, 'memories');

  readonly memories = signal<Memory[]>([]);

  listenForUser(uid: string) {
    const q = query(this.col, where('ownerUid', '==', uid), orderBy('createdAt', 'desc'));
    const unsubscribe = onSnapshot(q, (snap) => {
      this.memories.set(snap.docs.map((d) => ({ id: d.id, ...d.data() }) as Memory));
    });
    inject(DestroyRef).onDestroy(unsubscribe); // never leak listeners
  }

  create(title: string, photoUrl: string) {
    return addDoc(this.col, {
      title,
      photoUrl,
      ownerUid: this.auth.user()!.uid,
      createdAt: serverTimestamp(), // server clock, not the device's
    });
  }
}

Three modeling lessons that cost me real refactors:

  • Model for your queries, not your entities. Firestore has no joins: if a screen needs the author's name on every memory, store the name in the memory document. Duplicating data is normal here; it's called denormalization, not sin.
  • where on the owner is not security. The query filters what you ask for; only rules control what you can read. You need both.
  • Composite queries need indexes. where + orderBy on different fields will fail with an error that contains a link that creates the index for you. Click it, wait a minute, done.

Step 3 · Storage: user files (the heart of OurMemory)

Photos were the whole point of OurMemory, and Cloud Storage is where they live. The flow is always the same trio: upload the bytes → get a download URL → save that URL in Firestore.

// upload a photo with progress, then hand back its permanent URL
import { getStorage, ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage';

async function uploadPhoto(uid: string, file: File, onProgress: (pct: number) => void) {
  const storage = getStorage(firebaseApp());
  const path = ref(storage, `users/${uid}/photos/${crypto.randomUUID()}-${file.name}`);

  const task = uploadBytesResumable(path, file);
  task.on('state_changed', (snap) => onProgress((snap.bytesTransferred / snap.totalBytes) * 100));

  await task;
  return getDownloadURL(path.ref);
}

Two decisions that pay off: namespace paths by uid (users/{uid}/…) so rules can protect them with one line, and use uploadBytesResumable (not plain uploadBytes) for anything bigger than an avatar — users on mobile connections will thank you for the progress bar. Storage has its own rules, same philosophy as Firestore's:

// storage.rules — each user owns their folder
match /users/{uid}/{allPaths=**} {
  allow read, write: if request.auth != null && request.auth.uid == uid;
}

Step 4 · Cloud Functions: the server you didn't want to run

Sooner or later something can't run in the browser: a third-party API key that must stay secret, a payment, sending email, work that must happen even if the user closes the tab. Cloud Functions are Node functions Google runs on demand. Two shapes matter most:

Callables — a typed request/response between your app and the server, with auth verified for you:

// functions/src/index.ts (server side)
import { onCall, HttpsError } from 'firebase-functions/https';
import { defineSecret } from 'firebase-functions/params';

const apiKey = defineSecret('SOME_API_KEY'); // never ships to the client

export const enrichMemory = onCall({ secrets: [apiKey] }, async (request) => {
  if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first.');
  // …call the third-party API with apiKey.value() and return the result
});
// client side — one line to call it
const enrich = httpsCallable(getFunctions(firebaseApp()), 'enrichMemory');
const result = await enrich({ memoryId });

Firestore triggers — code that reacts to data: onDocumentCreated('memories/{id}', …) to generate a thumbnail, update a counter, send a notification. The serverless mindset in one line: your app writes a document; the backend reacts. Secrets go in defineSecret — never in client code, never in the repo.

Step 5 · Hosting: build, rewrite, deploy

Firebase Hosting serves your Angular build from a global CDN. Setup once with the CLI, then it's one command per release:

npm install -g firebase-tools
firebase login
firebase init hosting   # point "public" at your build output
firebase deploy

The one thing you must get right for a SPA — the rewrite, so deep links like /memories/42 don't 404 on refresh:

{
  "hosting": {
    "public": "dist/my-app/browser",
    "rewrites": [{ "source": "**", "destination": "/index.html" }],
    "headers": [
      {
        "source": "**/*.@(js|css)",
        "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
      }
    ]
  }
}

If you prerender with Angular SSG (like this site does), the same config serves the static HTML per route. Full SSR needs a server runtime — Hosting covers it by delegating to Cloud Functions/Cloud Run, but for most content sites, SSG + rewrites is simpler and faster.

Security rules: the section everyone skips (don't)

Rules are a declarative language evaluated server-side on every read and write. They are your entire authorization layer. The pattern that covers 90% of apps — each user owns their documents:

// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{db}/documents {
    match /memories/{id} {
      // Read/delete: only the owner of the existing document
      allow read, delete: if request.auth != null
        && request.auth.uid == resource.data.ownerUid;
      // Create: only as yourself — you can't plant documents owned by others
      allow create: if request.auth != null
        && request.auth.uid == request.resource.data.ownerUid;
      // Update: owner only, and ownership itself can never change hands
      allow update: if request.auth != null
        && request.auth.uid == resource.data.ownerUid
        && request.resource.data.ownerUid == resource.data.ownerUid;
    }
  }
}

resource is the document as it exists; request.resource is what the client is trying to write. That create/update distinction closes the holes people actually get burned by. Test rules in the console's Rules Playground or with the emulator suite before deploying.

Without rules, your database is open to the world. With where clauses only, it's open to anyone who opens DevTools.

Firebase vs Supabase: an honest comparison

Supabase is the usual alternative — same "backend in an afternoon" promise, opposite philosophy. Firebase is Google's proprietary NoSQL platform; Supabase is open source built on PostgreSQL.

Firebase Supabase
Database Firestore — NoSQL documents Postgres — relational, real SQL
Queries Limited (no joins/aggregates) Full SQL: joins, views, functions
Real-time Excellent, offline sync built in Good (Postgres replication), no offline sync
Auth Mature, huge provider list Solid, includes row-level security in SQL
Server code Cloud Functions (Node) Edge Functions (Deno/TypeScript)
Authorization Security rules (own language) RLS policies (SQL you already know)
Open source No — proprietary, Google-hosted Yes — self-hostable, data is plain Postgres
Pricing Free tier, then pay-as-you-go (can spike) Free tier, then predictable per-project plans
Sweet spot Mobile/realtime apps, fastest 0→1 Relational data, SQL teams, exit-path safety

When I'd pick each: Firebase when real-time and offline matter, when you're also shipping mobile, or when you want the absolute fastest path to a working product — that's why OurMemory runs on it. Supabase when your data is clearly relational (orders, invoices, anything with joins), when the team thinks in SQL, or when vendor lock-in is a real concern — worst case you take your Postgres dump and leave. Both are excellent; the database model is the real fork in the road.

Going deeper

Where to actually learn each piece, in the order I'd study them:

  • Firebase documentation — genuinely good; read the Firestore data modeling and Security Rules guides before writing production code.
  • Fireship — the fastest practical Firebase teaching on the internet; start with the Firestore data modeling course.
  • Firebase Emulator Suite — run Auth, Firestore, Storage and Functions locally; develop and test rules without touching production.
  • AngularFire — the official Angular integration, once you know the raw SDK and want DI and observables.
  • Cloud Functions (2nd gen) — triggers, callables, secrets and deployment in depth.
  • Supabase documentation — read at least the Auth and RLS guides; knowing both platforms makes the trade-offs concrete.

Firebase didn't make me a backend engineer. It did something more useful: it let me ship a complete product — auth, database, photos, deploys — while staying a frontend engineer. That's the promise, and in my experience, it keeps it.