Skip to main content
RapidDev - Software Development Agency
firebase-tutorial

How to Model Relationships in Firestore

Firestore is a NoSQL document database with no native joins, so modeling relationships requires deliberate design choices. One-to-many relationships work best as subcollections when data is always accessed through the parent. Many-to-many relationships use arrays of document IDs or a junction collection. Frequently queried data should be denormalized (duplicated) to avoid multiple reads. Use collection group queries to search across subcollections. Design your data model around your query patterns, not around how the entities relate logically.

What you'll learn

  • How to model one-to-many relationships with subcollections
  • How to model many-to-many relationships with arrays or junction collections
  • When to denormalize data versus use references
  • How to use collection group queries for cross-parent searches
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate8 min read15-20 minFirebase JS SDK v9+, Firestore (all plans)March 2026RapidDev Engineering Team
TL;DR

Firestore is a NoSQL document database with no native joins, so modeling relationships requires deliberate design choices. One-to-many relationships work best as subcollections when data is always accessed through the parent. Many-to-many relationships use arrays of document IDs or a junction collection. Frequently queried data should be denormalized (duplicated) to avoid multiple reads. Use collection group queries to search across subcollections. Design your data model around your query patterns, not around how the entities relate logically.

Modeling Relationships in Firestore

Unlike relational databases, Firestore has no JOIN operations, no foreign key constraints, and no way to query across collections in a single read (except collection group queries). This means your data model must be designed around how you query it, not how entities relate to each other. This tutorial covers the three main relationship patterns — embedding, subcollections, and root-level references — and when to use each one.

Prerequisites

  • A Firebase project with Firestore enabled
  • Firebase JS SDK v9+ installed
  • Basic understanding of Firestore documents and collections
  • Familiarity with your application's query patterns

Step-by-step guide

1

Model one-to-many with subcollections

Subcollections are ideal when child data is always accessed in the context of a parent. For example, a blog post's comments belong to that post. Each post document has a comments subcollection. This keeps related data co-located and lets you query comments for a specific post without filtering. Subcollections scale well because querying a subcollection only reads that subcollection, not the parent document.

typescript
1import { collection, addDoc, getDocs, query, orderBy, serverTimestamp } from 'firebase/firestore'
2
3// Add a comment to a specific post
4async function addComment(postId: string, userId: string, text: string) {
5 const commentsRef = collection(db, 'posts', postId, 'comments')
6 return addDoc(commentsRef, {
7 userId,
8 text,
9 createdAt: serverTimestamp(),
10 })
11}
12
13// Get all comments for a post, ordered by time
14async function getComments(postId: string) {
15 const q = query(
16 collection(db, 'posts', postId, 'comments'),
17 orderBy('createdAt', 'asc')
18 )
19 const snapshot = await getDocs(q)
20 return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
21}

Expected result: Comments are stored as a subcollection under each post document and can be queried independently.

2

Model many-to-many with arrays of references

For many-to-many relationships like users and groups, store an array of group IDs on the user document (or vice versa). Use the array-contains operator to find all users in a group, or the in operator to fetch multiple groups by ID. This works well when the array is small (under 50 items) and you need fast lookups in both directions.

typescript
1import { doc, updateDoc, arrayUnion, arrayRemove, getDocs, query, where, collection } from 'firebase/firestore'
2
3// Add user to a group
4async function joinGroup(userId: string, groupId: string) {
5 // Add groupId to user's groups array
6 await updateDoc(doc(db, 'users', userId), {
7 groups: arrayUnion(groupId),
8 })
9 // Add userId to group's members array
10 await updateDoc(doc(db, 'groups', groupId), {
11 members: arrayUnion(userId),
12 })
13}
14
15// Find all users in a group
16async function getGroupMembers(groupId: string) {
17 const q = query(
18 collection(db, 'users'),
19 where('groups', 'array-contains', groupId)
20 )
21 const snapshot = await getDocs(q)
22 return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
23}
24
25// Remove user from group
26async function leaveGroup(userId: string, groupId: string) {
27 await updateDoc(doc(db, 'users', userId), {
28 groups: arrayRemove(groupId),
29 })
30 await updateDoc(doc(db, 'groups', groupId), {
31 members: arrayRemove(userId),
32 })
33}

