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>.htmlvs/<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:
| 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:
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
- Types reference — the TypeScript shapes for these documents
- Operations: Firestore rules — security rules
- Settings → General