Skip to main content

Firestore data model

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.

{
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.

{
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).

{
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.

{
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.

{
// 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.

{
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.

{
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:

FieldsOrder
type ASC, createdAt DESCfor "All" tab
type ASC, status ASC, createdAt DESCfor 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:

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