Firestore data model

The complete picture of what's stored where in Firestore.

The complete picture of what's stored where in Firestore.

Collections

firestore/
├── posts/{postId}              — posts AND pages (distinguished by `type`)
├── terms/{termId}              — categories AND tags (distinguished by `type`)
├── media/{mediaId}             — uploaded images metadata
├── users/{uid}                 — admin/editor profiles + preferences
├── settings/                   — singleton documents
│   ├── site                    — every site-wide setting
│   └── externalRegistry        — installed external plugins / themes
└── config/                     — singleton documents
    └── flexweg                 — Flexweg API key + site URL

posts/{postId}

Single doc per post or page.

TS
{
  id: string,                          // same as docId
  type: "post" | "page",
  title: string,
  slug: string,                        // unique within type
  contentMarkdown: string,
  excerpt?: string,
  heroMediaId?: string,                // → media/{id}
  authorId: string,                    // → users/{uid}
  termIds: string[],                   // → terms/{id} (categories + tags mixed)
  primaryTermId?: string,              // the category — affects URL
  status: "draft" | "online",
  seo?: { title, description, ogImage },
  createdAt: Timestamp,
  updatedAt: Timestamp,
  publishedAt?: Timestamp,             // set on first publish
  lastPublishedPath?: string,          // current live URL path
  previousPublishedPaths?: string[],   // failed-deletion retries
  lastPublishedHash?: string,          // sha256 of rendered HTML
  legacyUrl?: string,                  // imported from external CMS
}

Why one collection for posts + pages

Pages are structurally identical to posts — title, body, slug, hero, SEO. Splitting them would mean duplicate code paths everywhere. Querying for type-specific lists is a single where("type", "==", "post") filter.

The minor cost: a global orderBy(createdAt) query mixes posts + pages. The publisher / hooks always know what they want and filter accordingly.

Slug uniqueness

Slugs must be unique per type:

  • Two posts can't share a slug
  • Two pages can't share a slug
  • A post and a page CAN share a slug (their URLs differ — /<post>.html vs /<page>.html)

The admin's findAvailableSlug enforces this on auto-generation; user-edited slugs trigger an inline collision warning.

terms/{termId}

Single doc per category or tag.

TS
{
  id: string,
  type: "category" | "tag",
  name: string,
  slug: string,                        // unique within type
  description?: string,
  parentId?: string,                   // for hierarchical categories
  createdAt: Timestamp,
  updatedAt: Timestamp,
  lastPublishedPath?: string,          // category archive path
}

Hierarchical categories work via parentId chains. Tags are flat (parentId is ignored).

media/{mediaId}

Single doc per uploaded asset (one or many file variants).

TS
{
  id: string,
  filename: string,                    // user-uploaded filename
  alt?: string,
  caption?: string,
  uploadedAt: Timestamp,
  uploadedBy: string,                  // user uid

  // Modern format (every upload after the variant pipeline):
  formats?: {
    [formatName: string]: {
      url: string,
      width: number,
      height: number,
      bytes: number,
    }
  },
  defaultFormat?: string,              // name of the default variant
  originalSlug?: string,               // slugified filename + 6-char hex suffix

  // Legacy single-URL format (uploaded before variant pipeline):
  url?: string,
  storagePath?: string,
}

The formats map's keys come from the active theme's imageFormats.formats object plus the two admin-only formats (admin-thumb, admin-preview).

Modern vs legacy

The pipeline rewrite (variants instead of single URL) was a one-shot upgrade. Legacy entries don't get auto-migrated. The mediaToView helper synthesises a legacy format from { url, storagePath } so consumer code doesn't branch on the shape.

users/{uid}

One doc per CMS user. Keyed by Firebase Auth uid.

TS
{
  uid: string,                         // matches docId
  email: string,
  displayName?: string,
  firstName?: string,
  lastName?: string,
  role: "admin" | "editor",
  bio?: string,
  title?: string,                      // public job title
  avatarMediaId?: string,              // → media/{id}
  socials?: SocialEntry[],
  preferences?: {
    adminLocale?: AdminLocale,
  },
}

