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.

Terminal
# 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
Default credentials
Email: 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.

Terminal
# 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.

Terminal (cURL)
# 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

PackageDescription
packages/coreAPI server, database, auth, CLI, SDK, webhooks, workers
packages/adminNext.js admin dashboard
packages/workerBackground 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

GET /cma/v1/schemas/types List all content types
GET /cma/v1/schemas/types/:key Get a content type
POST /cma/v1/schemas/types Create content type
PATCH /cma/v1/schemas/types/:key Update content type
POST /cma/v1/schemas/types/:key/publish Publish schema version
POST /cma/v1/schemas/types/:key/fields Add a field
DELETE /cma/v1/schemas/types/:key/fields/:fieldKey Remove a field

Entry Endpoints

GET /cma/v1/entries List entries (with filters)
GET /cma/v1/entries/:id Get entry with versions
POST /cma/v1/entries Create entry
POST /cma/v1/entries/:id:saveDraft Save draft version
POST /cma/v1/entries/:id:publish Publish entry
POST /cma/v1/entries/:id:unpublish Unpublish entry
POST /cma/v1/entries/:id:schedule Schedule publishing
POST /cma/v1/entries/:id:revert Revert to prior version
GET /cma/v1/entries/:id/versions List all versions
DELETE /cma/v1/entries/:id Delete entry

Example: Create an Entry

cURL
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

JSON
{
  "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.

GET /cda/v1/content/:typeKey List published entries
GET /cda/v1/content/:typeKey/:id Get single published entry
GET /cda/v1/schemas/types List content types
GET /cda/v1/assets/:id Get asset metadata
GET /cda/v1/blocks/definitions List block definitions
GET /cda/v1/patterns List block patterns
GET /cda/v1/taxonomies/:key/terms List taxonomy terms

Query Parameters

ParamDescriptionExample
slugFilter by slug?slug=hello-world
localeLocale for localized fields?locale=fr
fieldsResponse projection?fields=title,slug
includeExpand references?include=author
sortSort order?sort=-createdAt
pagePage number?page=2
limitItems per page (max 100)?limit=25

Example: Fetch Articles

cURL
curl -s http://localhost:3000/cda/v1/content/article?limit=10&sort=-createdAt&fields=title,slug,createdAt \
  -H "X-Space-Id: YOUR_SPACE_ID"
Response
{
  "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.

GET /preview/v1/content/:typeKey List entries (draft-preferred)
GET /preview/v1/content/:typeKey/:id Get entry (draft-preferred)
GET /preview/v1/schemas/types List schemas (include unpublished)
GET /preview/v1/assets/:id Get asset (include private)
cURL
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.

Example
# 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
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
curl -X POST http://localhost:3000/cma/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@htmless.com",
    "password": "admin123"
  }'
Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": "usr_abc123",
    "email": "admin@htmless.com",
    "name": "Admin",
    "role": "admin"
  }
}

Include the token in subsequent requests:

Header
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

API Tokens

API tokens are long-lived, scoped credentials for machine-to-machine access. Created by admins through the CMA.

Create an API Token
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"
  }'
Response
{
  "id": "tok_a1b2c3d4",
  "name": "Next.js Frontend",
  "token": "htk_live_xxxxxxxxxxxxxxxxxxxx",
  "scopes": ["cda:read"],
  "expiresAt": "2026-07-02T00:00:00.000Z"
}
Security note
The 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.

Create a Preview Token
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

RoleScopesDescription
adminAllFull access to everything
editorcma:read cma:writeCreate, edit, publish content
authorcma:read cma:write:ownEdit own content only
viewercma:readRead-only CMA access

Required Headers

HeaderRequiredDescription
X-Space-IdAlwaysIdentifies the space (tenant)
AuthorizationCMA, PreviewBearer token (JWT or API token)
Content-TypePOST/PATCHMust be application/json
If-MatchUpdatesETag 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.

Create a Content Type
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

TypeDescriptionExample Value
textPlain text string"Hello World"
richtextRich text with formatting"<p>Hello</p>"
numberInteger or decimal42
booleanTrue/false toggletrue
dateISO 8601 date/datetime"2026-04-03"
mediaReference to an asset"asset_abc123"
referenceReference to another entry"entry_xyz789"
jsonArbitrary JSON data{"key": "value"}
slugURL-safe identifier"hello-world"
enumPredefined value set"published"

Add Fields to a Type

cURL
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).

Create a Taxonomy
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.

Localized Entry
// 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
  }
}
Fetch in French
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.

Publish Schema
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: draftpublishedarchived. 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.

Save a Draft
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..."
    }
  }'
Publish
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.

List Versions
curl http://localhost:3000/cma/v1/entries/ENTRY_ID/versions \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Space-Id: YOUR_SPACE_ID"
Response
{
  "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" }
  ]
}
Revert to Version 1
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.

