How to Store and Retrieve Data from Firebase Firestore in an Ionic App

In this article we going to learn how to store and retrieve data from Firebase Firestore in an Ionic app — create, read, update, and delete documents, and listen to real-time data changes.

Most apps need to store data somewhere. A backend database, a REST API, local storage. Firebase Firestore gives us a cloud database that syncs in real time across devices — no backend server needed, no SQL schema to manage, and it works offline out of the box. When the device loses internet, reads still work from cache. When connection restores, everything syncs automatically.

For Ionic apps especially, this is a great combination. We can build a full-featured app with user data, real-time updates, and offline support without writing a single line of backend code.

This tutorial shows how to:

  • Set up Firebase Firestore in an Ionic Angular project
  • Add, update, and delete documents in Firestore
  • Retrieve a single document by ID
  • Retrieve all documents from a collection
  • Filter and query Firestore data
  • Listen to real-time updates with onSnapshot
  • Structure Firestore data properly for Ionic apps

What is Firestore

Firestore is a NoSQL document database. Data is organized into collections and documents.

A collection is like a table — it holds a group of related documents. A document is like a row — it holds the actual data as key-value pairs.

For example, a users collection might look like this :

users/                          ← collection
  userId1/                      ← document ID
    name: "Amit Gautam"
    email: "amit@example.com"
    createdAt: timestamp
  userId2/
    name: "Priya Verma"
    email: "priya@example.com"
    createdAt: timestamp

Documents can also have sub-collections. A users/userId1/orders sub-collection would hold all orders for that specific user.

Unlike SQL, there's no schema. Each document in a collection can have different fields. Flexible but needs care with data consistency.


Step 1 : Set Up Firestore in Firebase Console

If we haven't created a Firebase project yet, go to console.firebase.google.com, create a project, and add a web app to get the config. (We covered this in the Firebase Auth article.)

In the Firebase Console, click Build → Firestore Database.

Click Create database.

Choose Start in test mode for development — this allows reads and writes without authentication. We'll tighten security rules before going to production.

Select a Firestore location — pick the region closest to our users. Once set, this can't be changed.

Click Enable. Firestore is ready.


Step 2 : Install Firebase SDK

If we already have Firebase in our project from a previous setup, we just need to make sure Firestore is imported. If not, install it :

npm install firebase

Update our Firebase initialization file. Open src/app/firebase.config.ts and add Firestore :

import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { environment } from '../environments/environment';

const app = initializeApp(environment.firebaseConfig);

export const auth = getAuth(app);
export const db = getFirestore(app);

We export db — this is the Firestore instance we'll use throughout the app.


Step 3 : Create a Firestore Service

Let's create a generic FirestoreService that handles all CRUD operations. This keeps Firestore code in one place and makes it reusable across all pages :

ng generate service services/firestore

Open src/app/services/firestore.service.ts :

import { Injectable } from '@angular/core';
import {
  collection,
  doc,
  addDoc,
  setDoc,
  getDoc,
  getDocs,
  updateDoc,
  deleteDoc,
  query,
  where,
  orderBy,
  limit,
  onSnapshot,
  serverTimestamp,
  DocumentData,
  QueryConstraint
} from 'firebase/firestore';
import { db } from '../firebase.config';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class FirestoreService {

  // Add a document with auto-generated ID
  async addDocument(collectionName: string, data: any): Promise<string> {
    const colRef = collection(db, collectionName);
    const docRef = await addDoc(colRef, {
      ...data,
      createdAt: serverTimestamp(),
      updatedAt: serverTimestamp()
    });
    return docRef.id;
  }

  // Add a document with a specific ID
  async setDocument(collectionName: string, docId: string, data: any): Promise<void> {
    const docRef = doc(db, collectionName, docId);
    await setDoc(docRef, {
      ...data,
      updatedAt: serverTimestamp()
    });
  }

  // Get a single document by ID
  async getDocument<T>(collectionName: string, docId: string): Promise<T | null> {
    const docRef = doc(db, collectionName, docId);
    const docSnap = await getDoc(docRef);

    if (docSnap.exists()) {
      return { id: docSnap.id, ...docSnap.data() } as T;
    }
    return null;
  }

  // Get all documents from a collection
  async getCollection<T>(collectionName: string): Promise<T[]> {
    const colRef = collection(db, collectionName);
    const snapshot = await getDocs(colRef);

    return snapshot.docs.map(doc => ({
      id: doc.id,
      ...doc.data()
    })) as T[];
  }

  // Get collection with filters and sorting
  async queryCollection<T>(
    collectionName: string,
    constraints: QueryConstraint[]
  ): Promise<T[]> {
    const colRef = collection(db, collectionName);
    const q = query(colRef, ...constraints);
    const snapshot = await getDocs(q);

    return snapshot.docs.map(doc => ({
      id: doc.id,
      ...doc.data()
    })) as T[];
  }

  // Update specific fields in a document
  async updateDocument(collectionName: string, docId: string, data: Partial<any>): Promise<void> {
    const docRef = doc(db, collectionName, docId);
    await updateDoc(docRef, {
      ...data,
      updatedAt: serverTimestamp()
    });
  }

  // Delete a document
  async deleteDocument(collectionName: string, docId: string): Promise<void> {
    const docRef = doc(db, collectionName, docId);
    await deleteDoc(docRef);
  }

  // Real-time listener for a collection
  listenToCollection<T>(
    collectionName: string,
    constraints: QueryConstraint[] = []
  ): Observable<T[]> {
    return new Observable(observer => {
      const colRef = collection(db, collectionName);
      const q = constraints.length > 0
        ? query(colRef, ...constraints)
        : query(colRef);

      const unsubscribe = onSnapshot(q, snapshot => {
        const data = snapshot.docs.map(doc => ({
          id: doc.id,
          ...doc.data()
        })) as T[];
        observer.next(data);
      }, error => {
        observer.error(error);
      });

      // Return cleanup function
      return () => unsubscribe();
    });
  }

  // Real-time listener for a single document
  listenToDocument<T>(collectionName: string, docId: string): Observable<T | null> {
    return new Observable(observer => {
      const docRef = doc(db, collectionName, docId);

      const unsubscribe = onSnapshot(docRef, docSnap => {
        if (docSnap.exists()) {
          observer.next({ id: docSnap.id, ...docSnap.data() } as T);
        } else {
          observer.next(null);
        }
      }, error => {
        observer.error(error);
      });

      return () => unsubscribe();
    });
  }
}

