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.
| Thing | Why |
|---|---|
| Bun 1.3+ | Runs the server and bundles the admin. No build step for the API. |
| Postgres | The store, in development as well as production, so a dialect difference cannot wait until deploy day to appear. |
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
-
Install and configure
.env.exampledocuments 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 -
Point it at a database, and give it a secret
SECRETsigns 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)
-
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)
-
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.
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.
-
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). -
Give it fields
Add a
bodyrich text field, asummarytext field, and acovermedia field. There are 18 field types, including alistrepeater that nests and areferencethat points at entries of another type.Field keys are camelCase —
heroImage, nothero_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:
- The pre-edit state was snapshotted into revisions, so restoring later restores what it replaced.
- The entry was validated against the type's fields — a partial save never blanks a field the editor didn't render.
- The slug was checked for uniqueness within this type and locale, ignoring anything in the trash.
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.
-
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.
-
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.