Getting Started
Go from zero to a running HTMLess instance with content in under five minutes. All you need is Docker.
Quick Start with Docker
The fastest way to run HTMLess locally is with Docker Compose. This spins up the API, admin UI, PostgreSQL, and Redis in one command.
# Clone the repo git clone https://github.com/pbieda/htmless.git cd htmless # Start everything docker compose up -d # API is now at http://localhost:3000 # Admin UI at http://localhost:3001
admin@htmless.com / Password: admin123. Change these immediately in production.
Local Development Setup
If you want to develop HTMLess itself, use the dev compose file with hot reloading.
# Install dependencies pnpm install # Start Postgres + Redis docker compose -f docker-compose.dev.yml up -d # Run migrations and seed pnpm --filter @htmless/core run db:migrate pnpm --filter @htmless/core run db:seed # Start API + Admin in dev mode pnpm dev
Your First API Call
Once the stack is running, authenticate and fetch content types.
# 1. Login to get a JWT token curl -s -X POST http://localhost:3000/cma/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@htmless.com","password":"admin123"}' # Response: { "token": "eyJhbG...", "user": { ... } } # 2. List content types curl -s http://localhost:3000/cda/v1/schemas/types \ -H "X-Space-Id: YOUR_SPACE_ID" # 3. Fetch published entries curl -s http://localhost:3000/cda/v1/content/article \ -H "X-Space-Id: YOUR_SPACE_ID"
Project Structure
| Package | Description |
|---|---|
packages/core | API server, database, auth, CLI, SDK, webhooks, workers |
packages/admin | Next.js admin dashboard |
packages/worker | Background job processor (webhooks, scheduling) |
API Reference
HTMLess exposes three API surfaces: CMA (write), CDA (read), and Preview (draft-inclusive). All endpoints return JSON and require the X-Space-Id header.
Content Management API (CMA)
Write-heavy, role-gated endpoints for editors and automation. Requires a JWT or API token with appropriate scopes.
Schema Endpoints
Entry Endpoints
Example: Create an Entry
curl -X POST http://localhost:3000/cma/v1/entries \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "typeKey": "article", "slug": "hello-world", "fields": { "title": "Hello World", "body": "Welcome to HTMLess.", "author": "admin" } }'
Response
{ "id": "cln1a2b3c4d5e6f7g8h9i0j", "typeKey": "article", "slug": "hello-world", "state": "draft", "fields": { "title": "Hello World", "body": "Welcome to HTMLess.", "author": "admin" }, "version": 1, "createdAt": "2026-04-03T12:00:00.000Z", "updatedAt": "2026-04-03T12:00:00.000Z" }
Content Delivery API (CDA)
Read-only, published-only endpoints optimized for frontend consumption. Responses include Cache-Control and ETag headers for CDN caching.
Query Parameters
| Param | Description | Example |
|---|---|---|
slug | Filter by slug | ?slug=hello-world |
locale | Locale for localized fields | ?locale=fr |
fields | Response projection | ?fields=title,slug |
include | Expand references | ?include=author |
sort | Sort order | ?sort=-createdAt |
page | Page number | ?page=2 |
limit | Items per page (max 100) | ?limit=25 |
Example: Fetch Articles
curl -s http://localhost:3000/cda/v1/content/article?limit=10&sort=-createdAt&fields=title,slug,createdAt \ -H "X-Space-Id: YOUR_SPACE_ID"
{ "items": [ { "id": "cln1a2b3c...", "slug": "hello-world", "fields": { "title": "Hello World", "slug": "hello-world", "createdAt": "2026-04-03T12:00:00.000Z" } } ], "pagination": { "page": 1, "limit": 10, "total": 1, "totalPages": 1 } }
Preview API
Draft-inclusive reads for preview environments. Requires a preview token and returns the latest draft version of entries.
curl -s http://localhost:3000/preview/v1/content/article?slug=hello-world \ -H "Authorization: Bearer PREVIEW_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID"
Response Shaping
All list endpoints support response shaping to minimize payload size.
# Only return title and slug fields, expand the author reference GET /cda/v1/content/article?fields=title,slug&include=author # Sort by creation date descending, page 2 with 25 items GET /cda/v1/content/article?sort=-createdAt&page=2&limit=25
Concurrency Control
Mutable CMA endpoints use ETag / If-Match for optimistic concurrency (RFC 7232). The server returns an ETag with every response. When updating, pass it back.
curl -X POST http://localhost:3000/cma/v1/entries/ENTRY_ID:saveDraft \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "If-Match: \"v3\"" \ -H "Content-Type: application/json" \ -d '{"fields":{"title":"Updated Title"}}' # Returns 412 Precondition Failed if the ETag doesn't match
Authentication
HTMLess uses JWT for interactive sessions and scoped API tokens for machine access. RBAC is enforced on every route.
JWT Login
Authenticate with email and password to receive a JWT token for CMA access.
curl -X POST http://localhost:3000/cma/v1/auth/login \ -H "Content-Type: application/json" \ -d '{ "email": "admin@htmless.com", "password": "admin123" }'
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": "usr_abc123", "email": "admin@htmless.com", "name": "Admin", "role": "admin" } }
Include the token in subsequent requests:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
API Tokens
API tokens are long-lived, scoped credentials for machine-to-machine access. Created by admins through the CMA.
curl -X POST http://localhost:3000/cma/v1/api-tokens \ -H "Authorization: Bearer ADMIN_JWT" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "Next.js Frontend", "scopes": ["cda:read"], "expiresIn": "90d" }'
{ "id": "tok_a1b2c3d4", "name": "Next.js Frontend", "token": "htk_live_xxxxxxxxxxxxxxxxxxxx", "scopes": ["cda:read"], "expiresAt": "2026-07-02T00:00:00.000Z" }
token value is only shown once at creation time. Store it securely.
Preview Tokens
Short-lived tokens for preview environments. Can be scoped to a single entry or a content type.
curl -X POST http://localhost:3000/cma/v1/preview-tokens \ -H "Authorization: Bearer ADMIN_JWT" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "audience": "preview.mysite.com", "entryId": "cln1a2b3c4d5e6f7g8h9i0j", "expiresIn": "1h" }'
RBAC Roles
| Role | Scopes | Description |
|---|---|---|
admin | All | Full access to everything |
editor | cma:read cma:write | Create, edit, publish content |
author | cma:read cma:write:own | Edit own content only |
viewer | cma:read | Read-only CMA access |
Required Headers
| Header | Required | Description |
|---|---|---|
X-Space-Id | Always | Identifies the space (tenant) |
Authorization | CMA, Preview | Bearer token (JWT or API token) |
Content-Type | POST/PATCH | Must be application/json |
If-Match | Updates | ETag for concurrency control |
Content Modeling
Define your content structure with content types, fields, taxonomies, and localization. Changes are versioned and published explicitly.
Content Types
A content type defines the shape of your content. Each type has a unique key, a display name, and a set of fields.
curl -X POST http://localhost:3000/cma/v1/schemas/types \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "key": "article", "name": "Article", "description": "Blog posts and articles" }'
Field Types
| Type | Description | Example Value |
|---|---|---|
text | Plain text string | "Hello World" |
richtext | Rich text with formatting | "<p>Hello</p>" |
number | Integer or decimal | 42 |
boolean | True/false toggle | true |
date | ISO 8601 date/datetime | "2026-04-03" |
media | Reference to an asset | "asset_abc123" |
reference | Reference to another entry | "entry_xyz789" |
json | Arbitrary JSON data | {"key": "value"} |
slug | URL-safe identifier | "hello-world" |
enum | Predefined value set | "published" |
Add Fields to a Type
curl -X POST http://localhost:3000/cma/v1/schemas/types/article/fields \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "key": "title", "name": "Title", "type": "text", "required": true, "localized": true, "validations": { "minLength": 1, "maxLength": 200 } }'
Taxonomies
Taxonomies let you classify entries with terms. They can be flat (tags) or hierarchical (categories).
curl -X POST http://localhost:3000/cma/v1/taxonomies \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "key": "category", "hierarchical": true, "allowedTypes": ["article", "page"], "labels": { "singular": "Category", "plural": "Categories" } }'
Localization
Mark individual fields as localized: true to store per-locale values. Non-localized fields share a single value across locales.
// When a field is localized, its value is an object keyed by locale { "fields": { "title": { "en": "Hello World", "fr": "Bonjour le monde", "de": "Hallo Welt" }, "author": "admin" // not localized, single value } }
curl http://localhost:3000/cda/v1/content/article?slug=hello-world&locale=fr \ -H "X-Space-Id: YOUR_SPACE_ID"
Schema Versioning
Schema changes are tracked with a version number. When you add or remove fields, the version increments. Publish the schema to make changes available in the CDA.
curl -X POST http://localhost:3000/cma/v1/schemas/types/article/publish \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID"
Content Workflow
Every entry follows a state machine: draft → published → archived. Versions are immutable snapshots.
Draft / Publish Lifecycle
Entries start as drafts. Each save creates an immutable version. Publishing pins a version as the live content served by the CDA.
curl -X POST http://localhost:3000/cma/v1/entries/ENTRY_ID:saveDraft \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "fields": { "title": "Updated Draft Title", "body": "Work in progress..." } }'
curl -X POST http://localhost:3000/cma/v1/entries/ENTRY_ID:publish \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID"
Versioning
Every draft save increments the version number. You can list all versions and revert to any prior one.
curl http://localhost:3000/cma/v1/entries/ENTRY_ID/versions \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID"
{ "items": [ { "version": 3, "createdAt": "2026-04-03T14:30:00Z", "createdBy": "admin" }, { "version": 2, "createdAt": "2026-04-03T13:00:00Z", "createdBy": "admin" }, { "version": 1, "createdAt": "2026-04-03T12:00:00Z", "createdBy": "admin" } ] }
curl -X POST http://localhost:3000/cma/v1/entries/ENTRY_ID:revert \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{"toVersion": 1}'
Scheduling
Schedule an entry to be published at a future time. The worker will automatically publish it when the time arrives.
curl -X POST http://localhost:3000/cma/v1/entries/ENTRY_ID:schedule \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{"publishAt": "2026-04-10T09:00:00Z"}'
Preview
Use the Preview API to see draft content in your frontend before publishing. Create a preview token, then pass it to the Preview API.
// app/api/preview/route.ts import { draftMode } from 'next/headers'; import { redirect } from 'next/navigation'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const slug = searchParams.get('slug'); const previewToken = searchParams.get('token'); // Validate the preview token with HTMLess const res = await fetch( `http://localhost:3000/preview/v1/content/article?slug=${slug}`, { headers: { 'Authorization': `Bearer ${previewToken}`, 'X-Space-Id': process.env.HTMLESS_SPACE_ID!, }} ); if (!res.ok) return new Response('Invalid preview', { status: 401 }); (await draftMode()).enable(); redirect(`/articles/${slug}`); }
Blocks & Patterns
Structured rich content stored as validated JSON trees -- not HTML. Define block types, compose patterns, and render with the SDK.
Core Blocks
HTMLess ships with seven core block types. Each has a validated attributes schema.
| Block | Key | Attributes |
|---|---|---|
| Paragraph | paragraph | text |
| Heading | heading | level, text |
| Image | image | assetId, alt, caption |
| Callout | callout | tone, title, body |
| Embed | embed | url |
| List | list | ordered, items |
| Code | code | language, code |
Block Instance Format
{ "typeKey": "heading", "version": "1.0.0", "attrs": { "level": 2, "text": "Getting Started with HTMLess" }, "children": [] }
Custom Block Definitions
Register your own block types with a JSON Schema for attributes validation.
curl -X POST http://localhost:3000/cma/v1/blocks/definitions \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "key": "testimonial", "title": "Testimonial", "icon": "quote", "version": "1.0.0", "attributesSchema": { "type": "object", "properties": { "quote": { "type": "string" }, "author": { "type": "string" }, "role": { "type": "string" }, "avatarAssetId": { "type": "string" } }, "required": ["quote", "author"] } }'
Block Patterns
Patterns are reusable block compositions. Think of them as templates that insert a predefined block tree.
curl -X POST http://localhost:3000/cma/v1/patterns \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "title": "Article Intro", "blockTree": [ { "typeKey": "heading", "attrs": { "level": 1, "text": "" } }, { "typeKey": "paragraph", "attrs": { "text": "" } }, { "typeKey": "image", "attrs": { "assetId": "", "alt": "" } } ] }'
Renderer SDK
Use the renderer SDK to turn block trees into HTML/JSX in your frontend.
import type { BlockInstance, BlockRendererMap } from '@htmless/core/blocks/renderer-sdk'; import { renderBlocks } from '@htmless/core/blocks/renderer-sdk'; const renderers: BlockRendererMap = { paragraph: ({ attrs }) => <p>{attrs.text}</p>, heading: ({ attrs }) => { const Tag = `h${attrs.level}` as keyof JSX.IntrinsicElements; return <Tag>{attrs.text}</Tag>; }, image: ({ attrs }) => ( <figure> <img src={attrs.url} alt={attrs.alt || ''} /> {attrs.caption && <figcaption>{attrs.caption}</figcaption>} </figure> ), callout: ({ attrs }) => ( <aside className={`callout callout-${attrs.tone}`}> {attrs.title && <strong>{attrs.title}</strong>} <p>{attrs.body}</p> </aside> ), code: ({ attrs }) => ( <pre><code className={`language-${attrs.language}`}> {attrs.code} </code></pre> ), }; function ArticleBody({ blocks }: { blocks: BlockInstance[] }) { return <>{renderBlocks(blocks, renderers)}</>; }
Helper Functions
import { extractText, collectAssetIds } from '@htmless/core/blocks/renderer-sdk'; // Extract all plain text for search indexing const searchText = extractText(blocks); // Collect asset IDs for preloading const assetIds = collectAssetIds(blocks);
Media
Upload assets, retrieve them via CDN-friendly URLs, and apply on-the-fly image transforms.
Upload an Asset
curl -X POST http://localhost:3000/cma/v1/assets \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -F "file=@./photo.jpg" \ -F "alt=A scenic mountain view" \ -F "caption=Photo by John Doe"
{ "id": "ast_k7m2n8p4", "filename": "photo.jpg", "mimeType": "image/jpeg", "bytes": 245891, "width": 1920, "height": 1080, "alt": "A scenic mountain view", "caption": "Photo by John Doe", "storageKey": "spaces/sp_abc/assets/ast_k7m2n8p4/photo.jpg", "createdAt": "2026-04-03T12:00:00.000Z" }
Image Transforms
Transform images on the fly using URL query parameters. Results are cached by the CDN.
https://assets.htmle.ss/{assetId}/{filename}?w=800&h=450&fit=crop&fm=webp&q=75
| Param | Description | Values |
|---|---|---|
w | Width in pixels | 1-4000 |
h | Height in pixels | 1-4000 |
fit | Resize behavior | cover, contain, crop, fill |
fm | Output format | webp, avif, jpg, png |
q | Quality | 1-100 |
Update Asset Metadata
curl -X PATCH http://localhost:3000/cma/v1/assets/ast_k7m2n8p4 \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{"alt": "Updated alt text", "caption": "New caption"}'
Usage Tracking
HTMLess automatically tracks which entries reference each asset. Query usage to prevent accidental deletion of assets in use.
curl http://localhost:3000/cma/v1/assets/ast_k7m2n8p4/usages \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" # Response: { "usages": [ { "entryId": "...", "field": "heroImage" } ] }
Webhooks
Subscribe to content events and receive signed HTTP callbacks. Automatic retries. Full delivery logs.
Create a Webhook
curl -X POST http://localhost:3000/cma/v1/webhooks \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "url": "https://myapp.com/api/webhooks/htmless", "events": ["entry.published", "entry.unpublished", "asset.created"], "active": true }'
{ "id": "wh_a1b2c3d4", "url": "https://myapp.com/api/webhooks/htmless", "events": ["entry.published", "entry.unpublished", "asset.created"], "active": true, "signingSecret": "whsec_xxxxxxxxxxxxxxxx" }
Available Events
| Event | Triggered When |
|---|---|
entry.created | New entry is created |
entry.updated | Draft is saved |
entry.published | Entry is published |
entry.unpublished | Entry is unpublished |
entry.deleted | Entry is deleted |
asset.created | New asset uploaded |
asset.deleted | Asset is deleted |
schema.typePublished | Content type schema is published |
* | Subscribe to all events |
Signature Verification
Every webhook delivery is signed with HMAC-SHA256. Verify the signature in your handler to ensure authenticity.
Webhook Headers
| Header | Description |
|---|---|
X-HTMLess-Event-Id | Unique event identifier |
X-HTMLess-Timestamp | ISO 8601 timestamp of delivery |
X-HTMLess-Signature | HMAC-SHA256 signature (sha256=...) |
import { createHmac } from 'crypto'; function verifySignature(body: string, timestamp: string, signature: string, secret: string): boolean { // Reject if timestamp is older than 5 minutes (replay protection) const age = Date.now() - new Date(timestamp).getTime(); if (age > 5 * 60 * 1000) return false; const message = `${timestamp}.${body}`; const expected = `sha256=${createHmac('sha256', secret).update(message).digest('hex')}`; return signature === expected; } // In your Express handler: app.post('/api/webhooks/htmless', (req, res) => { const isValid = verifySignature( JSON.stringify(req.body), req.headers['x-htmless-timestamp'], req.headers['x-htmless-signature'], process.env.WEBHOOK_SECRET ); if (!isValid) return res.status(401).send('Invalid signature'); // Process the event... console.log('Event:', req.body.eventType, req.body.data); res.sendStatus(200); });
Retry Policy
Failed deliveries are retried up to 3 times with a 30-second delay between attempts. A delivery is considered failed if:
- The endpoint returns a non-2xx status code
- The request times out after 10 seconds
- A network error occurs
Delivery Logs
Every delivery attempt is logged. View them in the admin dashboard or via API.
curl http://localhost:3000/cma/v1/webhooks/wh_a1b2c3d4/deliveries \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Space-Id: YOUR_SPACE_ID"
GraphQL
Query content with a GraphQL-like API. Auto-generated from your schema. Supports variables and introspection.
Endpoint
Query Syntax
curl -X POST http://localhost:3000/graphql \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "query": "{ articles(limit: 10, sort: \"-createdAt\") { id slug fields { title createdAt } } }" }'
{ "data": { "articles": [ { "id": "cln1a2b3c...", "slug": "hello-world", "fields": { "title": "Hello World", "createdAt": "2026-04-03T12:00:00Z" } } ] } }
Variables
Pass variables alongside your query for parameterized requests.
curl -X POST http://localhost:3000/graphql \ -H "X-Space-Id: YOUR_SPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "query": "{ articles(slug: $slug) { id fields { title body } } }", "variables": { "slug": "hello-world" } }'
Introspection
Discover available types and fields dynamically. The schema is auto-generated from your content types.
curl http://localhost:3000/graphql/schema \ -H "X-Space-Id: YOUR_SPACE_ID"
{ "schema": { "types": { "article": { "fields": { "title": { "type": "text", "required": true }, "body": { "type": "richtext", "required": false }, "heroImage": { "type": "media", "required": false } } } } } }
JavaScript Example
async function fetchArticles() { const res = await fetch('http://localhost:3000/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Space-Id': 'YOUR_SPACE_ID', }, body: JSON.stringify({ query: `{ articles(limit: 10, sort: "-createdAt") { id slug fields { title body createdAt } } }`, }), }); const { data } = await res.json(); return data.articles; }
TypeScript SDK
Zero-dependency TypeScript client for the HTMLess API. Works in Node 18+, Deno, Bun, and all modern browsers.
Installation
npm install @htmless/core
# or
pnpm add @htmless/core
Setup
import { HTMLessClient } from '@htmless/core/sdk/client'; const client = new HTMLessClient({ baseUrl: 'http://localhost:3000', spaceId: 'YOUR_SPACE_ID', apiToken: 'htk_live_xxxxxxxxxxxxxxxxxxxx', });
Fetch Entries
// List entries with filtering and sorting const { items, meta } = await client.getEntries<Article>('article', { sort: '-createdAt', limit: 10, fields: ['title', 'slug', 'createdAt'], include: ['author'], }); console.log(items); // Article[] console.log(meta.total); // total count // Get a single entry by ID const article = await client.getEntry<Article>('article', 'ENTRY_ID'); // Filter by slug const { items: bySlug } = await client.getEntries<Article>('article', { slug: 'hello-world', });
Fetch Schema
// List all content types const types = await client.getTypes(); // Get a specific content type with its fields const articleType = await client.getType('article'); console.log(articleType.fields); // ContentTypeField[]
Assets
const asset = await client.getAsset('ast_k7m2n8p4'); console.log(asset.filename); // "photo.jpg" console.log(asset.width); // 1920 console.log(asset.mimeType); // "image/jpeg"
Preview
// Fetch draft content for preview const draft = await client.getPreview<Article>( 'article', 'hello-world', 'PREVIEW_TOKEN' );
Error Handling
import { HTMLessError } from '@htmless/core/sdk/client'; try { const article = await client.getEntry('article', 'nonexistent'); } catch (err) { if (err instanceof HTMLessError) { console.log(err.status); // 404 console.log(err.body); // raw error body } }
Next.js Integration
import { HTMLessClient } from '@htmless/core/sdk/client'; const cms = new HTMLessClient({ baseUrl: process.env.HTMLESS_URL!, spaceId: process.env.HTMLESS_SPACE_ID!, apiToken: process.env.HTMLESS_API_TOKEN!, }); export default async function ArticlesPage() { const { items } = await cms.getEntries('article', { sort: '-createdAt', limit: 20, }); return ( <ul> {items.map((a: any) => ( <li key={a.id}> <a href={`/articles/${a.slug}`}>{a.fields.title}</a> </li> ))} </ul> ); }
Self-Hosting
Run HTMLess on your own infrastructure with Docker Compose. PostgreSQL + Redis + API + Admin + Worker in one stack.
Docker Compose
services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: htmless POSTGRES_PASSWORD: ${DB_PASSWORD:-htmless_prod} POSTGRES_DB: htmless volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine restart: unless-stopped volumes: - redisdata:/data api: build: context: . dockerfile: Dockerfile.api restart: unless-stopped ports: - "${API_PORT:-3000}:3000" environment: DATABASE_URL: postgresql://htmless:${DB_PASSWORD}@postgres:5432/htmless REDIS_URL: redis://redis:6379 JWT_SECRET: ${JWT_SECRET} NODE_ENV: production worker: build: context: . dockerfile: Dockerfile.worker restart: unless-stopped environment: DATABASE_URL: postgresql://htmless:${DB_PASSWORD}@postgres:5432/htmless REDIS_URL: redis://redis:6379 admin: build: context: . dockerfile: Dockerfile.admin restart: unless-stopped ports: - "${ADMIN_PORT:-3001}:3001" volumes: pgdata: redisdata:
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | Yes | - | PostgreSQL connection string |
REDIS_URL | Yes | - | Redis connection string |
JWT_SECRET | Yes | - | Secret for signing JWT tokens |
DB_PASSWORD | Yes | htmless_prod | PostgreSQL password |
API_PORT | No | 3000 | Port for the API server |
ADMIN_PORT | No | 3001 | Port for the admin dashboard |
NODE_ENV | No | production | Runtime environment |
JWT_SECRET and DB_PASSWORD in production. The defaults are for development only.
Production Configuration
Create a .env file in your project root:
DB_PASSWORD=a-strong-random-password-here JWT_SECRET=another-strong-random-secret-here API_PORT=3000 ADMIN_PORT=3001
Reverse Proxy (Nginx)
server { listen 443 ssl http2; server_name cms.example.com; location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location / { proxy_pass http://127.0.0.1:3001; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }
Traefik Labels
If you use Traefik as your reverse proxy, add labels to the service definitions:
services: api: labels: - "traefik.enable=true" - "traefik.http.routers.htmless-api.rule=Host(`api.cms.example.com`)" - "traefik.http.routers.htmless-api.tls.certresolver=le" - "traefik.http.services.htmless-api.loadbalancer.server.port=3000"
CLI
The htmless CLI helps you scaffold projects, run migrations, seed data, start the dev server, and generate TypeScript types.
Installation
npm install -g @htmless/core
# or use npx
npx @htmless/core help
Commands
| Command | Description |
|---|---|
htmless init | Scaffold a new HTMLess project with Docker Compose and config |
htmless migrate | Run database migrations (Prisma under the hood) |
htmless seed | Seed the database with default roles, admin user, and core blocks |
htmless dev | Start the API + Admin in development mode with hot reload |
htmless codegen | Generate TypeScript types from your content type schemas |
htmless help | Show available commands and options |
htmless init
Creates a new project directory with all required files.
htmless init # Creates: # docker-compose.yml # .env.example # htmless.config.ts
htmless migrate
Runs pending database migrations. Requires DATABASE_URL to be set.
# Run all pending migrations htmless migrate # The command uses Prisma migrate deploy under the hood # Make sure DATABASE_URL is set in your environment
htmless seed
Seeds the database with default data: admin user, roles, and core block definitions.
htmless seed # Output: # Created default space # Created admin user (admin@htmless.com / admin123) # Created roles: admin, editor, author, viewer # Seeded 7 core block definitions
htmless dev
Starts the development server with hot reloading on the API and Admin UI.
htmless dev # Starts: # API server on http://localhost:3000 (with file watching) # Admin UI on http://localhost:3001 (Next.js dev)
htmless codegen
Generates TypeScript interfaces from your content type schemas. Run this after modifying your schema to keep types in sync.
htmless codegen # Generates: ./htmless.types.ts # Example output: # export interface Article { # title: string; # body: string; # author?: string; # heroImage?: string; # }
import type { Article } from './htmless.types'; import { HTMLessClient } from '@htmless/core/sdk/client'; const { items } = await client.getEntries<Article>('article'); // items is typed as Article[]