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
Model one-to-many with subcollections
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.
1import { collection, addDoc, getDocs, query, orderBy, serverTimestamp } from 'firebase/firestore'23// Add a comment to a specific post4async 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}1213// Get all comments for a post, ordered by time14async 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.
Model many-to-many with arrays of references
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.
1import { doc, updateDoc, arrayUnion, arrayRemove, getDocs, query, where, collection } from 'firebase/firestore'23// Add user to a group4async function joinGroup(userId: string, groupId: string) {5 // Add groupId to user's groups array6 await updateDoc(doc(db, 'users', userId), {7 groups: arrayUnion(groupId),8 })9 // Add userId to group's members array10 await updateDoc(doc(db, 'groups', groupId), {11 members: arrayUnion(userId),12 })13}1415// Find all users in a group16async 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}2425// Remove user from group26async 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.
Use a junction collection for large many-to-many relationships
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.
1import { addDoc, deleteDoc, getDocs, query, where, collection, doc, getDoc } from 'firebase/firestore'23// Junction collection: group_memberships4// Each doc: { userId, groupId, role, joinedAt }56async 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}1415// Find all groups for a user16async 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())2324 // Fetch full group documents25 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 groups32}Expected result: Relationships are stored in a dedicated collection that scales to any size and supports metadata on each relationship.
Denormalize data for read-heavy access patterns
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.
1import { doc, setDoc, serverTimestamp } from 'firebase/firestore'23// Store denormalized author data on the post4async function createPost(userId: string, title: string, body: string) {5 const userDoc = await getDoc(doc(db, 'users', userId))6 const userData = userDoc.data()!78 return setDoc(doc(collection(db, 'posts')), {9 title,10 body,11 authorId: userId,12 // Denormalized copies — avoids reading users collection on every post view13 authorName: userData.displayName,14 authorAvatar: userData.photoURL,15 createdAt: serverTimestamp(),16 })17}1819// When the user updates their profile, update all their posts too20// Best done in a Cloud Function trigger on users/{userId}21// onDocumentUpdated: query posts where authorId == userId,22// batch update authorName and authorAvatarExpected result: Post documents include author display data, eliminating the need for a separate user document read on every post view.
Query across subcollections with collection group queries
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.
1import { collectionGroup, query, where, getDocs, orderBy } from 'firebase/firestore'23// Find all comments by a user across ALL posts4async 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}1718// Security rules for collection group queries must be at the19// 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
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'1920const 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)2627// --- 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}3334// --- 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}3940// --- 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}4647// --- 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation