A type-safe, stream-based static CMS powered by Effect and SQLite. Build blazingly fast content workflows with full type safety, efficient querying, and seamless asset management.
- 🎯 Full Type Safety - End-to-end type safety powered by Effect Schema
- ⚡ Stream-Based Architecture - Efficient data loading and processing with Effect Streams
- 🗄️ SQLite Under the Hood - Fast, efficient querying with automatic indexing
- 🔗 Type-Safe Relations - Define and query relationships between collections with complete type inference
- 📦 Built-in Loaders - JSON, YAML, MDX, and JSON Lines loaders included
- ☁️ Asset Sync Utilities - Sync static assets to S3-compatible storage (R2, S3, etc.)
- ⚙️ Effect-Native - Built on Effect for composable, testable, and maintainable code
bun add @foldcms/core effect @effect/platform @effect/sql-sqlite-bun# For MDX support
bun add mdx-bundler esbuild react react-dom
# For YAML support
bun add yaml
# For S3/R2 asset sync
bun add @aws-sdk/client-s3import { Schema, Effect } from "effect";
import { defineCollection, makeCms, build, SqlContentStore } from "@foldcms/core";
import { jsonFilesLoader } from "@foldcms/core/loaders";
import { SqliteClient } from "@effect/sql-sqlite-bun";
// 1. Define your schemas
const PostSchema = Schema.Struct({
id: Schema.String,
title: Schema.String,
content: Schema.String,
authorId: Schema.String,
publishedAt: Schema.Date,
});
const AuthorSchema = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.String,
});
// 2. Create collections with relations
const posts = defineCollection({
loadingSchema: PostSchema,
loader: jsonFilesLoader(PostSchema, {
folder: "posts",
}),
relations: {
authorId: {
type: "single",
field: "authorId",
target: "authors",
},
},
});
const authors = defineCollection({
loadingSchema: AuthorSchema,
loader: jsonFilesLoader(AuthorSchema, {
folder: "authors",
}),
});
// 3. Create CMS instance
const { CmsTag, CmsLayer } = makeCms({
collections: { posts, authors },
});
// 4. Set up dependencies
const SqlLive = SqliteClient.layer({
filename: "cms.db",
});
const AppLayer = CmsLayer.pipe(
Layer.provideMerge(SqlContentStore),
Layer.provide(SqlLive),
);
// 5. Build and query
const program = Effect.gen(function* () {
// Build the database
yield* build({ collections: { posts, authors } });
// Get CMS instance
const cms = yield* CmsTag;
// Query posts
const allPosts = yield* cms.getAll("posts");
// Get specific post
const post = yield* cms.getById("posts", "post-1");
// Load relations
if (Option.isSome(post)) {
const author = yield* cms.loadRelation("posts", post.value, "authorId");
console.log(author); // Fully typed!
}
return allPosts;
});
await Effect.runPromise(program.pipe(Effect.provide(AppLayer)));Collections are type-safe data sources with optional transformation and validation:
const posts = defineCollection({
// Schema for loaded data
loadingSchema: PostLoadSchema,
// Schema after transformation (optional)
transformedSchema: PostTransformSchema,
// Stream-based loader
loader: jsonFilesLoader(PostLoadSchema, {
folder: "posts",
}),
// Optional transformer
transformer: (post) => Effect.gen(function* () {
return {
...post,
excerpt: post.content.slice(0, 200),
};
}),
// Optional validator
validator: (post) => Effect.gen(function* () {
if (post.title.length < 3) {
return yield* Effect.fail(
new ValidationError({
message: "Title too short",
issues: ["Title must be at least 3 characters"],
})
);
}
}),
// Relations to other collections
relations: {
authorId: {
type: "single",
field: "authorId",
target: "authors",
},
tagIds: {
type: "array",
field: "tagIds",
target: "tags",
},
},
});FoldCMS supports three types of relations with full type safety:
Single Relations - One-to-one relationships
relations: {
authorId: {
type: "single",
field: "authorId",
target: "authors",
},
}
// Returns: Option<Author>
const author = yield* cms.loadRelation("posts", post, "authorId");Array Relations - One-to-many relationships
relations: {
tagIds: {
type: "array",
field: "tagIds",
target: "tags",
},
}
// Returns: readonly Tag[]
const tags = yield* cms.loadRelation("posts", post, "tagIds");Map Relations - Key-value relationships
relations: {
translations: {
type: "map",
field: "translationMap", // { "en": "id1", "fr": "id2" }
target: "translations",
},
}
// Returns: ReadonlyMap<string, Translation>
const translations = yield* cms.loadRelation("posts", post, "translations");import { jsonFilesLoader } from "@foldcms/core/loaders";
const loader = jsonFilesLoader(MySchema, {
folder: "posts", // Loads all .json files
});import { jsonLinesLoader } from "@foldcms/core/loaders";
const loader = jsonLinesLoader(MySchema, {
folder: "data", // Loads .jsonl files
});import { yamlFilesLoader } from "@foldcms/core/loaders";
const loader = yamlFilesLoader(MySchema, {
folder: "config", // Loads .yaml/.yml files
});import { yamlStreamLoader } from "@foldcms/core/loaders";
// For YAML files with multiple documents (---)
const loader = yamlStreamLoader(MySchema, {
folder: "data",
});import { mdxLoader } from "@foldcms/core/loaders";
const PostSchema = Schema.Struct({
title: Schema.String,
slug: Schema.String,
tags: Schema.Array(Schema.String),
meta: Schema.Struct({
mdx: Schema.String, // Compiled MDX
raw: Schema.String, // Original content
exports: Schema.Record({ // Exported values
key: Schema.String,
value: Schema.Any,
}),
}),
});
const loader = mdxLoader(PostSchema, {
folder: "posts",
bundlerOptions: {
cwd: process.cwd(),
// Any mdx-bundler options
},
exports: ["metadata", "toc"], // Export names to capture
});Sync static assets to S3-compatible storage with automatic change detection:
import { syncFolderToStorage, S3StorageServiceLive } from "@foldcms/core/utils";
import { ConfigProvider, Effect } from "effect";
const program = syncFolderToStorage({
folderPath: "/path/to/assets",
// Determine bucket based on filename
getBucket: (fileName) => {
if (fileName.endsWith(".pdf")) {
return Effect.succeed("private-bucket");
}
return Effect.succeed("public-bucket");
},
// Clean up orphaned files
bucketsToClean: ["public-bucket", "private-bucket"],
deleteOrphaned: true,
concurrency: 10,
}).pipe(
Effect.provide(S3StorageServiceLive),
Effect.withConfigProvider(
ConfigProvider.fromJson({
S3_ACCOUNT_ID: "your-account-id",
S3_ACCESS_KEY_ID: "your-key",
S3_SECRET_ACCESS_KEY: "your-secret",
})
)
);
await Effect.runPromise(program);After syncing assets, create a collection to reference them:
const MediaSchema = Schema.Struct({
id: Schema.String,
filename: Schema.String,
url: Schema.String,
size: Schema.Number,
mimeType: Schema.String,
});
const media = defineCollection({
loadingSchema: MediaSchema,
loader: jsonFilesLoader(MediaSchema, {
folder: "media",
}),
});
// Reference media in other collections
const posts = defineCollection({
loadingSchema: PostSchema,
loader: jsonFilesLoader(PostSchema, {
folder: "posts",
}),
relations: {
featuredImageId: {
type: "single",
field: "featuredImageId",
target: "media",
},
},
});Create custom loaders using Effect Streams:
import { Stream, Effect } from "effect";
import { LoadingError } from "@foldcms/core";
const customLoader = <T extends Schema.Struct<any>>(
schema: T,
config: { source: string }
) => {
return Stream.fromIterable([/* your data */])
.pipe(
Stream.mapEffect((raw) => Schema.decodeUnknown(schema)(raw)),
Stream.mapError((e) => new LoadingError({
message: e.message,
cause: e
}))
);
};Transform data during loading:
const posts = defineCollection({
loadingSchema: PostLoadSchema,
transformedSchema: PostTransformSchema,
loader: jsonFilesLoader(PostLoadSchema, {
folder: "posts",
}),
transformer: (post) => Effect.gen(function* () {
// Add computed fields
const wordCount = post.content.split(/\s+/).length;
const readingTime = Math.ceil(wordCount / 200);
// Fetch related data
const author = yield* fetchAuthor(post.authorId);
return {
...post,
wordCount,
readingTime,
authorName: author.name,
};
}),
});Add validation logic to ensure data quality:
const posts = defineCollection({
loadingSchema: PostSchema,
loader: jsonFilesLoader(PostSchema, {
folder: "posts",
}),
validator: (post) => Effect.gen(function* () {
const issues: string[] = [];
if (post.title.length < 10) {
issues.push("Title too short");
}
if (post.content.length < 100) {
issues.push("Content too short");
}
if (issues.length > 0) {
return yield* Effect.fail(
new ValidationError({
message: `Validation failed for post ${post.id}`,
issues,
})
);
}
}),
});FoldCMS is built with Effect, making it highly testable:
import { test, expect } from "bun:test";
import { Effect, Layer, ManagedRuntime } from "effect";
import { SqliteClient } from "@effect/sql-sqlite-bun";
const SqlLive = SqliteClient.layer({ filename: ":memory:" });
const CmsLive = CmsLayer.pipe(
Layer.provideMerge(SqlContentStore),
Layer.provide(SqlLive)
);
const TestRuntime = ManagedRuntime.make(CmsLive);
test("loads and queries posts", async () => {
await TestRuntime.runPromise(build({ collections: { posts } }));
const program = Effect.gen(function* () {
const cms = yield* CmsTag;
const allPosts = yield* cms.getAll("posts");
return allPosts;
});
const posts = await TestRuntime.runPromise(program);
expect(posts).toHaveLength(2);
});- Efficient Querying: SQLite with automatic indexes
- Streaming: Process large datasets without loading everything into memory
- Concurrent Loading: Multiple collections load in parallel
- Smart Caching: Asset sync only uploads changed files (hash-based)
FoldCMS is built on Effect, providing:
- Composability: Build complex workflows from simple pieces
- Type Safety: Catch errors at compile time
- Testability: Pure functions make testing easy
- Resource Management: Automatic cleanup of database connections
- Error Handling: Structured error types instead of throwing
- Observability: Built-in logging and tracing
Check out the /tests directory for complete examples:
- Basic CMS setup
- Relations between collections
- Custom loaders
- Asset sync
MIT
Contributions welcome! Please open an issue or PR.