Use Cases (Updated: 6/1/2026)

Firestore Schema Design with Claude Code: A GCP/Firebase SaaS Guide

Design Firestore collections, rules, indexes, and SaaS schemas with Claude Code without common security mistakes.

Firestore Schema Design with Claude Code: A GCP/Firebase SaaS Guide

Firestore design starts from reads, not from tidy collection names

I am Masa, the operator of claudecode-lab.com.

The first Firestore mistake I made was very normal: I opened a blank project and started naming collections. users, projects, events, subscriptions looked clean. Then the real SaaS screens arrived: project dashboards, member lists, audit events, trial expiry reminders, and admin billing views. The schema looked tidy, but the queries were awkward.

Firestore is a document database, not a relational database with joins that can fix every modeling decision later. The official Firestore data model describes data as documents stored inside collections. Documents can hold fields, nested objects, and subcollections. A simple mental model is: a collection is a shelf, a document is a file on that shelf, and a subcollection is a smaller folder attached to that file.

That flexibility is powerful, but it also means you should design from the way the product reads data. Which screen needs a list? Which field filters it? Which order does the UI need? Which user is allowed to see it? Which composite index will support it? Those questions should come before the folder names.

This guide shows how I use Claude Code as a local design reviewer for a GCP/Firebase SaaS schema. The example covers users, projects, project events, subscription status, Security Rules, composite indexes, collection group queries, and copy-paste TypeScript. The goal is not to let Claude invent your architecture. The goal is to make Claude Code expose contradictions before those contradictions become production bugs.


Explain collections, documents, and subcollections in product terms

Firestore data always lives in a document, and every document lives in a collection. A path such as users/{uid} means: the users collection contains a document whose ID is the user ID. A path such as projects/{projectId}/events/{eventId} means: each project document has an events subcollection with event documents inside it.

For a small SaaS, I usually start with this shape:

users/{uid}
projects/{projectId}
projects/{projectId}/members/{uid}
projects/{projectId}/events/{eventId}
subscriptions/{uid}
billingCustomers/{uid}
PathPurposeTypical read
users/{uid}Profile fields such as email and display nameCurrent user profile
projects/{projectId}Workspace or customer projectProject detail page
projects/{projectId}/members/{uid}Project-level role and membershipAccess checks and member list
projects/{projectId}/events/{eventId}Audit log and activity feedRecent events by project
subscriptions/{uid}Plan and billing statusFeature gating
billingCustomers/{uid}Stripe or billing provider IDsServer-only billing jobs

The important choice is whether a document should be top-level or nested. Project events belong naturally under a project because the most common screen is probably “recent events for this project”. If you also need “recent events across all projects”, you can add a collection group query later, but that decision affects indexes and rules.

A better prompt for Claude Code is not “design Firestore”. Ask for the reads first:

claude -p "
Review a Firestore design for a B2B SaaS.
Before proposing collections, create a query inventory by screen.

Screens:
- Projects the current user belongs to
- Project detail
- Last 50 events inside one project
- Admin billing status list
- Users whose trial ends soon

For each screen, return where/orderBy/limit, required composite indexes,
and the Security Rules condition that must match the query.
"

This forces the conversation toward working product behavior instead of attractive but untested diagrams.


A SaaS schema example you can actually implement

Here is a practical server-side model for Firebase Admin SDK or Cloud Functions. Even if your client writes some documents directly, defining these shapes first makes validation and review much easier.

import type { Timestamp } from "firebase-admin/firestore";

export type ProjectRole = "owner" | "admin" | "member" | "viewer";
export type SubscriptionStatus =
  | "trialing"
  | "active"
  | "past_due"
  | "canceled";

