The Problem This System Solves Copied!
I wanted a journal that behaved like a small knowledge base. Articles live in folders. Some folders act like sections. Others behave like nested topics.
The UI needs structure, navigation, ordering, and relationships between entries.
Astro gives a strong base with content collections. The project adds a layer that builds a manifest. That manifest becomes the source of truth for the UI.
The flow looks like this.
MDX Files
│
▼
Astro Content Collection
│
▼
getCollection("thejournal")
│
▼
loadManifest()
│
├── entryManifest
├── vaultsManifest
└── rawEntries
│
▼
UI pages read the manifestEach stage adds structure.
Step 1. The Content Lives as MDX Files Copied!
Every journal entry lives under this directory.
src/thejournal/Example structure.
src/thejournal
│
├── index.mdx
│
├── architecture
│ ├── index.mdx
│ ├── astro-content.mdx
│ └── static-site-structure.mdx
│
└── javascript
├── index.mdx
└── closures.mdxImportant convention.
Every folder has an index.mdx. That file acts as the section root.
This project calls those sections vaults.
So the folder becomes a vault. The index file becomes the vault entry.
Step 2. Astro Collections Register the Content Copied!
Astro collections provide a typed content layer.
The configuration lives in src/content.config.ts.
import { glob } from "astro/loaders";
import { defineCollection, z } from "astro:content";
const thejournal = defineCollection({
loader: glob({
pattern: "**/*.mdx",
base: "./src/thejournal",
}),
schema: ({ image }) =>
z.object({
title: z.string(),
github: z.string().optional(),
image: image().optional(),
description: z.string().default("Without description available."),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
order: z.number().default(100),
}),
});
export const collections = { thejournal };This does several things.
Astro scans all MDX files under src/thejournal.
Each file becomes a typed entry.
The schema enforces frontmatter structure.
Example frontmatter.
---
title: Astro Content Collections
description: How Astro organizes content
pubDate: 2025-01-01
tags: [astro, content]
order: 1
---Benefits of collections.
- Schema validation
- Type safety
- Automatic content discovery
- Unified loading through getCollection
- Clear separation between code and content
The project now has structured content. It still lacks hierarchy.
That is where the manifest comes in.
Step 3. The Manifest Builder Copied!
The filesrc/utils/thejournal_manifest.tsbuilds the system that the UI actually uses.
Its job is to transform raw entries into a navigable structure.
The process begins here.
const rawEntries = await getCollection("thejournal");getCollection returns every journal entry with metadata and body.
But the UI needs more information.
Examples.
- folder hierarchy
- navigation order
- previous and next article
- vault grouping
The manifest layer computes all of this.
Step 4. What Is a Vault Copied!
A vault represents a root directory inside the journal.
Example.
src/thejournal/architectureThe file
src/thejournal/architecture/index.mdxdefines the vault entry.
The rest of the files belong to that vault.
architecture
├── index.mdx
├── astro-content.mdx
└── static-site-structure.mdxThe vault becomes a navigable tree.
Vault
│
├── Index Article
├── Article A
└── Article BNested folders create nested vault nodes.
Step 5. Loading the Manifest Copied!
The manifest loader returns three resources.
export const [entryManifest, vaultsManifest, rawEntries] = await loadManifest();Each one serves a different purpose.
entryManifest Copied!
This is a flat lookup table for all entries.
Structure.
Record<string, EntryContext>;Example.
{
"architecture/astro-content.mdx": EntryContext,
"architecture/static-site-structure.mdx": EntryContext
}Purpose.
Fast access to entry metadata from an id.
Used by routing, navigation, and article pages.
The data is produced by this function.
function mapEntryToContext(entry: JournalEntry): EntryContext;It converts a raw Astro entry into a simplified context object.
Fields include
- id
- filepath
- title
- description
- tags
- pubDate
- readTime
- vaultId
Read time is calculated like this.
const wordsPerMinute = 160;
const words = entry.body?.trim().split(/\s+/).length;vaultsManifest Copied!
This object represents the journal structure.
Type.
Record<string, VaultContext>;Each vault contains
VaultContext
│
├── index
└── itemsExample.
architecture
│
├── index.mdx
├── astro-content.mdx
└── static-site-structure.mdxBecomes
VaultContext
│
├── index
└── items[]Nested folders become nested vault nodes.
Vault
│
├── Entry
└── Subfolder
│
├── index
└── itemsThe hierarchy is built here.
function buildNestedStructure(entries, currentPath);It groups entries by subfolder and recursively builds the tree.
Sorting is handled by
function sortByOrderThenTitle(a, b);Entries use the order field first. Title acts as a fallback.
rawEntries Copied!
This is the direct output of Astro.
JournalEntry[]Nothing is modified.
The project keeps this list because sometimes the raw content is useful.
Example use cases.
- search indexing
- feeds
- analytics
- building lists without hierarchy
Step 6. Linking Articles Copied!
Articles inside a vault get automatic navigation.
The function responsible is
linkVaultEntries(vault);It walks through the vault tree and connects entries.
Entry A <-> Entry B <-> Entry CEach entry receives
previous
nextThis powers the article footer navigation.
Step 7. Path Resolution Copied!
Pages often know only a URL path.
The project needs to resolve that path into context data.
That is the purpose of
getContextFromPath();Definition.
export function getContextFromPath(path: string);Return value.
[EntryContext | null, VaultContext | null];The function does three things.
- Normalize the URL path
- Locate the entry in the manifest
- Resolve its vault
Example flow.
URL
│
▼
/thejournal/architecture/astro-content
│
▼
Normalize path
│
▼
Find entry in entryManifest
│
▼
Find vault in vaultsManifestThe page now has everything it needs.
- entry metadata
- vault structure
- sibling entries
- navigation links
Step 8. How the UI Uses the Manifest Copied!
The journal page loads the catalog like this.
const catalog = Object.values(entryManifest);This creates a list of all entries.
The UI components then consume the structured data.
Typical flow.
entryManifest
│
▼
JournalGrid
│
▼
JournalCardVault navigation uses vaultsManifest.
vaultsManifest
│
▼
VaultTreeNode componentEach component receives preprocessed data. No expensive computation happens inside the UI.
Full Lifecycle Summary Copied!
The entire system works like this.
1. Author writes MDX files
2. Astro collection loads the files
3. getCollection returns typed entries
4. loadManifest builds structured data
5. entryManifest enables fast lookup
6. vaultsManifest builds hierarchy
7. linkVaultEntries connects navigation
8. UI components consume the manifestOr visually.
MDX Files
│
▼
Astro Collection
│
▼
getCollection()
│
▼
Manifest Builder
│
├── entryManifest
├── vaultsManifest
└── rawEntries
│
▼
UI ComponentsThe result feels like a small content database built entirely at build time.
Astro handles file discovery and typing.
The manifest layer turns files into a navigable knowledge structure.
The UI simply reads the final map.
