Content Layer: Current System Documentation
Reference doc for the SQLite migration. Captures exactly how the current filesystem-based content system works, where the pain points are, and what each function actually does.
Overview
The content layer lives in three files:
src/lib/posts.tsβ discovers and indexes all content by scanning the filesystemsrc/lib/content-loader.tsβ loads individual content by slugsrc/lib/routes.tsβ builds a flat route index for the Navigator search
Content lives in two directories:
content/tsx/β React components with exportedmetadataobjectscontent/md/β Markdown files with JSON frontmatter
posts.ts
What it does
Scans both content directories on every call and returns arrays of posts or docs with their metadata.
The problem: duplicated filesystem scans
getAllPosts() and getAllDocs() are nearly identical β both scan content/tsx/ and content/md/, import every file, parse metadata, deduplicate, and filter by type. The only difference is the final filter: posts check for type === 'post', docs check for type?.startsWith('doc').
This means any caller that needs both posts and docs triggers two full filesystem scans.
Key functions
getAllPosts()
- Reads all
.tsxfiles fromcontent/tsx/, dynamically imports each one, extractsmodule.metadata - Reads all
.mdfiles fromcontent/md/, parses JSON frontmatter - Deduplicates: TSX takes priority over MD for the same slug
- Filters to only return items where
typeis undefined or'post'
getAllDocs()
- Same scan as
getAllPosts()β reads both directories, imports TSX, parses MD frontmatter - Same deduplication logic
- Filters to only return items where
typestarts with'doc'(coversdoc,doc:commit, etc.)
getPostBySlug(slug)
- Calls
getAllPosts()(full scan) just to find one filename - Returns the filename string or null
getDocBySlug(slug)
- Same as above but calls
getAllDocs()
getAllAppRoutes()
- Recursively walks
src/app/looking forpage.tsxfiles - Skips dynamic routes (directories containing
[) - Skips client components by checking if file starts with
'use client' - Imports each page and checks for a
routeMetadataexport - Returns route entries for pages that have metadata
The scan cost
Every call to getAllPosts() or getAllDocs() does:
fs.readdirSync()on both directoriesimport()on every TSX file (dynamic import, not cached between requests in dev)fs.readFileSync()+ frontmatter parse on every MD file
At 69 files this is acceptable. At 500+ files this becomes noticeably slow.
content-loader.ts
What it does
Loads a single piece of content by slug β returns either a TSX component or markdown string depending on what exists.
Key types
export type ContentType = 'post' | 'doc';
export interface LoadedContent {
type: 'tsx' | 'markdown';
Component?: React.ComponentType;
markdownContent?: string;
metadata: PostMeta;
filename: string;
}
The type field is a discriminated union β callers know whether to render a React component or a markdown string.
Key functions
loadContentBySlug(slug, type)
- Calls
getAllPosts()orgetAllDocs()to find the matching item (full scan) - Then checks if a TSX file exists at the expected path with
existsSync() - If TSX exists: dynamically imports it, returns the default export as
Component - If not: reads the MD file, parses frontmatter, returns
markdownContent
Note: this does a full scan to find the filename, then separately checks if the file exists β redundant since the scan already confirmed it exists.
getAllPostSlugs() / getAllDocSlugs()
- Thin wrappers that call
getAllPosts()/getAllDocs()and map to slug arrays - Used by Next.js
generateStaticParams()for static build-time pre-rendering
routes.ts
What it does
Builds a flat RouteEntry[] index used by the Navigator search feature. Combines sitemap entries with post/doc metadata to produce searchable route data.
Key type
export interface RouteEntry {
path: string;
title: string;
description?: string;
keywords?: string[];
}
The problem: three scans per call
getAllRoutes() calls:
sitemap()β generates the full sitemap (which itself may scan content)getAllPosts()β full filesystem scangetAllDocs()β full filesystem scan
All three happen on every call to getAllRoutes().
How it works
- Iterates sitemap entries, extracts the URL pathname
- For
/posts/:slugroutes: finds matching post metadata, uses title/description/tags as keywords - For
/docs/:slugroutes: same but for docs - For
/: hardcoded Home entry - Fallback for any other route: derives title from the last path segment
What it produces
A flat array of RouteEntry objects β path, title, description, keywords. This is intentionally stripped down from the full PostMeta type. The Navigator only needs what it needs.
The core architectural problem
Every data access goes through a full filesystem scan. There is no caching layer, no persistent index, no way to query a subset of content without loading everything.
This works fine at current scale. It breaks down as content grows because:
- Related posts require loading every post into memory and comparing manually
- Tag filtering requires loading everything and filtering in JS
- Chronological queries require loading everything and sorting
- Any feature that needs to "find posts where X" requires a full scan
The SQLite migration replaces these filesystem scans with actual queries. The three files above become thin wrappers around SQL β the shape of the API stays the same, the internals become fast and queryable.
Data Model Problems
These are the design issues in the current PostMeta interface that should be fixed during the SQLite migration.
1. No runtime validation
TypeScript only enforces types at compile time. When frontmatter is parsed from a file:
const { metadata } = parseMarkdownWithJsonFrontmatter(fileContent);
return { filename, metadata };
You're casting the parsed object to PostMeta with zero runtime checks. The file could be missing required fields, have wrong types, or have typos in field names β TypeScript won't catch any of it. It all blows up at render time instead of at write time.
Required fields that are routinely missing in practice: description is typed as required string but the metadata scanner shows empty descriptions across many posts. Same risk with date. These should either be truly required (enforced at write time) or explicitly optional in the interface.
SQLite fixes this β NOT NULL constraints on title, slug, date mean bad data can't get in. You catch the problem when populating the DB, not when someone loads a page.
2. Inconsistent union types on author and updated
author?: string | string[]
updated?: string | string[]
Both fields are string | string[] β meaning every single consumer has to branch on Array.isArray() before using them. This pattern spreads defensive code everywhere and is easy to forget.
These should just always be arrays:
authors: string[] // always an array, required, minimum 1
updatedDates: string[] // always an array, empty if never updated
A single author is just an array of length 1. A single update date is just an array of length 1. No branching needed anywhere downstream. This happened organically β single author came first, multi-author was bolted on later with | string[] to avoid a breaking change. The SQLite migration is the right time to normalize this.
3. description is required but treated as optional
The interface says:
description: string; // required, non-optional
But in practice many posts don't have one. This means TypeScript thinks it's always there, so any code doing metadata.description.toLowerCase() or passing it to a component won't complain at compile time but will throw at runtime for posts missing it.
Should be description?: string to match reality, or actually enforced as required at write time via SQLite schema.
4. type union is incomplete
type?: 'post' | 'doc' | 'doc:commit'
But getAllDocs() filters with type?.startsWith('doc') β implying there are or could be other doc:* variants beyond doc:commit. The type definition doesn't match the actual filtering logic. Either lock down the union to all known variants or widen it and document the convention.
5. relatedPosts is manual and unvalidated
relatedPosts?: string[] // Array of slugs for related posts
These are freehand slug strings in a file. No validation that the slugs actually exist. A typo or a renamed slug silently produces a broken related posts link with no error anywhere.
SQLite with a foreign key constraint fixes this β a related_posts junction table referencing content.slug will reject invalid slugs at insert time.
What maps to what in SQLite
| Current | SQLite equivalent |
|---|---|
getAllPosts() | SELECT * FROM content WHERE type = 'post' |
getAllDocs() | SELECT * FROM content WHERE type LIKE 'doc%' |
getPostBySlug(slug) | SELECT filename FROM content WHERE slug = ? |
loadContentBySlug(slug) | SELECT * FROM content WHERE slug = ? AND type = ? |
getAllRoutes() | SELECT path, title, description, tags FROM content |
| Related posts (not yet built) | SELECT ... JOIN content_tags ... WHERE tag IN (...) |
| Tag filtering (not yet built) | SELECT * FROM content WHERE id IN (SELECT content_id FROM content_tags WHERE tag = ?) |
The migration doesn't change the API surface. getAllPosts() still returns the same shape. The filesystem scan just becomes a query.