export interface UserDoc {
  uid: string;
  email: string;
  displayName: string;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

export interface ProjectDoc {
  id: string;
  name: string;
  ownerUid: string;
  plan: "free" | "starter" | "pro";
  memberCount: number;
  lastEventAt: Timestamp | null;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

export interface ProjectMemberDoc {
  uid: string;
  role: ProjectRole;
  displayName: string;
  email: string;
  joinedAt: Timestamp;
}

export interface ProjectEventDoc {
  id: string;
  projectId: string;
  actorUid: string;
  actorName: string;
  type: "created" | "updated" | "commented" | "exported";
  message: string;
  createdAt: Timestamp;
}

export interface SubscriptionDoc {
  uid: string;
  status: SubscriptionStatus;
  plan: "free" | "starter" | "pro";
  currentPeriodEnd: Timestamp | null;
  trialEndsAt: Timestamp | null;
  updatedAt: Timestamp;
}

The duplicated displayName and email inside ProjectMemberDoc are intentional denormalization. Denormalization means copying a small piece of data to avoid extra reads. If a member list has 50 people, reading 50 separate users/{uid} documents just to show names is wasteful. Keeping the display fields on the membership document makes the list a single query. You then synchronize the duplicate fields when a profile changes.

A first real example is the home dashboard. If the first screen must show projects the current user belongs to, add a per-user reference collection:

users/{uid}/projectRefs/{projectId}
  projectId: string
  projectName: string
  role: "owner" | "admin" | "member" | "viewer"
  lastEventAt: Timestamp | null

This is duplicated data, but it turns the home page into one predictable read pattern. That is usually a good trade in Firestore.


Security Rules are not filters

This is the biggest Firestore trap. The official secure query documentation says Security Rules are not filters. Queries are evaluated as all-or-nothing. If a query could return a document the user is not allowed to read, the whole query fails.

Consider this rule:

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /projects/{projectId}/events/{eventId} {
      allow list: if request.auth != null
        && exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid))
        && request.query.limit <= 50;
    }
  }
}

The rule says a project member may list up to 50 events in that project. This client query is invalid because it does not include the limit required by the rule:

import { collection, getDocs } from "firebase/firestore";

// Bad: the rule requires a limit, but the query does not provide one.
await getDocs(collection(db, "projects", projectId, "events"));

Write the query so it matches the rule:

import {
  collection,
  getDocs,
  limit,
  orderBy,
  query,
} from "firebase/firestore";

export async function listProjectEvents(projectId: string) {
  const eventsRef = collection(db, "projects", projectId, "events");
  const eventsQuery = query(
    eventsRef,
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

A second example is public content. If your rule allows reads only when resource.data.visibility == "public", your query should include where("visibility", "==", "public"). Firestore will not silently return only the documents the user can read. Either the query is provably safe, or it fails.

This is why I ask Claude Code to review rules and queries together. A rules file by itself can look safe. A query file by itself can look normal. The bug lives in the mismatch between them.


Design composite indexes and collection group queries before launch

Firestore creates basic indexes automatically, but compound filters and sorting often require composite indexes. The official index management page explains that missing indexes produce errors with links to create the required index. That is helpful during development, but I prefer to commit firestore.indexes.json so index decisions are reviewed in Git.

{
  "indexes": [
    {
      "collectionGroup": "projectRefs",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "role", "order": "ASCENDING" },
        { "fieldPath": "lastEventAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "events",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        { "fieldPath": "projectId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "subscriptions",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "trialEndsAt", "order": "ASCENDING" }
      ]
    }
  ],
  "fieldOverrides": []
}

A collection group query reads across every subcollection with the same collection ID. This is useful for projects/{projectId}/events/{eventId} when you need a cross-project event view.

import {
  collectionGroup,
  getDocs,
  limit,
  orderBy,
  query,
  where,
} from "firebase/firestore";