Expected result: Users and groups are linked through arrays, queryable in both directions with array-contains.

3

Use a junction collection for large many-to-many relationships

When arrays would grow too large, create a separate junction collection (like a SQL join table). Each document represents one relationship and stores both IDs. This scales to millions of relationships and supports additional metadata like the date joined or the user's role in the group. Query one side by filtering on the other side's ID.

typescript
1import { addDoc, deleteDoc, getDocs, query, where, collection, doc, getDoc } from 'firebase/firestore'
2
3// Junction collection: group_memberships
4// Each doc: { userId, groupId, role, joinedAt }
5
6async function addMembership(userId: string, groupId: string, role: string) {
7 return addDoc(collection(db, 'group_memberships'), {
8 userId,
9 groupId,
10 role,
11 joinedAt: serverTimestamp(),
12 })
13}
14
15// Find all groups for a user
16async function getUserGroups(userId: string) {
17 const q = query(
18 collection(db, 'group_memberships'),
19 where('userId', '==', userId)
20 )
21 const snapshot = await getDocs(q)
22 const memberships = snapshot.docs.map((d) => d.data())
23
24 // Fetch full group documents
25 const groups = await Promise.all(
26 memberships.map(async (m) => {
27 const groupDoc = await getDoc(doc(db, 'groups', m.groupId))
28 return { id: groupDoc.id, ...groupDoc.data(), role: m.role }
29 })
30 )
31 return groups
32}

Expected result: Relationships are stored in a dedicated collection that scales to any size and supports metadata on each relationship.

4

Denormalize data for read-heavy access patterns

When you frequently need data from a related document (like the author name on every post), store a copy of that data directly on the document instead of fetching the related document separately. This trades write complexity for read performance. Update denormalized copies when the source changes, either manually or with a Cloud Function trigger.

typescript
1import { doc, setDoc, serverTimestamp } from 'firebase/firestore'
2
3// Store denormalized author data on the post
4async function createPost(userId: string, title: string, body: string) {
5 const userDoc = await getDoc(doc(db, 'users', userId))
6 const userData = userDoc.data()!
7
8 return setDoc(doc(collection(db, 'posts')), {
9 title,
10 body,
11 authorId: userId,
12 // Denormalized copies — avoids reading users collection on every post view
13 authorName: userData.displayName,
14 authorAvatar: userData.photoURL,
15 createdAt: serverTimestamp(),
16 })
17}
18
19// When the user updates their profile, update all their posts too
20// Best done in a Cloud Function trigger on users/{userId}
21// onDocumentUpdated: query posts where authorId == userId,
22// batch update authorName and authorAvatar

Expected result: Post documents include author display data, eliminating the need for a separate user document read on every post view.

5

Query across subcollections with collection group queries

Collection group queries let you search across all subcollections that share the same name, regardless of parent document. For example, you can find all comments across all posts by a specific user. This requires a collection group index, which Firestore will prompt you to create when you first run the query.

typescript
1import { collectionGroup, query, where, getDocs, orderBy } from 'firebase/firestore'
2
3// Find all comments by a user across ALL posts
4async function getUserComments(userId: string) {
5 const q = query(
6 collectionGroup(db, 'comments'),
7 where('userId', '==', userId),
8 orderBy('createdAt', 'desc')
9 )
10 const snapshot = await getDocs(q)
11 return snapshot.docs.map((doc) => ({
12 id: doc.id,
13 path: doc.ref.path, // e.g., 'posts/post-1/comments/comment-1'
14 ...doc.data(),
15 }))
16}
17
18// Security rules for collection group queries must be at the
19// collection group level:
20// match /{path=**}/comments/{commentId} {
21// allow read: if request.auth != null;
22// }

Expected result: A single query returns matching documents from all subcollections named 'comments' across the entire database.

Complete working example