This service covers everything — add, set, get, query, update, delete, and real-time listeners. The listenToCollection and listenToDocument methods wrap Firestore's onSnapshot in an RxJS Observable, so we can use async pipe in our templates or subscribe in our components.


Step 4 : Add and Retrieve Documents

Let's build a simple Notes app to see this in action. Create a page :

ionic generate page pages/notes

Define the Note interface :

export interface Note {
  id?: string;
  title: string;
  content: string;
  userId: string;
  createdAt?: any;
  updatedAt?: any;
}

Open notes.page.ts :

import { Component, OnInit } from '@angular/core';
import { IonicModule, AlertController } from '@ionic/angular';
import { CommonModule } from '@angular/common';
import { FirestoreService } from '../../services/firestore.service';
import { Note } from '../../models/note.model';

@Component({
  selector: 'app-notes',
  standalone: true,
  imports: [IonicModule, CommonModule],
  template: `
    <ion-header>
      <ion-toolbar color="primary">
        <ion-title>My Notes</ion-title>
        <ion-buttons slot="end">
          <ion-button (click)="addNote()">
            <ion-icon name="add-outline"></ion-icon>
          </ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <ion-list>
        <ion-item *ngFor="let note of notes" (click)="editNote(note)">
          <ion-label>
            <h3>{{ note.title }}</h3>
            <p>{{ note.content }}</p>
          </ion-label>
          <ion-button slot="end" fill="clear" color="danger"
            (click)="deleteNote(note.id!); $event.stopPropagation()">
            <ion-icon name="trash-outline"></ion-icon>
          </ion-button>
        </ion-item>
      </ion-list>

      <ion-note *ngIf="notes.length === 0" style="padding: 16px; display: block; text-align: center;">
        No notes yet. Tap + to add one.
      </ion-note>
    </ion-content>
  `
})
export class NotesPage implements OnInit {
  notes: Note[] = [];
  private userId = 'user123';  // In real app, get from AuthService

  constructor(
    private firestoreService: FirestoreService,
    private alertController: AlertController
  ) {}

  async ngOnInit() {
    await this.loadNotes();
  }

  async loadNotes() {
    this.notes = await this.firestoreService.getCollection<Note>('notes');
  }

  async addNote() {
    const alert = await this.alertController.create({
      header: 'New Note',
      inputs: [
        { name: 'title', type: 'text', placeholder: 'Title' },
        { name: 'content', type: 'textarea', placeholder: 'Content' }
      ],
      buttons: [
        { text: 'Cancel', role: 'cancel' },
        {
          text: 'Save',
          handler: async (data) => {
            if (!data.title) return;

            const newNote: Note = {
              title: data.title,
              content: data.content,
              userId: this.userId
            };

            await this.firestoreService.addDocument('notes', newNote);
            await this.loadNotes();
          }
        }
      ]
    });

    await alert.present();
  }

  async editNote(note: Note) {
    const alert = await this.alertController.create({
      header: 'Edit Note',
      inputs: [
        { name: 'title', type: 'text', value: note.title, placeholder: 'Title' },
        { name: 'content', type: 'textarea', value: note.content, placeholder: 'Content' }
      ],
      buttons: [
        { text: 'Cancel', role: 'cancel' },
        {
          text: 'Update',
          handler: async (data) => {
            await this.firestoreService.updateDocument('notes', note.id!, {
              title: data.title,
              content: data.content
            });
            await this.loadNotes();
          }
        }
      ]
    });

    await alert.present();
  }

  async deleteNote(id: string) {
    await this.firestoreService.deleteDocument('notes', id);
    await this.loadNotes();
  }
}

Step 5 : Query Firestore with Filters

Firestore supports querying with where, orderBy, and limit. Here's how we use the queryCollection method from our service :