export async function listRecentEventsAcrossProjects(projectId: string) {
  const eventsQuery = query(
    collectionGroup(db, "events"),
    where("projectId", "==", projectId),
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

For collection group queries, rules need extra care. The official rules structure guide notes that match statements point to document paths, not collections. Collection group access also needs rules version 2 and a recursive wildcard pattern.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    function isProjectMember(projectId) {
      return signedIn()
        && exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid));
    }

    match /{path=**}/events/{eventId} {
      allow list: if signedIn()
        && request.query.limit <= 50
        && resource.data.projectId is string
        && isProjectMember(resource.data.projectId);

      allow get: if signedIn()
        && resource.data.projectId is string
        && isProjectMember(resource.data.projectId);
    }
  }
}

Test this in the Firebase Emulator. Collection group rules apply to all collections named events, even if you later create another events collection for billing webhooks or email logs. If two concepts need different access rules, give them different collection IDs.


Ask Claude Code for a local design review

The most useful Claude Code prompt is a review prompt, not a generation prompt. Put docs/firestore-schema.md, firestore.rules, firestore.indexes.json, and your query functions in the repository, then ask for contradictions.

claude -p "
Review this Firestore design locally.
Files:
- docs/firestore-schema.md
- firestore.rules
- firestore.indexes.json
- src/lib/firestore/queries.ts

Check:
1. Does every screen query match the schema?
2. Are Security Rules being used as filters by mistake?
3. Do all list queries include the required where/orderBy/limit?
4. Are composite indexes missing or excessive?
5. Is any collection group query too broad?
6. Can a client tamper with subscription status?
7. Which screen has too many document reads?

Return issues, the reason, and corrected code.
"

A third concrete example is billing. Do not let clients write subscriptions/{uid}. A server webhook or Cloud Function should own that document.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    match /subscriptions/{uid} {
      allow get: if signedIn() && request.auth.uid == uid;
      allow list: if false;
      allow create, update, delete: if false;
    }
  }
}

Also enforce plan status on the server. UI hiding is not authorization.

import { getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export async function assertActiveSubscription(uid: string) {
  const snap = await db.collection("subscriptions").doc(uid).get();
  const data = snap.data();

  if (!data || !["trialing", "active"].includes(data.status)) {
    throw new Error("Active subscription required");
  }

  return data;
}

Failure cases I now check every time

First, avoid sequential document IDs. Use automatic IDs and store slugs separately if you need stable URLs.

import { FieldValue, getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export async function createProject(name: string, ownerUid: string) {
  const projectRef = db.collection("projects").doc();

  await projectRef.set({
    id: projectRef.id,
    name,
    ownerUid,
    plan: "free",
    memberCount: 1,
    lastEventAt: null,
    createdAt: FieldValue.serverTimestamp(),
    updatedAt: FieldValue.serverTimestamp(),
  });

  await projectRef.collection("members").doc(ownerUid).set({
    uid: ownerUid,
    role: "owner",
    joinedAt: FieldValue.serverTimestamp(),
  });

  return projectRef.id;
}

Second, never separate rules review from query review. If the rule requires limit <= 50, the query needs limit(50). If the rule requires public visibility, the query needs a matching where clause.

Third, keep billing state separate from profile state. users/{uid}.plan = "pro" is tempting, but it mixes user profile edits with subscription authority. A dedicated subscriptions/{uid} document with server-only writes is easier to audit.

Fourth, do not reuse a generic collection name such as events for every log in the product. Collection group queries cross the same collection ID everywhere. Use projectEvents, auditEvents, or billingEvents when access rules differ.

I tested this workflow on a contact manager, a content admin tool, and a small SaaS demo. The projects that started with a query inventory needed fewer rewrites than the ones that started with collection names. The biggest improvement came from making Claude Code review schema, rules, indexes, and query functions in one pass.

For more GCP work, read Claude Code x GCP Cloud Functions and Claude Code x GCP Cloud Run. If your API boundaries are still unclear, REST API Design with Claude Code is the companion piece.

ClaudeCodeLab is also turning these patterns into free PDFs, learning materials, and consultation checklists. If you want a Firestore or GCP design reviewed before implementation, bring your schema, rules, and query list; the review becomes much more concrete.

#claude-code #gcp #firestore #database #typescript #query-design
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.