firestore-relationships.ts
1import { initializeApp } from 'firebase/app'
2import {
3 getFirestore,
4 collection,
5 doc,
6 addDoc,
7 getDoc,
8 getDocs,
9 setDoc,
10 updateDoc,
11 query,
12 where,
13 orderBy,
14 collectionGroup,
15 arrayUnion,
16 arrayRemove,
17 serverTimestamp,
18} from 'firebase/firestore'
19
20const app = initializeApp({
21 apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
22 authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
23 projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
24})
25const db = getFirestore(app)
26
27// --- One-to-many: subcollection ---
28export async function addComment(postId: string, userId: string, text: string) {
29 return addDoc(collection(db, 'posts', postId, 'comments'), {
30 userId, text, createdAt: serverTimestamp(),
31 })
32}
33
34// --- Many-to-many: arrays ---
35export async function joinGroup(userId: string, groupId: string) {
36 await updateDoc(doc(db, 'users', userId), { groups: arrayUnion(groupId) })
37 await updateDoc(doc(db, 'groups', groupId), { members: arrayUnion(userId) })
38}
39
40// --- Many-to-many: junction collection ---
41export async function addMembership(userId: string, groupId: string) {
42 return addDoc(collection(db, 'group_memberships'), {
43 userId, groupId, joinedAt: serverTimestamp(),
44 })
45}
46
47// --- Collection group query ---
48export async function getUserComments(userId: string) {
49 const q = query(
50 collectionGroup(db, 'comments'),
51 where('userId', '==', userId),
52 orderBy('createdAt', 'desc')
53 )
54 const snap = await getDocs(q)
55 return snap.docs.map((d) => ({ id: d.id, path: d.ref.path, ...d.data() }))
56}

Common mistakes when modeling Relationships in Firestore

Why it's a problem: Designing the data model like a relational database with separate normalized collections and manual joins

How to avoid: Embrace denormalization. Firestore has no joins, so duplicate data that is read together. Design around query patterns, not entity relationships.

Why it's a problem: Storing unbounded arrays (thousands of IDs) in a document, hitting the 1 MiB document size limit

How to avoid: Switch to a junction collection when arrays could grow beyond 50-100 items. Junction collections scale without document size limits.

Why it's a problem: Forgetting to add collection group scope security rules for collection group queries

How to avoid: Add a wildcard path rule: match /{path=**}/comments/{commentId} { allow read: if request.auth != null; } to authorize collection group queries.

Best practices

  • Design your data model around query patterns, not entity-relationship diagrams
  • Use subcollections for one-to-many data that is always accessed through the parent
  • Use arrays for small many-to-many relationships (under 50 items) that need fast lookups
  • Use a junction collection for many-to-many relationships that could grow to thousands of items
  • Denormalize frequently read data to avoid extra document reads on every page load
  • Use Cloud Functions to keep denormalized copies in sync when source data changes
  • Leverage collection group queries to search across subcollections without restructuring your data

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building a social app with Firestore. Users can create posts, comment on posts, and join groups. Show me how to model these relationships using subcollections, arrays of references, and denormalization. Use the Firebase modular SDK v9.

Firebase Prompt

Create a Firestore data model for a project management app with teams, projects, and tasks. Use subcollections for tasks under projects, arrays for team membership, and denormalize the project name onto task documents. Include all CRUD functions.

Frequently asked questions

Should I use subcollections or root-level collections?

Use subcollections when the child data is always accessed through the parent (e.g., comments on a post). Use root-level collections when you need to query the data across all parents (e.g., all messages from all chat rooms). Collection group queries blur this line by letting you query subcollections globally.

How do I keep denormalized data in sync?

Use a Cloud Function triggered on document updates. When the source document changes, the function queries all documents that contain the denormalized copy and updates them. For example, when a user changes their display name, update all their posts' authorName field.

Is there a limit to how many items an array field can hold?

There is no explicit item limit, but the entire document (including all arrays) must be under 1 MiB. In practice, arrays with more than a few hundred items become unwieldy. Use a junction collection for larger relationships.

Can I do a SQL-style JOIN in Firestore?

No. Firestore has no server-side join operation. You must either denormalize data into a single document, perform multiple reads on the client, or use Cloud Functions to pre-aggregate data.

What is a collection group query?

A collection group query searches across all subcollections with the same name, regardless of their parent document. For example, collectionGroup(db, 'comments') queries every comments subcollection in the database.

Can RapidDev help design my Firestore data model?

Yes. RapidDev can analyze your application requirements, design an optimized Firestore data model with proper denormalization, and set up Cloud Functions to keep related data in sync.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.