API Reference
Complete REST API reference for every Blog-Doc endpoint — request shape, response shape, and status codes.
All API endpoints are under the /admin/api prefix. All request and response bodies are JSON unless noted.
Base URL: http://localhost:3000/admin/api
Common error shape:
{ "error": "Human-readable message" }
Status codes used:
| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created |
400 |
Bad request (missing field, invalid value, failed validation) |
404 |
Resource not found |
409 |
Conflict (slug already taken, has children) |
500 |
Server error — always includes error and message |
502 |
Upstream fetch failed (marketplace, remote install) |
Pages
Response 200
{
"pages": [
{
"slug": "about",
"title": "About",
"description": "About this site",
"order": 1,
"root": true,
"parent": null,
"depth": 1,
"status": "published",
"featured_image": "/images/hero.webp",
"url": "/about/"
},
{
"slug": "getting-started",
"title": "Getting Started",
"description": "",
"order": null,
"root": false,
"parent": "documentation",
"depth": 2,
"status": "published",
"featured_image": null,
"url": "/documentation/getting-started/"
}
]
}
Response 200
{
"slug": "about",
"frontmatter": {
"title": "About",
"description": "About this site",
"slug": "about",
"order": 1,
"root": false,
"status": "published"
},
"body": "## Hello\n\nThis is the about page."
}
Returns 404 if not found.
Response 200
{ "slug": "about", "available": true }
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | Yes | |
slug |
string | Yes | Will be slugified |
description |
string | No | |
parent |
string | No | Cleared automatically when root: true. Max 3 levels deep. |
order |
number | No | |
root |
boolean | No | If true, served at /{slug} |
featured_image |
string | No | |
status |
"draft" | "published" |
No | Defaults to "draft" |
body |
string | No | Raw Markdown content |
Response 201
{ "slug": "about", "message": "Page created" }
Returns 409 if the slug is already taken.
Accepts the same fields as POST. If slug in the body differs from :slug in the URL, the page file is renamed and the parent field of all direct children is updated automatically.
Response 200
{ "slug": "about", "message": "Page updated" }
Returns 404 if the current page doesn't exist. Returns 409 if the new slug is taken.
Response 200
{ "message": "Page deleted" }
Returns 404 if not found. Returns 409 if the page has children.
Request body
{ "action": "publish", "slugs": ["about", "contact", "docs"] }
action must be "publish", "draft", or "delete".
Response 200
{
"ok": ["about", "contact"],
"skipped": [{ "slug": "docs", "reason": "has children: getting-started" }]
}
Always returns 200. Results are reported per-slug.
Posts
Posts share the same endpoint shape as Pages, substituting /admin/api/posts for the base. Differences are noted below.
Response 200
{
"posts": [
{
"slug": "hello-world",
"title": "Hello World",
"description": "My first post",
"date": "2025-06-01",
"category": "General",
"tags": ["intro", "news"],
"status": "published",
"featured_image": null,
"author": "LebCit"
}
]
}
Post-specific fields in addition to shared page fields:
| Field | Type | Notes |
|---|---|---|
date |
string | ISO date YYYY-MM-DD. Defaults to today if omitted. |
category |
string | A single category string |
tags |
string | array | Comma-separated string or array |
author |
string | A single author string |
The root and order fields do not apply to posts. The parent hierarchy does not apply to posts.
Response 200
{
"slug": "hello-world",
"frontmatter": {
"title": "Hello World",
"description": "My first post",
"slug": "hello-world",
"date": "2025-06-01",
"category": "General",
"tags": ["intro", "news"],
"status": "published",
"featured_image": null,
"related_posts": ["another-post", "yet-another"],
"author": "LebCit"
},
"body": "## Hello\n\nThis is the post body."
}
Returns 404 if not found.
Response 200
{ "slug": "hello-world", "available": true }
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | Yes | |
slug |
string | Yes | Will be slugified |
description |
string | No | |
date |
string | No | ISO date YYYY-MM-DD. Defaults to today if omitted. |
category |
string | No | A single category string |
tags |
string | array | No | Comma-separated string or array |
featured_image |
string | No | |
related_posts |
string | array | No | Comma-separated string or array of post slugs. Max 4. Order is preserved. |
status |
"draft" | "published" |
No | Defaults to "draft" |
body |
string | No | Raw Markdown content |
author |
string | No |
Response 201
{ "slug": "hello-world", "message": "Post created" }
Returns 409 if the slug is already taken.
Accepts the same fields as POST. If slug in the body differs from :slug in the URL, the post file is renamed automatically.
Response 200
{ "slug": "hello-world", "message": "Post updated" }
Returns 404 if the current post doesn't exist. Returns 409 if the new slug is taken.
Response 200
{ "message": "Post deleted" }
Returns 404 if not found.
Request body
{ "action": "publish", "slugs": ["hello-world", "another-post"] }
action must be "publish", "draft", or "delete". Posts have no parent-child constraint, so delete never produces skipped entries due to children.
Response 200
{
"ok": ["hello-world", "another-post"],
"skipped": []
}
Always returns 200. Results are reported per-slug.
Images
Response 200
{
"images": [
{
"name": "hero.webp",
"url": "/images/hero.webp",
"size": 48210,
"mtime": "2025-06-01T10:00:00.000Z"
}
]
}
- Content-Type:
multipart/form-data - Field name:
images - Size limit: 10 MB per upload
- Allowed extensions:
.jpg,.jpeg,.png,.gif,.webp,.svg,.avif - Filenames are sanitized — lowercased, special chars stripped, spaces replaced with hyphens
- Collisions resolved by appending a counter (
hero-1.webp)
Response 201
{
"uploaded": [{ "name": "hero.webp", "url": "/images/hero.webp", "size": 48210 }],
"errors": []
}
Returns 400 if the filename contains .., /, or \. Returns 404 if not found.
Response 200
{ "message": "Image deleted" }
Request body
{ "names": ["hero.webp", "logo.png"] }
Response 200
{
"ok": ["hero.webp"],
"skipped": [{ "name": "logo.png", "reason": "not found" }]
}
Themes
Response 200
{
"themes": [
{
"id": "default",
"name": "Default",
"version": "1.0.0",
"features": ["blog"],
"menus": ["primary", "footer"]
}
],
"activeTheme": "default"
}
Response 200
{ "activeTheme": "default", "message": "Theme \"Default\" activated" }
Returns 404 if the theme ID doesn't exist. Returns 400 if a required template is missing.
The ZIP must contain a valid theme.json with at minimum name, id, and version. Both templates/home.html and templates/404.html must be present inside the ZIP — these are validated before any files touch disk.
Response 201
{ "themeId": "my-theme", "name": "My Theme", "message": "Theme \"My Theme\" installed" }
Returns 400 for non-ZIP uploads, missing theme.json, invalid manifest, or missing required templates.
The manifest is fetched from the official Blog-Doc themes repository on Codeberg and cached in memory for 30 minutes. Update detection compares each remote theme's version against the locally installed copy with the same id.
Response 200
{
"fetchedAt": 1748800000000,
"themes": [
{
"id": "my-theme",
"name": "My Theme",
"version": "1.2.0",
"author": "Jane Doe",
"description": "A clean, minimal theme.",
"screenshot": "https://example.com/screenshot.webp",
"download": "https://example.com/my-theme.zip"
}
],
"updates": ["my-theme"]
}
updates is an array of IDs of installed themes whose local version differs from the remote. Returns 502 if the remote manifest cannot be fetched.
Request body
{ "url": "https://example.com/my-theme.zip" }
The ZIP is fetched into memory and passed through the same pipeline as the local install endpoint. The marketplace cache is invalidated after a successful install.
Response 201
{ "themeId": "my-theme", "name": "My Theme", "message": "Theme \"My Theme\" installed" }
Returns 400 if url is missing or the ZIP fails validation. Returns 502 if the remote download fails.
Response 200
{ "message": "Theme \"My Theme\" deleted" }
Menus
Response 200
{
"menus": {
"primary": [
{ "label": "Home", "url": "/" },
{ "label": "Blog", "url": "/blog" }
],
"footer": []
},
"supportedMenus": ["primary", "footer"]
}
supportedMenus comes from the active theme's theme.json menus array.
Request body
{
"primary": [
{ "label": "Home", "url": "/" },
{ "label": "About", "url": "/about" }
],
"footer": [{ "label": "Privacy", "url": "/privacy" }]
}
Blog-Doc does not enforce a schema on menu items — store whatever shape your theme expects.
Response 200
{ "menus": { "..." }, "message": "Menus saved" }
Request body
[
{ "label": "Home", "url": "/" },
{ "label": "About", "url": "/about" }
]
Response 200
{ "menu": "primary", "items": ["..."], "message": "Menu \"primary\" saved" }
Settings
Response 200
{
"settings": {
"title": "My Site",
"description": "A personal blog",
"url": "https://example.com",
"postsPerPage": 5,
"language": "en",
"logo": "/images/logo.png",
"icon": "/images/favicon.png",
"headerCode": "<!-- custom head HTML -->",
"footerCode": "<!-- custom pre-body HTML -->"
}
}
Accepted keys: title, description, url, postsPerPage, language, logo, icon, headerCode, footerCode. All other keys are silently ignored. Settings are merged (Object.assign-style).
Send an empty string for logo or icon to clear the field.
Response 200
{ "settings": { "..." }, "message": "Settings saved" }
Build
Response 200 (success)
{
"success": true,
"stats": { "pages": 3, "posts": 12, "assets": 8, "taxonomyPages": 6 },
"errors": [],
"duration": "0.45s",
"message": "Site built in 0.45s — 3 pages, 12 posts",
"builtAt": "2025-06-01T10:00:00.000Z"
}
Response 500 (partial failure)
{
"success": false,
"stats": { "pages": 2, "posts": 11, "assets": 8, "taxonomyPages": 4 },
"errors": ["Post \"broken-post\": Template not found"],
"duration": "0.41s",
"message": "Build completed with 1 error(s)",
"builtAt": "2025-06-01T10:00:00.000Z"
}
Returns { "message": "No build has been run yet" } if no build has run since server start. Otherwise returns the last build result object (same shape as POST /admin/api/build).
- Content-Type:
application/zip - Returns
404if no build exists yet.
Health
Response 200
{ "status": "ok", "app": "Blog-Doc", "version": "4.0.0" }