Getting started

Get set up

From an empty database to a real site reading live content. Twenty minutes, and nothing to deploy twice — Inkling is one process on one port.

What you need

Bun, and a Postgres you can point at. Bun is the runtime, the package manager, and the bundler — there is no node or npm step anywhere in this project.

ThingWhy
Bun 1.3+Runs the server and bundles the admin. No build step for the API.
PostgresThe store, in development as well as production, so a dialect difference cannot wait until deploy day to appear.
Note

The test suite runs on in-memory SQLite, so bun test needs no database of its own. That is the only supported use of SQLite here — run Postgres for anything you intend to keep.

Get it running

  1. Install and configure

    .env.example documents every variable in place, and is the only place they are documented. Copy it and read it once.

    git clone https://github.com/wess/inkling
    cd inkling
    bun install
    cp .env.example .env
  2. Point it at a database, and give it a secret

    SECRET signs sessions and seals stored credentials. In production Inkling refuses to boot if it is still the default or shorter than 32 characters — so set a real one now and save yourself the surprise later.

    # .env
    DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/inkling
    PUBLIC_URL=http://localhost:4300
    SECRET=$(openssl rand -hex 32)
  3. Start it

    Migrations run on boot, so there is no separate step. The admin, the API, your media, and the socket all answer on one port.

    bun run dev
    
    migrated: 00000001_users, 00000002_content, …
    plugins: 5 enabled of 7 installed
    inkling on http://localhost:4300 (postgres)
  4. Claim the owner account

    Open localhost:4300. The first visit asks you to create the owner; after that the same screen becomes the normal sign-in, and the setup route closes permanently. There is no public signup.

Port 4300, not 4000

Docker Desktop binds *:4000 on IPv6, which wins localhost resolution and will silently answer requests meant for Inkling. A 403 with an XML body is the tell.

Build a model

A content type is a shape you define — what a post, a product, or a landing page is made of. Everything else follows from it: the editor screens, what the delivery API returns, and what gets validated on save.

  1. Add the type

    In the admin, go to Types → New type. Call it Post, leave the kind as collection (many entries, as opposed to single — a homepage, your site hours).

  2. Give it fields

    Add a body rich text field, a summary text field, and a cover media field. There are 18 field types, including a list repeater that nests and a reference that points at entries of another type.

    Field keys are camelCase — heroImage, not hero_image. They live inside a JSON document and never become database columns.

Write something

Go to Content → Post → New, write a paragraph, drop an image into cover, and publish it. A few things just happened that are worth knowing about:

Read it from a site

Your website reads content with an API key over the delivery API. Keys are stored only as a hash, so the plaintext is shown exactly once — copy it when you mint it.

  1. Mint a key

    API keys → New key. Leave the scopes empty to allow every type, or name the ones this site should see. A key can also carry an expiration.

  2. Ask for the content

    Media and reference fields come back expanded, so rendering a page takes one request rather than one per image.

    curl http://localhost:4300/content/post \
      -H "x-api-key: ink_…"
    
    {
      "data": [{
        "slug": "hello",
        "title": "Hello",
        "publishedAt": "2026-08-05T09:12:04Z",
        "data": {
          "summary": "A first post.",
          "cover": { "url": "…/cover.jpg", "alt": "…", "width": 1600 }
        }
      }],
      "meta": { "type": "post", "total": 1, "page": 1, "limit": 20 }
    }

A delivery key sees published content and nothing else — never a draft, never a user's email, never a deleted row. That holds through reference expansion too: a linked entry is re-checked for publication and against the key's own scopes before it is included.

Useful from here: ?include=terms attaches taxonomy terms, ?term=news filters by one, and GET /content lists the types a key may read along with their field shapes, so a consumer can discover the model.

Stay in sync

Rather than polling on a timer, hold a socket. Exchange your key for a short-lived ticket, connect, and subscribe to the types you render.

const { ticket } = await fetch("http://localhost:4300/realtime/delivery/ticket", {
  method: "POST",
  headers: { "x-api-key": process.env.INKLING_KEY },
}).then(r => r.json())

const socket = new WebSocket(`ws://localhost:4300/realtime?ticket=${ticket}`)

socket.onopen = () => socket.send(JSON.stringify({ action: "subscribe", topic: "content:post" }))
socket.onmessage = event => {
  const { event: name, data } = JSON.parse(event.data)
  if (name === "entry.published") revalidate(`/posts/${data.slug}`)
}

Frames carry the id, slug, and type — never the content itself. Re-read the entry through /content when one arrives, so the boundary is enforced on the way out.

Where next