import { where, orderBy, limit } from 'firebase/firestore';

// Get notes for a specific user, sorted by date
async loadUserNotes() {
  this.notes = await this.firestoreService.queryCollection<Note>('notes', [
    where('userId', '==', 'user123'),
    orderBy('createdAt', 'desc'),
    limit(20)
  ]);
}

// Get only notes with specific content
async searchNotes(keyword: string) {
  // Firestore doesn't support full-text search
  // For simple prefix search, use range queries
  this.notes = await this.firestoreService.queryCollection<Note>('notes', [
    where('title', '>=', keyword),
    where('title', '<=', keyword + '\uf8ff')
  ]);
}

Important Firestore query rules :

  • If we use orderBy on a field, any where clause on a different field with range operators (>, <, >=, <=) must order by that field first
  • Firestore requires a composite index for queries that combine where on one field with orderBy on another. The Firebase Console will give us a link to create the index when it's needed.
  • Firestore doesn't support full-text search. For that we'd need Algolia or similar.

Step 6 : Real-Time Updates with onSnapshot

The most powerful feature of Firestore — listening to data in real time. When any document in the collection changes, our app updates automatically without polling.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { where, orderBy } from 'firebase/firestore';

export class NotesPage implements OnInit, OnDestroy {
  notes: Note[] = [];
  private notesSubscription!: Subscription;

  constructor(private firestoreService: FirestoreService) {}

  ngOnInit() {
    this.subscribeToNotes();
  }

  ngOnDestroy() {
    // Always unsubscribe when component is destroyed
    this.notesSubscription?.unsubscribe();
  }

  subscribeToNotes() {
    this.notesSubscription = this.firestoreService
      .listenToCollection<Note>('notes', [
        where('userId', '==', 'user123'),
        orderBy('createdAt', 'desc')
      ])
      .subscribe({
        next: (notes) => {
          this.notes = notes;
        },
        error: (err) => {
          console.error('Firestore error:', err);
        }
      });
  }
}

Now when any note is added, updated, or deleted in the notes collection, the notes array in our component updates automatically. We don't call loadNotes() after every operation anymore — the real-time listener handles it.

This works across devices too. If User A adds a note on their phone, User B sees it appear instantly on their tablet. No refresh needed.


Step 7 : Firestore Security Rules

Before going to production, we must update Firestore security rules. The default test mode allows everyone to read and write everything — that's not safe for production.

Go to Firebase Console → Firestore → Rules tab.

Replace the default rules with proper authentication-based rules :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Users can only read and write their own notes
    match /notes/{noteId} {
      allow read, write: if request.auth != null
                         && request.auth.uid == resource.data.userId;

      // Allow create if the userId in the new doc matches the logged-in user
      allow create: if request.auth != null
                    && request.auth.uid == request.resource.data.userId;
    }

    // Users can only access their own user document
    match /users/{userId} {
      allow read, write: if request.auth != null
                         && request.auth.uid == userId;
    }
  }
}

Click Publish. These rules ensure only authenticated users can access data, and they can only read/write documents that belong to them.


Common Issues

Permission denied errors

Our Firestore security rules are blocking the request. Either we're not authenticated when the rules require auth, or the userId in the document doesn't match the logged-in user. Check the rules in Firebase Console and make sure the request satisfies them.

"Missing or insufficient permissions" after auth

The security rules check resource.data.userId for reads but for a new document that doesn't exist yet, we need request.resource.data.userId for creates. Make sure our rules handle both create and update separately if needed.

Real-time listener fires twice on start

Normal behaviour — Firestore fires the listener once from cache (if cached data exists) and once with fresh server data. Both calls happen quickly. This is by design and the data is eventually consistent.

orderBy and where on different fields

If we get an error about needing a composite index when combining where on one field with orderBy on another, Firestore shows a direct link in the error message in the console. Click that link to create the index in one click in the Firebase Console.

Data not showing after add

If using the one-time getCollection approach (not real-time), we need to call loadNotes() after every add/update/delete to refresh the data. If using listenToCollection, it updates automatically — we don't need to manually reload.


Summary

We learned how to store and retrieve data from Firebase Firestore in an Ionic app. We covered :

  • What Firestore is — collections, documents, and how data is structured
  • Setting up Firestore in Firebase Console and installing the Firebase SDK
  • Creating a reusable FirestoreService with add, set, get, query, update, delete, and real-time listeners
  • Using addDocument to create notes with auto-generated IDs and server timestamps
  • Using updateDocument to update specific fields without overwriting the whole document
  • Using deleteDocument to remove documents
  • Querying Firestore with where, orderBy, and limit constraints
  • Real-time updates with onSnapshot wrapped in an RxJS Observable
  • Unsubscribing from real-time listeners in ngOnDestroy to avoid memory leaks
  • Setting up Firestore security rules to protect data in production

Firestore is one of the cleanest ways to add cloud data storage to an Ionic app. Real-time sync, offline support, and no backend to manage. Once the FirestoreService is set up, adding data to any collection is just one method call from anywhere in our app.

I hope you like this article...

Happy coding! 🚀

Post a Comment

0 Comments