type SocialEntry = {
  network: SocialNetwork,
  url: string,
  visible: boolean,                    // public surface gate
};

Bootstrap admin

The admin email pinned in VITE_ADMIN_EMAIL (or via the SetupForm) is treated as admin even without a users/ doc. On first action, ensureSelfUserRecord writes the doc with role: "admin".

settings/site

Singleton — one doc, lots of fields.

TS
{
  // Site identity
  title: string,
  description?: string,
  language?: string,                   // BCP-47 — `<html lang>` for public pages
  baseUrl?: string,                    // absolute URL — required for sitemaps/RSS
  logoMediaId?: string,
  faviconMediaId?: string,

  // Theme
  activeThemeId: string,
  themeConfigs?: {
    [themeId: string]: unknown,        // each theme owns its config shape
  },

  // Plugins
  enabledPlugins?: {
    [pluginId: string]: boolean,
  },
  pluginConfigs?: {
    [pluginId: string]: unknown,       // each plugin owns its config shape
  },

  // Content layout
  homeMode?: "latest-posts" | "static-page",
  homePageId?: string,                 // → posts/{id} where type === "page"
  postsPerPage?: number,

  // Performance
  paginationMode?: "global" | "paginated",

  // Menus
  menus?: {
    header: MenuItem[],
    footer: MenuItem[],
  },

  // Authors
  socials?: SocialEntry[],             // site-wide socials (footer, etc.)

  // Misc
  copyright?: string,
  updatedAt?: Timestamp,
}

Plugin configs are nested-merged on save — partial saves don't wipe sibling fields.

settings/externalRegistry

Singleton — tracks installed external plugins / themes.

TS
{
  plugins: {
    [pluginId: string]: {
      version: string,
      apiVersion: string,
      installedAt: number,             // epoch millis
    }
  },
  themes: {
    [themeId: string]: { /* same shape */ }
  }
}

Modified on every install / uninstall flow. Read once at boot to drive loadAllExternalEntries().

config/flexweg

Singleton — Flexweg API credentials.

TS
{
  apiKey: string,                      // sensitive
  siteUrl?: string,                    // public site URL (e.g. "https://example.com")
}

Stored separately from settings/site so Firestore rules can be stricter on this doc — only admins read / write, never editors. Since editors don't need to publish (they edit drafts; admins approve), they don't need the API key.

Indexes

Single-field auto-indexes

Firestore creates these automatically. Cover most queries.

Composite indexes (paginated mode only)

When settings/site.paginationMode === "paginated", two composite indexes on the posts collection are mandatory:

Fields Order
type ASC, createdAt DESC for "All" tab
type ASC, status ASC, createdAt DESC for Draft / Online filtered tabs

In global mode (the default), no composite indexes are required.

See Settings → Performance for the index creation paths.

Querying without indexes

fetchAllPosts({ type? }) is intentionally index-free: no where clause, no orderBy clause. It fetches the entire posts collection in one shot, filters by type and sorts by createdAt desc in memory.

Cached for 30s with in-flight promise dedup. Invalidated on every write through createPost / updatePost / markPostOnline / markPostDraft / deletePost.

This is why the publisher works without composite indexes in either pagination mode.

Subcollections (none)

The schema is flat — no subcollections. A post's revisions, comments, etc. would live in their own top-level collections (e.g. revisions/{postId}/...) if added later. Don't store related data as subcollections — it complicates security rules and querying.

Backups

Firestore offers managed backups via Cloud Storage exports. Configure via:

BASH
gcloud firestore export gs://your-backup-bucket

Or via the Firebase Console's Import / Export UI. Restoring overwrites all docs at the destination.

For incremental backups, use a third-party tool — Firestore doesn't natively support delta exports.

Continue