Getting Started
Welcome to FoldCMS! This guide will walk you through building a complete e-commerce content system with products, blog posts, categories, and tags. By the end, you’ll understand how to load multiple content formats, define type-safe relationships, and query your content with full TypeScript safety.
What We’re Building
Section titled “What We’re Building”We’ll create a tech e-commerce site with:
- Products loaded from JSON files
- Blog posts written in MDX
- Categories stored in YAML files
- Tags in a YAML stream format
- Type-safe relationships between all content types
Prerequisites
Section titled “Prerequisites”- Bun installed (v1.0+)
- Basic TypeScript knowledge
- Familiarity with Effect (helpful but not required)
Installation
Section titled “Installation”First, create a new directory and install FoldCMS:
mkdir my-cms-projectcd my-cms-projectbun init -y
Install the required dependencies:
bun add @foldcms/core effect @effect/platform @effect/sql-sqlite-bun
Peer Dependencies
Section titled “Peer Dependencies”Depending on which loaders you’ll use, install the appropriate peer dependencies:
# For MDX supportbun add mdx-bundler esbuild react react-dom
# For YAML supportbun add yaml
# TypeScriptbun add -D typescript @types/bun
Project Structure
Section titled “Project Structure”Let’s set up a well-organized content structure. Create the following directory layout:
.├── cms-data/ # All your content lives here│ ├── blog/ # MDX blog posts│ ├── categories/ # YAML category files│ ├── products/ # JSON product files│ └── tags/ # YAML stream for tags├── main.ts # Your CMS program└── package.json
Create the directories:
mkdir -p cms-data/{blog,categories,products,tags}
Understanding the Structure
Section titled “Understanding the Structure”Why this structure?
- Separation by content type: Each content type gets its own folder
- Format flexibility: Use JSON for data-heavy content, YAML for human-friendly config, MDX for rich content
- Scalability: Easy to add new content types as folders
- Version control friendly: Text-based formats work great with Git
The build process will create SQLite database files (cms-example.db
, .db-shm
, .db-wal
) for efficient querying.
Step 1: Create Your Content
Section titled “Step 1: Create Your Content”Categories (YAML)
Section titled “Categories (YAML)”Categories use YAML because they’re configuration-like data that humans need to edit frequently. Create individual YAML files for better organization.
cms-data/categories/electronics.yaml
id: cat-electronicsname: Electronicsslug: electronicsdescription: Latest in consumer electronics and gadgetsicon: 💻parentId: null
cms-data/categories/smartphones.yaml
id: cat-smartphonesname: Smartphonesslug: smartphonesdescription: Modern smartphones and mobile devicesicon: 📱parentId: cat-electronics # This creates a parent-child relationship
Create two more: laptops.yaml
(parent: electronics) and accessories.yaml
(parent: null).
Why YAML for categories?
- Human-readable and easy to edit
- Great for hierarchical data
- No quotes needed for most values
- Comments supported
Tags (YAML Stream)
Section titled “Tags (YAML Stream)”Tags use YAML stream format (multiple documents in one file separated by ---
) because they’re simple and often edited together.
cms-data/tags/content.yaml
---id: tag-5gname: 5Gcolor: "#3B82F6"---id: tag-wirelessname: Wirelesscolor: "#8B5CF6"---id: tag-gamingname: Gamingcolor: "#EF4444"---id: tag-premiumname: Premiumcolor: "#F59E0B"
Add more tags as needed (productivity, budget, sustainable, innovation).
Why YAML stream for tags?
- All tags in one file for easy overview
- Quick to add new tags
- Separated by
---
markers - Still human-readable
Products (JSON)
Section titled “Products (JSON)”Products use JSON because they have complex nested data structures and many fields.
cms-data/products/pixel-9-pro.json
{ "id": "prod-pixel-9-pro", "sku": "GOOG-PIX9P-128", "name": "Google Pixel 9 Pro", "slug": "google-pixel-9-pro", "description": "Experience the power of Google AI in a premium smartphone.", "price": 999, "currency": "USD", "stock": 45, "categoryId": "cat-smartphones", "tagIds": ["tag-5g", "tag-premium", "tag-innovation"], "images": [ "/products/pixel-9-pro-front.jpg", "/products/pixel-9-pro-back.jpg" ], "specifications": { "display": "6.7\" LTPO OLED", "processor": "Google Tensor G4", "ram": "12GB", "storage": "128GB" }, "featured": true, "available": true, "publishedAt": "2024-10-15T10:00:00Z"}
Create 5-6 more product files with different categories and tags.
Why JSON for products?
- Complex nested structures (specifications)
- Arrays of images
- Precise data types (numbers for prices)
- Easy to generate programmatically
Blog Posts (MDX)
Section titled “Blog Posts (MDX)”Blog posts use MDX for rich content with frontmatter metadata.
cms-data/blog/ai-smartphones-2025.mdx
---id: blog-ai-smartphones-2025title: "The AI Revolution in Smartphones: What to Expect in 2025"slug: ai-smartphones-2025excerpt: "From real-time translation to advanced photography, AI is transforming how we use our smartphones."author: Sarah ChenpublishedAt: 2024-12-01T09:00:00ZupdatedAt: 2024-12-01T09:00:00ZcategoryId: cat-smartphonestagIds: - tag-5g - tag-innovationcoverImage: /blog/ai-smartphones-cover.jpgfeatured: true---
# The AI Revolution in Smartphones
The smartphone industry is experiencing its biggest transformation since touchscreens. **Artificial Intelligence** is fundamentally changing how we interact with devices.
## On-Device AI Processing
Modern processors feature dedicated **Neural Processing Units (NPUs)** that handle complex AI tasks locally:
- Faster response times- Better privacy- Works offline- Lower battery consumption
## Code Example
```typescriptconst translate = async (text: string, targetLang: string) => { return await device.neuralEngine.translate(text, targetLang);};
Real-time translation without cloud APIs!
Create 3-4 more blog posts covering different topics and categories.
**Why MDX for blog posts?**- Frontmatter for metadata- Rich markdown content- Code blocks with syntax highlighting- Can embed React components- Compiled and bundled automatically
## Step 2: Define Your Schemas
Now let's write the TypeScript code. Create `main.ts`:
```typescriptimport { Schema } from "effect";
// Define the shape of your Categoryconst CategorySchema = Schema.Struct({ id: Schema.String, name: Schema.String, slug: Schema.String, description: Schema.String, icon: Schema.String, parentId: Schema.NullOr(Schema.String), // Can be null for root categories});
What’s happening here?
Schema.Struct
defines the structure of your data- Each field has a type (
Schema.String
,Schema.Number
, etc.) Schema.NullOr
allows a field to be null- This gives you compile-time and runtime type safety
Continue with Tag schema:
const TagSchema = Schema.Struct({ id: Schema.String, name: Schema.String, color: Schema.String, // Hex color code});
Product schema with more complex types:
const ProductSchema = Schema.Struct({ id: Schema.String, sku: Schema.String, name: Schema.String, slug: Schema.String, description: Schema.String, price: Schema.Number, currency: Schema.String, stock: Schema.Number, categoryId: Schema.String, tagIds: Schema.Array(Schema.String), // Array of tag IDs images: Schema.Array(Schema.String), // Array of image URLs specifications: Schema.Record({ // Key-value pairs key: Schema.String, value: Schema.String, }), featured: Schema.Boolean, available: Schema.Boolean, publishedAt: Schema.DateFromString, // Parses ISO string to Date});
Key concepts:
Schema.Array
for arraysSchema.Record
for key-value objectsSchema.DateFromString
automatically parses ISO date strings- Field names must match your JSON exactly
Blog post schema with nested MDX metadata:
const BlogPostSchema = Schema.Struct({ id: Schema.String, title: Schema.String, slug: Schema.String, excerpt: Schema.String, author: Schema.String, publishedAt: Schema.Date, // YAML parses dates automatically updatedAt: Schema.Date, categoryId: Schema.String, tagIds: Schema.Array(Schema.String), coverImage: Schema.String, featured: Schema.Boolean, meta: Schema.Struct({ // Nested structure for MDX data mdx: Schema.String, // Compiled MDX code raw: Schema.String, // Original markdown exports: Schema.Record({ // Any exported values from MDX key: Schema.String, value: Schema.Unknown, }), }),});
Date handling tip:
- Use
Schema.DateFromString
for JSON (dates are strings) - Use
Schema.Date
for YAML/MDX frontmatter (dates are parsed)
Step 3: Define Collections with Loaders
Section titled “Step 3: Define Collections with Loaders”Collections connect your schemas to your content files. Import the loaders:
import { jsonFilesLoader, mdxLoader, yamlFilesLoader, yamlStreamLoader,} from "@foldcms/core/loaders";import { defineCollection } from "@foldcms/core";
Categories Collection
Section titled “Categories Collection”const categories = defineCollection({ loadingSchema: CategorySchema, loader: yamlFilesLoader(CategorySchema, { folder: "cms-data/categories", }), relations: { parentId: { type: "single", // One parent per category field: "parentId", // Field that stores the parent ID target: "categories", // Points to another category }, },});
What’s happening?
loadingSchema
: Validates data as it loadsloader
: UsesyamlFilesLoader
to read all.yaml
files in the folderrelations
: Defines the parent-child relationship (self-referential)
Tags Collection
Section titled “Tags Collection”const tags = defineCollection({ loadingSchema: TagSchema, loader: yamlStreamLoader(TagSchema, { folder: "cms-data/tags", }),});
Simpler! No relations needed for tags. The yamlStreamLoader
reads YAML documents separated by ---
.
Products Collection
Section titled “Products Collection”const products = defineCollection({ loadingSchema: ProductSchema, loader: jsonFilesLoader(ProductSchema, { folder: "cms-data/products", }), relations: { categoryId: { type: "single", // One category per product field: "categoryId", target: "categories", }, tagIds: { type: "array", // Multiple tags per product field: "tagIds", target: "tags", }, },});
Understanding relations:
type: "single"
returnsOption<Category>
when loadedtype: "array"
returnsreadonly Tag[]
when loaded- Relations are type-safe: TypeScript knows the exact return type
Blog Collection
Section titled “Blog Collection”const blog = defineCollection({ loadingSchema: BlogPostSchema, loader: mdxLoader(BlogPostSchema, { folder: "cms-data/blog", bundlerOptions: { cwd: process.cwd(), }, }), relations: { categoryId: { type: "single", field: "categoryId", target: "categories", }, tagIds: { type: "array", field: "tagIds", target: "tags", }, },});
MDX loader features:
- Parses frontmatter automatically
- Bundles MDX into executable code
- Captures exported values
- Handles imports and components
Step 4: Create the CMS Instance
Section titled “Step 4: Create the CMS Instance”import { makeCms } from "@foldcms/core";
const { CmsTag, CmsLayer } = makeCms({ collections: { categories, tags, products, blog, },});
What you get:
CmsTag
: For dependency injection in Effect codeCmsLayer
: Provides the CMS service to your program
This creates a fully type-safe CMS. TypeScript knows:
- All collection names (
"categories"
,"tags"
, etc.) - All field types
- All relation types
- Return types for every query
Step 5: Set Up the Runtime
Section titled “Step 5: Set Up the Runtime”import { BunContext } from "@effect/platform-bun";import { SqliteClient } from "@effect/sql-sqlite-bun";import { Layer } from "effect";import { SqlContentStore } from "@foldcms/core";
// SQLite database layerconst SqlLive = SqliteClient.layer({ filename: "cms-example.db",});
// Combine all layersconst AppLayer = CmsLayer.pipe( Layer.provideMerge(SqlContentStore), Layer.provideMerge(SqlLive), Layer.provideMerge(BunContext.layer),);
Layer composition:
SqlLive
: SQLite database connectionSqlContentStore
: Content storage implementationCmsLayer
: Your CMS instanceBunContext.layer
: Bun runtime services (file system, etc.)
Layers provide dependencies to your Effect programs.
Step 6: Build the CMS
Section titled “Step 6: Build the CMS”import { build } from "@foldcms/core";import { Effect, Console } from "effect";
const program = Effect.gen(function* () { // Build phase: Load, transform, validate, and store all content yield* Console.log("🔨 Building CMS...");
yield* build({ collections: { categories, tags, products, blog, }, });
yield* Console.log("✅ Build complete!");});
What build does:
- Loads all files from each collection
- Validates against schemas
- Transforms if transformers are defined
- Stores in SQLite database
- Creates indexes for efficient queries
This happens once at build time, not on every request.
Step 7: Query Your Content
Section titled “Step 7: Query Your Content”Get All Items
Section titled “Get All Items”const cms = yield* CmsTag;
// Get all productsconst allProducts = yield* cms.getAll("products");// ^? readonly Product[]
console.log(`Found ${allProducts.length} products`);
Type safety: TypeScript knows allProducts
is readonly Product[]
with all the fields from your schema.
Get Item by ID
Section titled “Get Item by ID”const pixelOption = yield* cms.getById("products", "prod-pixel-9-pro");// ^? Option<Product>
if (Option.isSome(pixelOption)) { const pixel = pixelOption.value; console.log(`${pixel.name}: $${pixel.price}`);}
Option type: Returns Option<Product>
because the item might not exist. Pattern match with Option.isSome()
to safely access the value.
Load Single Relations
Section titled “Load Single Relations”if (Option.isSome(pixelOption)) { const pixel = pixelOption.value;
const categoryOption = yield* cms.loadRelation( "products", // Source collection pixel, // Source item "categoryId" // Relation field ); // ^? Option<Category>
if (Option.isSome(categoryOption)) { console.log(`Category: ${categoryOption.value.name}`); }}
Single relation returns Option<Category>
- one item or none.
Load Array Relations
Section titled “Load Array Relations”const productTags = yield* cms.loadRelation( "products", pixel, "tagIds");// ^? readonly Tag[]
console.log("Tags:");for (const tag of productTags) { console.log(` - ${tag.name} (${tag.color})`);}
Array relation returns readonly Tag[]
- always an array, never null.
Filter and Transform
Section titled “Filter and Transform”// Get all productsconst allProducts = yield* cms.getAll("products");
// Filter in JavaScriptconst premiumProducts = allProducts.filter(p => p.price > 1000);const inStock = allProducts.filter(p => p.available && p.stock > 0);const featured = allProducts.filter(p => p.featured);
// Transformconst productNames = allProducts.map(p => p.name);const totalValue = allProducts.reduce((sum, p) => sum + p.price, 0);
In-memory queries: Since everything is in SQLite and loaded into memory for queries, you can use normal JavaScript array methods.
Load Self-Referential Relations
Section titled “Load Self-Referential Relations”const allCategories = yield* cms.getAll("categories");
for (const category of allCategories) { if (category.parentId) { const parentOption = yield* cms.loadRelation( "categories", category, "parentId" );
if (Option.isSome(parentOption)) { console.log(`${category.name} → child of ${parentOption.value.name}`); } }}
Self-referential: Categories can point to other categories as parents.
Step 8: Run Your Program
Section titled “Step 8: Run Your Program”// Execute the programprogram .pipe(Effect.provide(AppLayer)) .pipe(Effect.runPromise) .then( () => { console.log("✨ Success!"); process.exit(0); }, (error) => { console.error("❌ Error:", error); process.exit(1); } );
Run it:
bun main.ts
You should see:
🔨 Building CMS...[foldcms-build] Build completed in 1234ms✅ Build complete!Found 6 products✨ Success!
Understanding the Database
Section titled “Understanding the Database”After running, you’ll see these files:
cms-example.db # Main SQLite databasecms-example.db-shm # Shared memory filecms-example.db-wal # Write-ahead log
What’s stored:
- All content as JSON blobs
- Content hashes for change detection
- Indexes on collection and ID for fast queries
Why SQLite?
- Fast queries: Orders of magnitude faster than reading files
- Portable: Single file database
- Reliable: ACID compliant
- No server: Embedded database
You can inspect it with:
bun install -g sqlite3sqlite3 cms-example.db "SELECT collection, count(*) FROM entities GROUP BY collection"
Advanced: Transformations
Section titled “Advanced: Transformations”Add custom transformations during loading:
const products = defineCollection({ loadingSchema: ProductSchema, transformedSchema: EnrichedProductSchema, loader: jsonFilesLoader(ProductSchema, { folder: "cms-data/products", }), transformer: (product) => Effect.gen(function* () { // Add computed fields const discount = product.featured ? 0.1 : 0; const discountedPrice = product.price * (1 - discount);
return { ...product, discount, discountedPrice, }; }),});
Use cases:
- Add computed fields
- Enrich with external data
- Generate slugs
- Optimize images
Advanced: Validation
Section titled “Advanced: Validation”Add custom validation rules:
import { ValidationError } from "@foldcms/core";
const products = defineCollection({ loadingSchema: ProductSchema, loader: jsonFilesLoader(ProductSchema, { folder: "cms-data/products", }), validator: (product) => Effect.gen(function* () { const issues: string[] = [];
if (product.price <= 0) { issues.push("Price must be positive"); }
if (product.stock < 0) { issues.push("Stock cannot be negative"); }
if (product.name.length < 3) { issues.push("Name too short"); }
if (issues.length > 0) { return yield* Effect.fail( new ValidationError({ message: `Product ${product.id} validation failed`, issues, }) ); } }),});
Validation runs during the build phase and will fail the build if any item is invalid.
Advanced: Custom Loaders
Section titled “Advanced: Custom Loaders”Create a custom loader for remote data:
import { Stream, Effect } from "effect";import { LoadingError } from "@foldcms/core";
const customApiLoader = <T extends Schema.Struct<any>>( schema: T, config: { apiUrl: string }) => { return Stream.fromIterableEffect( Effect.gen(function* () { // Fetch from API const response = yield* Effect.tryPromise({ try: () => fetch(config.apiUrl), catch: (e) => new LoadingError({ message: "API fetch failed", cause: e }) });
const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (e) => new LoadingError({ message: "JSON parse failed", cause: e }) });
return data; }) ).pipe( Stream.mapEffect((raw) => Schema.decodeUnknown(schema)(raw)), Stream.mapError((e) => new LoadingError({ message: e.message, cause: e })) );};
Use it:
const remoteProducts = defineCollection({ loadingSchema: ProductSchema, loader: customApiLoader(ProductSchema, { apiUrl: "https://api.example.com/products", }),});
Next Steps
Section titled “Next Steps”Now that you have a working CMS, you can:
-
Integrate with your framework
- Use with Astro, Next.js, Remix, etc.
- Access CMS in route loaders or server components
-
Add more content types
- Authors, Reviews, FAQs
- Any structured content
-
Customize loaders
- Load from databases
- Fetch from APIs
- Parse custom formats
-
Deploy
- Build phase runs once during deployment
- Ship the SQLite database with your app
- Ultra-fast queries at runtime
Common Patterns
Section titled “Common Patterns”Framework Integration Example (Astro)
Section titled “Framework Integration Example (Astro)”import { Effect } from "effect";import { CmsTag, AppLayer } from "./cms-setup";
export async function getCms() { return Effect.runPromise( Effect.gen(function* () { return yield* CmsTag; }).pipe(Effect.provide(AppLayer)) );}
// src/pages/products/[slug].astro---import { getCms } from "../../cms";
const cms = await getCms();const products = await Effect.runPromise(cms.getAll("products"));const product = products.find(p => p.slug === Astro.params.slug);---
<h1>{product.name}</h1><p>${product.price}</p>
Rebuild on Content Changes
Section titled “Rebuild on Content Changes”Watch for content changes and rebuild:
import { watch } from "fs";
watch("cms-data", { recursive: true }, async () => { console.log("Content changed, rebuilding..."); await Effect.runPromise( build({ collections }).pipe(Effect.provide(AppLayer)) );});
Troubleshooting
Section titled “Troubleshooting”Schema Validation Errors
Section titled “Schema Validation Errors”Error: Expected string, actual undefined
Fix: Make sure your content files have all required fields, or make fields optional:
Schema.Struct({ requiredField: Schema.String, optionalField: Schema.optional(Schema.String),})
Date Parsing Issues
Section titled “Date Parsing Issues”Error: Expected string, actual Date
Fix: Use correct date schema for your format:
Schema.DateFromString
for JSONSchema.Date
for YAML/MDX
Loader Not Finding Files
Section titled “Loader Not Finding Files”Error: No files found in directory
Fix: Check:
- Folder path is correct
- Files have correct extensions (
.json
,.yaml
,.mdx
) - Files are not empty
Relation Errors
Section titled “Relation Errors”Error: Related entity not found
Fix: Ensure:
- Target collection is defined
- Referenced IDs exist in target collection
- IDs are spelled correctly
Performance Tips
Section titled “Performance Tips”- Use SQLite for large datasets: Queries are O(log n) instead of O(n)
- Index frequently queried fields: SQLite auto-indexes collection and ID
- Filter after loading: In-memory filtering is fast for reasonable dataset sizes
- Build once, query many: Build phase is one-time, queries are instant
- Use readonly types: Prevents accidental mutations
Summary
Section titled “Summary”You’ve learned:
- ✅ How to organize content in different formats
- ✅ Defining type-safe schemas with Effect Schema
- ✅ Creating collections with loaders
- ✅ Setting up relationships between content
- ✅ Building and querying the CMS
- ✅ Loading relations with full type safety
- ✅ Advanced features like transformations and custom loaders
Key concepts:
- Collections: Type-safe content containers
- Schemas: Runtime validation + TypeScript types
- Loaders: Stream-based content loading
- Relations: Type-safe references between content
- Build phase: One-time setup, stores in SQLite
- Query phase: Instant lookups with full type safety
FoldCMS gives you the power of a traditional CMS with the developer experience of TypeScript and the reliability of Effect. Your content is version-controlled, your queries are instant, and your types are guaranteed.
Happy building! 🚀