Schedule Publishing
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.

Next.js Preview Route
// 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.

BlockKeyAttributes
Paragraphparagraphtext
Headingheadinglevel, text
ImageimageassetId, alt, caption
Calloutcallouttone, title, body
Embedembedurl
Listlistordered, items
Codecodelanguage, code

Block Instance Format

JSON
{
  "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.

Register a Block Definition
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.

Create a Pattern
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.

TypeScript / React
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

TypeScript
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 (multipart)
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"
Response
{
  "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.

Transform URL Pattern
https://assets.htmle.ss/{assetId}/{filename}?w=800&h=450&fit=crop&fm=webp&q=75
ParamDescriptionValues
wWidth in pixels1-4000
hHeight in pixels1-4000
fitResize behaviorcover, contain, crop, fill
fmOutput formatwebp, avif, jpg, png
qQuality1-100

Update Asset Metadata

cURL
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
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
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
  }'
Response
{
  "id": "wh_a1b2c3d4",
  "url": "https://myapp.com/api/webhooks/htmless",
  "events": ["entry.published", "entry.unpublished", "asset.created"],
  "active": true,
  "signingSecret": "whsec_xxxxxxxxxxxxxxxx"
}

Available Events

EventTriggered When
entry.createdNew entry is created
entry.updatedDraft is saved
entry.publishedEntry is published
entry.unpublishedEntry is unpublished
entry.deletedEntry is deleted
asset.createdNew asset uploaded
asset.deletedAsset is deleted
schema.typePublishedContent 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

HeaderDescription
X-HTMLess-Event-IdUnique event identifier
X-HTMLess-TimestampISO 8601 timestamp of delivery
X-HTMLess-SignatureHMAC-SHA256 signature (sha256=...)
Node.js Verification
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:

Delivery Logs

Every delivery attempt is logged. View them in the admin dashboard or via API.

cURL
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

POST /graphql Execute a query
GET /graphql/schema Introspection

Query Syntax

cURL
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 } } }"
  }'
Response
{
  "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
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
curl http://localhost:3000/graphql/schema \
  -H "X-Space-Id: YOUR_SPACE_ID"
Response
{
  "schema": {
    "types": {
      "article": {
        "fields": {
          "title": { "type": "text", "required": true },
          "body": { "type": "richtext", "required": false },
          "heroImage": { "type": "media", "required": false }
        }
      }
    }
  }
}

JavaScript Example

TypeScript
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

Terminal
npm install @htmless/core
# or
pnpm add @htmless/core

Setup

TypeScript
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

TypeScript
// 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

TypeScript
// 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

TypeScript
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

TypeScript
// Fetch draft content for preview
const draft = await client.getPreview<Article>(
  'article',
  'hello-world',
  'PREVIEW_TOKEN'
);

Error Handling

TypeScript
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

app/articles/page.tsx
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

docker-compose.yml
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

VariableRequiredDefaultDescription
DATABASE_URLYes-PostgreSQL connection string
REDIS_URLYes-Redis connection string
JWT_SECRETYes-Secret for signing JWT tokens
DB_PASSWORDYeshtmless_prodPostgreSQL password
API_PORTNo3000Port for the API server
ADMIN_PORTNo3001Port for the admin dashboard
NODE_ENVNoproductionRuntime environment
Production security
Always set a strong, unique JWT_SECRET and DB_PASSWORD in production. The defaults are for development only.

Production Configuration

Create a .env file in your project root:

.env
DB_PASSWORD=a-strong-random-password-here
JWT_SECRET=another-strong-random-secret-here
API_PORT=3000
ADMIN_PORT=3001

Reverse Proxy (Nginx)

nginx.conf
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:

docker-compose.override.yml
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

Terminal
npm install -g @htmless/core
# or use npx
npx @htmless/core help

Commands

CommandDescription
htmless initScaffold a new HTMLess project with Docker Compose and config
htmless migrateRun database migrations (Prisma under the hood)
htmless seedSeed the database with default roles, admin user, and core blocks
htmless devStart the API + Admin in development mode with hot reload
htmless codegenGenerate TypeScript types from your content type schemas
htmless helpShow available commands and options

htmless init

Creates a new project directory with all required files.

Terminal
htmless init

# Creates:
#   docker-compose.yml
#   .env.example
#   htmless.config.ts

htmless migrate

Runs pending database migrations. Requires DATABASE_URL to be set.

Terminal
# 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.

Terminal
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.

Terminal
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.

Terminal
htmless codegen

# Generates: ./htmless.types.ts
# Example output:
# export interface Article {
#   title: string;
#   body: string;
#   author?: string;
#   heroImage?: string;
# }
Use generated types
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[]