RustyCMS
A file-based headless CMS written in Rust. Content types are defined as JSON5 schemas, content is stored as JSON5 files, and served via a REST API. Under development.
Features
- Schema-first: Define content types in
types/*.json5– fields, types, constraints, defaults - TypeScript-like type system:
extends(including multiple),pick,omit,partial - Rich validation:
minLength,maxLength,min,max,pattern,enum,unique,nullable,readonly,strict - File-based (default): No database setup – everything is files, versionable with Git
- Optional SQLite: Switch store via env – same API, different storage layer
- REST API: Full CRUD endpoints, auto-generated per content type
- References & _resolve: Reference fields as
{ _type, _slug }; embed with?_resolve=all - Referrer index: Reverse index of who references which entry; file-based in
content/_referrers.json; full reindex when file is missing;GET /api/content/:type/:slug/referrers - Reusable partials:
reusableschemas anduseFieldsfor shared field groups (e.g. layout) - Optional API auth: Protect write access (POST/PUT/DELETE) with an API key via env
- Admin UI: Next.js web interface for browsing collections, creating and editing content; manage types (list, create, edit, delete) with schema files and JSON Schema export updated on save
- Swagger UI: Interactive API docs at
/swagger-ui - Asset management: Upload, serve, and delete image files (jpg, png, webp, avif, gif, svg) stored in
content/assets/viaGET/POST/DELETE /api/assets; served with immutable cache headers; combinable with/api/transformfor on-the-fly resizing - Image transformation:
GET /api/transform– load image from external URL or local asset, optional resize (w, h), aspect-ratio crop (e.g. 1:1), fit (fill/contain/cover), output formats jpeg, png, webp, avif; with response cache - JSON5: Human-friendly format with comments, trailing commas, unquoted keys
- Editor validation: Export JSON Schema from your types so VS Code/Cursor validate
content/*.json5while you edit - CORS: Open by default for frontend development
Requirements
Quick start
cd rustycms
cargo run
The server starts at http://127.0.0.1:3000.
Start API + Admin UI and open browser:
./dev.sh
Runs both the API and Admin UI, waits until ready, and opens http://localhost:2001 in the default browser. Press Ctrl+C to stop both.
Admin UI (optional)
A Next.js web interface for managing content. Start the API server first, then:
cd admin-ui
npm install
npm run dev
The Admin UI runs at http://localhost:2001 (different port to avoid conflict with the API).
Version control: Only a single demo type (types/demo.json5) and one demo content entry (content/de/demo/demo-welcome.json5) are tracked. All other files under types/ and content/ are ignored via .gitignore, so you can add your own types and content without committing them.
Admin UI: Dashboard → Types lists all content types; you can create a new type, edit (fields, description, category, strict, …) or delete a type. Edits are written to types/*.json5 and the corresponding schemas/*.schema.json is updated. Content is managed under Collections (list, new entry, edit, with reference fields and Markdown editor). Schema and data preview are available on the relevant pages.
Environment variables (in admin-ui/.env.local or as env vars):
| Variable | Default | Description |
|---|---|---|
NEXT_PUBLIC_RUSTYCMS_API_URL |
http://127.0.0.1:3000 |
RustyCMS API base URL |
NEXT_PUBLIC_RUSTYCMS_API_KEY |
– | API key for write operations (same as RUSTYCMS_API_KEY). Optional if you use the Admin UI login. |
API key setup (backend + Admin UI):
-
Backend – In the project root (next to
Cargo.toml), create or edit.env:RUSTYCMS_API_KEY=dein-geheimer-schluesselUse any secret string (e.g.
openssl rand -hex 32). Start the API withcargo run; the log should show that API key auth is enabled. -
Admin UI – Either:
- Option A: In
admin-ui/.env.localset the same key so the UI can write without logging in:NEXT_PUBLIC_RUSTYCMS_API_URL=http://127.0.0.1:3000 NEXT_PUBLIC_RUSTYCMS_API_KEY=dein-geheimer-schluessel - Option B: Leave
NEXT_PUBLIC_RUSTYCMS_API_KEYunset. Open the Admin UI, click Login in the sidebar, enter the same API key, and submit. The key is stored in the browser (sessionStorage) until you log out or close the tab.
- Option A: In
-
Check: Without a key,
GET /api/collectionsreturns 200;POST/PUT/DELETEreturn 401. With headerX-API-Key: dein-geheimer-schluessel(orAuthorization: Bearer …), write requests succeed.
CLI options
| Option | Default | Description |
|---|---|---|
--types-dir |
./types |
Directory with type definitions |
--content-dir |
./content |
Directory with content files |
-p, --port |
3000 |
Port |
--host |
127.0.0.1 |
Host address |
cargo run -- --types-dir ./my-schemas --content-dir ./my-data -p 8080
Configuration (environment variables)
A .env in the project directory is loaded at startup. See .env.example.
| Variable | Default | Description |
|---|---|---|
RUSTYCMS_STORE |
file |
Store backend: file or sqlite |
RUSTYCMS_DATABASE_URL |
sqlite:content.db |
When using sqlite: SQLite URL (fallback: DATABASE_URL) |
RUSTYCMS_API_KEY |
– | Optional. Single key = full write access (Bearer or X-API-Key). GET stays public. |
RUSTYCMS_API_KEYS |
– | Optional. Multiple keys with roles: key1:read_write,key2:read. Roles: read, read_write, admin. Overrides RUSTYCMS_API_KEY. |
RUSTYCMS_ENVIRONMENTS |
– | Optional. Comma-separated (e.g. production,staging). File store only. Content per env; API uses ?_environment=staging. |
RUSTYCMS_WEBHOOKS |
– | Optional. Comma-separated URLs. POST with JSON { event, collection?, slug?, ... } on content/asset/schema create/update/delete. |
RUSTYCMS_CORS_ORIGIN |
all | Optional. One allowed CORS origin (e.g. https://my-frontend.com). Empty or * = all allowed. |
RUSTYCMS_CACHE_TTL_SECS |
60 |
Optional. Response cache for GET /api/content in seconds. 0 = cache off. |
Examples:
# File store (default), no auth
cargo run
# SQLite backend
RUSTYCMS_STORE=sqlite cargo run
# Write access only with API key
RUSTYCMS_API_KEY=your-secret-token cargo run
Referrer index
When not using RUSTYCMS_ENVIRONMENTS, the API maintains a reverse index of references: for each entry it knows which other entries reference it. This is stored as a single JSON file in the content directory: content/_referrers.json. On startup, if that file is missing, a full reindex is run over all collections and locales; on every create/update/delete the index is updated incrementally. Use GET /api/content/:type/:slug/referrers to list referrers (response: array of { collection, slug, field, locale? }). Documented in Swagger UI and OpenAPI spec.
Project structure
rustycms/
├── admin-ui/ # Next.js Admin UI (optional)
│ ├── src/
│ │ ├── app/ # Dashboard, content list/edit pages
│ │ └── components/ # ContentForm, MarkdownEditor, ReferenceArrayField, Sidebar
│ └── package.json
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point, CLI, server
│ ├── schema/
│ │ ├── types.rs # SchemaDefinition, FieldDefinition
│ │ ├── loader.rs # Load JSON5, resolve extends/pick/omit/partial
│ │ ├── json_schema.rs # Export JSON Schema for editor validation
│ │ ├── validator.rs # Validation (constraints, unique, readonly, …)
│ │ └── mod.rs # SchemaRegistry
│ ├── bin/
│ │ └── export_json_schema.rs # CLI: export schemas + optional .vscode/settings.json
│ ├── store/
│ │ ├── store.rs # ContentStore trait (abstract storage layer)
│ │ ├── filesystem.rs # FileStore – CRUD on JSON5 files
│ │ ├── sqlite.rs # SqliteStore – optional SQLite backend
│ │ ├── query.rs # Filter, sort, paginate
│ │ ├── slug.rs # Slug normalisation & validation
│ │ └── mod.rs
│ └── api/
│ ├── handlers.rs # REST handlers (CRUD + validation)
│ ├── routes.rs # Axum router
│ ├── openapi.rs # OpenAPI spec & Swagger UI
│ ├── auth.rs # Optional API key check (POST/PUT/DELETE)
│ ├── error.rs # API error types
│ ├── response.rs # Reference formatting, _resolve
│ ├── transform.rs # Image transformation (external URL → resize/crop/format)
│ └── mod.rs
├── types/ # Type definitions (layer 1)
│ ├── blog_post.json5
│ ├── page.json5 # extends blog_post
│ └── product.json5 # strict mode, constraints, unique, pattern
├── scripts/
│ └── contentful-to-rustycms.mjs # Migration: Contentful-Export → content/de (JSON5)
├── schemas/ # Generated JSON Schema (optional, for editor)
└── content/ # Content files (layer 2)
├── blog_post/
├── page/
└── product/
Editor validation (JSON Schema)
You can export JSON Schema files from your type definitions so that VS Code / Cursor validate content/*.json5 (and *.json) while you edit. Invalid fields, wrong types, or constraint violations show as squiggles in the editor.
# Export one .schema.json per content type into ./schemas
cargo run --bin export-json-schema
# Also write .vscode/settings.json so content/<type>/*.json5 uses schemas/<type>.schema.json
cargo run --bin export-json-schema -- --vscode
Options: --types-dir (default ./types), --out-dir (default ./schemas). After running with --vscode, open any file under content/blog_post/, content/page/, etc. – the editor will use the matching schema. Re-run the command after changing type definitions to refresh schemas and (with --vscode) the settings.
Defining types
Add a .json5 file under types/:
// types/blog_post.json5
{
name: "blog_post",
description: "Simple blog post with title, body, tags and publish status",
tags: ["content", "blog"],
category: "content",
fields: {
title: {
type: "string",
required: true,
minLength: 1,
maxLength: 200,
description: "Title of the blog post",
},
body: {
type: "richtext",
required: true,
description: "Main content (Markdown)",
},
author: { type: "string" },
tags: { type: "array", items: { type: "string" } },
published: { type: "boolean", default: false },
created_at: { type: "datetime", auto: true, readonly: true },
}
}
Schema options
| Option | Type | Description |
|---|---|---|
name |
string | Required. Content type name |
description |
string | Human-readable description (Admin UI, Swagger) |
tags |
string[] | Tags for grouping/filtering (e.g. ["content","blog"]) |
category |
string | Category for Admin UI (e.g. "content", "components") |
extends |
string / array | Parent type(s) to inherit from |
strict |
boolean | Reject unknown fields |
pick |
string[] | Only inherit these fields from parent (Pick<T>) |
omit |
string[] | Exclude these fields from parent (Omit<T>) |
partial |
boolean | Make all inherited fields optional (Partial<T>) |
reusable |
boolean | Schema only as partial (useFields), no collection/API |
Field options
| Option | Type | Description |
|---|---|---|
type |
string | Required. Field type (see below) |
required |
boolean | Field must be present when creating |
unique |
boolean | Value must be unique in the collection |
default |
any | Default value if not provided |
auto |
boolean | Auto-generated (e.g. timestamp) |
readonly |
boolean | Field cannot be changed after creation |
nullable |
boolean | Field allows explicit null |
enum |
array | Allowed values |
description |
string | Field description (Swagger UI, Admin UI) |
minLength |
number | Minimum string length |
maxLength |
number | Maximum string length |
pattern |
string | Regex pattern for strings |
min |
number | Minimum numeric value |
max |
number | Maximum numeric value |
minItems |
number | Minimum array length |
maxItems |
number | Maximum array length |
items |
object | Item definition for array fields |
fields |
object | Nested fields for object type |
collection |
string | Target collection for reference type |
useFields |
string | For type: "object": use fields from this schema (partial) |
widget |
string | For string: "textarea" = multi-line; "code" = code editor with syntax highlighting (requires codeLanguage) |
codeLanguage |
string | When widget: "code": "css", "javascript", "json", or "html" – used for highlighting and copy in admin UI |
Field types
| Type | JSON type | Description |
|---|---|---|
string |
string | Plain text |
richtext |
string | Markdown / rich text |
markdown |
string | Markdown content |
html |
string | Raw HTML (safely embedded) |
number |
number | Integer or float |
integer |
number | Integer only |
boolean |
boolean | true / false |
datetime |
string | ISO 8601 date/time |
array |
array | List (optional items) |
object |
object | Nested object |
reference |
string | Reference to another type |
Inheritance & type composition
Simple inheritance – inherit all fields from parent:
{
name: "page",
description: "Page with layout, SEO and content rows",
tags: ["content", "page"],
category: "content",
extends: "blog_post",
fields: {
nav_order: { type: "number", min: 0 },
template: { type: "string", enum: ["default", "landing", "docs"] },
}
}
Multiple inheritance – inherit from several types:
{
name: "featured_article",
description: "Featured article with expiration",
tags: ["content", "blog"],
category: "content",
extends: ["blog_post", "seo_meta"],
fields: {
featured_until: { type: "datetime" },
}
}
Pick – only certain fields from parent:
{
name: "blog_post_preview",
extends: "blog_post",
pick: ["title", "excerpt", "author", "created_at"],
}
Omit – exclude certain fields:
{
name: "blog_post_light",
extends: "blog_post",
omit: ["body"],
}
Partial – make all inherited fields optional:
{
name: "blog_post_patch",
extends: "blog_post",
partial: true,
}
Reusable partials (useFields)
Define shared field groups once and reuse in multiple types:
- Reusable schema – e.g.
types/component_layout.json5withreusable: trueand only the shared fields (e.g.mobile,tablet,desktop,spaceBottom). - Reference in type – on an object field:
type: "object",useFields: "component_layout". On load, the partial’s fields are inlined.
This keeps layout in one place and reuses it in Markdown, Headline, etc.
Strict mode
In strict mode, unknown fields are rejected:
{
name: "product",
description: "Product with SKU, price and optional fields",
tags: ["content", "ecommerce"],
category: "content",
strict: true,
fields: {
title: { type: "string", required: true },
price: { type: "number", required: true, min: 0 },
sku: { type: "string", required: true, unique: true, pattern: "^[A-Z]{2,4}-\\d{3,6}$" },
}
}
Creating content
Contentful-Migration
Ein Contentful-Export (z. B. contentful-export.json) kann nach content/de/ überführt werden:
node scripts/contentful-to-rustycms.mjs [Pfad-zum-Export]
# Default: ../www.windwiderstand.de/contentful-export.json
Es werden u. a. Pages, Posts, Markdown, Link-Listen, Fullwidth-Banner, Tags, Navigation, Footer, Page-Config und Campaigns geschrieben. Nur unterstützte Row-Komponenten (markdown, link_list, fullwidth_banner, post_overview, searchable_text) landen in row1Content.
Via files
Add a .json5 file under content/<type>/ or, for localized content, content/<locale>/<type>/ (e.g. content/de/page/). The filename (without extension) becomes the slug.
// content/product/laptop-pro.json5
{
title: "Laptop Pro 16",
sku: "EL-1001",
price: 1499.99,
category: "electronics",
images: ["laptop-front.jpg", "laptop-side.jpg"],
}
Via API
curl -X POST http://localhost:3000/api/content/product \
-H "Content-Type: application/json" \
-d '{
"_slug": "keyboard-mx",
"title": "Keyboard MX",
"sku": "EL-2000",
"price": 79.99,
"category": "electronics",
"images": ["keyboard.jpg"]
}'
_slug sets the filename. Fields with default or auto are set automatically.
Note: If RUSTYCMS_API_KEY is set, POST/PUT/DELETE (including schema and content) must send the key – see section “Optional API auth”.
REST API
Overview
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/collections |
List all content types |
GET |
/api/collections/:type |
Get schema for a type |
POST |
/api/schemas |
Create new type (Admin UI: New type) |
PUT |
/api/schemas/:type |
Update type definition (Admin UI: Edit type) |
DELETE |
/api/schemas/:type |
Delete type definition (Admin UI: Delete type) |
GET |
/api/content/:type |
List all entries |
GET |
/api/content/:type/:slug |
Get single entry |
GET |
/api/content/:type/:slug/referrers |
List entries that reference this entry |
POST |
/api/content/:type |
Create entry |
PUT |
/api/content/:type/:slug |
Update entry |
DELETE |
/api/content/:type/:slug |
Delete entry |
GET |
/api or /api/ |
API index (living docs: links to Swagger UI + endpoint overview) |
GET |
/swagger-ui |
Swagger UI |
GET |
/api-docs/openapi.json |
OpenAPI 3.0 spec |
GET |
/api/assets |
List all uploaded image assets |
POST |
/api/assets |
Upload an image asset (multipart/form-data) |
GET |
/api/assets/:filename |
Serve an image asset |
DELETE |
/api/assets/:filename |
Delete an image asset |
GET |
/api/transform |
Transform image from external URL (resize, crop, format) |
GET |
/health |
Health check (e.g. for K8s/Docker), always 200 + {"status":"ok"} |
Asset management
Images and other media files can be stored in content/assets/ and served directly via the API. This is useful for logos, hero images, icons, or any static media that belongs to your content.
Allowed file types: jpg, jpeg, png, webp, avif, gif, svg
Files are stored at {content-dir}/assets/ (default: ./content/assets/). The directory is created automatically on first use.
List assets
GET /api/assets
{
"assets": [
{ "filename": "hero.jpg", "url": "/api/assets/hero.jpg", "mime_type": "image/jpeg", "size": 102400 }
],
"total": 1
}
Upload an asset
curl -X POST http://localhost:3000/api/assets \
-H "X-API-Key: your-key" \
-F "file=@/path/to/hero.jpg"
Response (201):
{ "filename": "hero.jpg", "url": "/api/assets/hero.jpg", "mime_type": "image/jpeg", "size": 102400 }
- Auth required (POST/DELETE) when
RUSTYCMS_API_KEYis set - Filenames are lowercased and sanitized (only letters, digits,
-,_,.) - Uploading a file that already exists returns
409 Conflict
Serve an asset
GET /api/assets/hero.jpg
Returns the raw image with correct Content-Type. Response header:
Cache-Control: public, max-age=31536000, immutable
Browsers and CDNs cache the file for 1 year. Combine with /api/transform for on-the-fly resizing of uploaded assets:
GET /api/transform?url=http://localhost:3000/api/assets/hero.jpg&w=400&h=300&format=webp
Delete an asset
curl -X DELETE http://localhost:3000/api/assets/hero.jpg \
-H "X-API-Key: your-key"
Returns 204 No Content.
Image transformation (external URLs)
GET /api/transform loads an image from an external URL and returns it transformed. Parameters:
| Parameter | Alias | Description |
|---|---|---|
url |
– | Required. External image URL (e.g. HTTPS). |
width |
w |
Target width in pixels. |
height |
h |
Target height in pixels. |
aspect_ratio |
ar |
Aspect ratio before resize, e.g. 1:1 or 16:9 (center crop). |
fit |
– | fill (exact w×h), contain (keep aspect ratio), cover (fill with crop). When w and h are set: default fill; otherwise default contain. |
format |
– | Output: jpeg, png, webp or avif. Default: jpeg. WebP is lossy when quality is used. |
quality |
– | Quality 1–100 for JPEG and WebP (lossy). Default: 85. |
Example: 50×50 square from any image:
GET /api/transform?url=https://example.com/image.jpg&w=50&h=50&ar=1:1
Caching: Identical requests (same URL + same params) are served from memory when response cache is enabled (RUSTYCMS_CACHE_TTL_SECS > 0) until TTL expires (no re-download/resize).
ETags (conditional GET)
GET /api/content/:type/:slug sets an ETag header (content hash). If the client sends If-None-Match with that value and the content is unchanged, the API responds with 304 Not Modified (no body) – saves bandwidth and load.
Resolving references (_resolve)
Reference fields return only { "_type": "collection", "_slug": "slug" } by default. With the _resolve query parameter you can embed referenced entries:
# Resolve all references (e.g. page with Markdown blocks, banner)
GET /api/content/page/contact?_resolve=all
# Resolve only a specific field
GET /api/content/page/home?_resolve=row1Content
Response then includes the full object in row1Content instead of just { _type, _slug }.
Filtering
Exact match: ?field=value. Suffixes are also supported:
?title_prefix=Hello– title starts with “Hello”?body_contains=rust– body contains “rust”?price_min=10– number/string >= 10?price_max=100– number/string <= 100
GET /api/content/blog_post?published=true
GET /api/content/blog_post?title_prefix=Intro
GET /api/content/product?category=electronics&price_min=50
Sorting
GET /api/content/blog_post?_sort=created_at&_order=desc
Pagination
GET /api/content/blog_post?_page=2&_per_page=10
Response:
{
"items": [ ... ],
"total": 42,
"page": 2,
"per_page": 10,
"total_pages": 5
}
Validation
The API validates automatically against the schema:
POST with title: 123 → "Expected type 'string', got 'number'"
POST without required field → "Field is required"
POST with sku: "bad" → "Value does not match pattern '^[A-Z]{2,4}-\\d{3,6}$'"
POST with price: -5 → "Value -5 is below minimum 0"
POST with duplicate SKU → "Value must be unique"
POST with unknown field → "Unknown field (strict mode is enabled)"
PUT with changed readonly → "Field is readonly and cannot be changed"
POST with title: null → "Field does not allow null"
POST with rating: null → OK (nullable: true)
POST with images: [] → "Array too short (min 1 items)"
POST with excerpt (501 chars)→ "String too long (max 500 characters)"
Store: file vs SQLite
By default the file store is used: each collection is a subdirectory under content/, each entry a .json5 file.
You can switch to SQLite – same REST API, same schemas, different backing store (one table with collection, slug, JSON data):
RUSTYCMS_STORE=sqlite RUSTYCMS_DATABASE_URL=sqlite:content.db cargo run
The database file is created when needed (create_if_missing). Useful for larger setups, concurrent writers, or when you want content in a database.
Optional API auth
If you set RUSTYCMS_API_KEY (e.g. in .env), POST, PUT and DELETE require a valid key. GET stays public (read without auth).
Send the key – one of:
- Header:
Authorization: Bearer <your-api-key> - Header:
X-API-Key: <your-api-key>
Example with curl:
# Without key → 401 Unauthorized
curl -X POST http://localhost:3000/api/content/tag -H "Content-Type: application/json" -d '{"_slug":"x","name":"y"}'
# With key → 201 Created (or validation error)
curl -X POST http://localhost:3000/api/content/tag \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-token" \
-d '{"_slug":"x","name":"y"}'
If RUSTYCMS_API_KEY is not set, all endpoints behave as before without auth.
Swagger UI
Auto-generated interactive API documentation:
http://localhost:3000/swagger-ui
The OpenAPI spec is generated at startup from the schemas – including all constraints (minLength, pattern, min/max, nullable, readOnly, …). The spec can also be fetched directly; it is served with cache headers so clients can cache it:
http://localhost:3000/api-docs/openapi.json
Schema hot-reload: Changes in types/*.json5 are watched – after saving, schemas and the OpenAPI spec are reloaded automatically without restarting the server.
Troubleshooting
| Problem | Possible cause |
|---|---|
Collection 'xyz' not found |
Type does not exist or path typo. Check GET /api/collections. |
Field '_slug' is required |
POST must include _slug in the JSON body. |
Missing or invalid API key |
RUSTYCMS_API_KEY is set; POST/PUT/DELETE need header Authorization: Bearer <key> or X-API-Key: <key>. |
Entry 'slug' not found |
Slug misspelled or entry not present in the collection. |
| Validation errors (type, pattern, unique, …) | Check request body: types, required fields, patterns and uniqueness per schema. |
Operation
- Graceful shutdown: On SIGINT (Ctrl+C) or SIGTERM (e.g. K8s), in-flight requests are completed, then the server exits cleanly.
Logging
# Default (info)
cargo run
# Debug
RUST_LOG=rustycms=debug,tower_http=debug cargo run
# Warnings only
RUST_LOG=warn cargo run
License
MIT