Tutorials

Build something

Five walkthroughs, each one finished and running by the end. Start with the blog if you have not used Inkling before — the rest assume it.

A blog, end to end

A model, some writing, and a site rendering it. Assumes you have Inkling running from Get set up.

  1. Model the post

    Types → New type, named Post, kind collection. Give it these fields:

    KeyTypeWhy
    summarytextThe card on your index page, and the meta description.
    bodyrichtextThe writing. Stores portable, cleaned HTML.
    covermediaComes back expanded, with dimensions, so you can lay it out without measuring.
    relatedreference → postLinks to other posts, checked against the target type on save.
  2. Add a category taxonomy

    Categories → New taxonomy, named category, hierarchical if you want nesting. Terms are attached to entries from the editor, and come back on delivery when you ask for them.

  3. Tell it where the post lives

    On the type, set the preview URL to https://yoursite.com/blog/{slug}. Now the admin's "view" button opens the real page instead of guessing.

  4. Write two posts and publish one

    Leave the second as a draft — you want something that should not appear when you read the API in a moment.

  5. Render the index

    Mint a key under API keys, scope it to post, and read the list.

    const res = await fetch(`${process.env.INKLING_URL}/content/post?include=terms&limit=10`, {
      headers: { "x-api-key": process.env.INKLING_KEY },
    })
    const { data: posts } = await res.json()
    
    return posts.map(post => `
      <article>
        <img src="${post.data.cover.url}" alt="${post.data.cover.alt}" width="${post.data.cover.width}">
        <h2><a href="/blog/${post.slug}">${post.title}</a></h2>
        <p>${post.data.summary}</p>
      </article>`).join("")

    Only the published post comes back. The draft is not filtered out on your side — it never left.

  6. Render one post

    GET /content/post/my-first-post

    related arrives as full entries rather than ids, each one re-checked for publication and against your key's scopes. A draft you linked to simply is not in the array.

  7. Stop rebuilding on a timer

    Subscribe to content:post and revalidate the one page that changed. See Stay in sync for the socket handshake.

Write a plugin

A plugin is a plain object in a directory — no class to extend, no lifecycle to inherit. This one adds a reading time to every post the delivery API returns, computed at read time rather than stored, so it can never go stale against the writing.

  1. Make the directory

    The directory name and the plugin's name must match.

    mkdir -p plugins/readingtime
  2. Write it

    plugins/readingtime/index.ts. The delivery.entry filter runs on every entry on its way out, which is where a derived value belongs.

    import { definePlugin } from "../../src/plugins/define.ts"
    
    // Words per minute is a setting rather than a constant because the right
    // number is editorial, not technical.
    const words = (html: string): number =>
      html.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean).length
    
    export default definePlugin({
      name: "readingtime",
      version: "1.0.0",
      label: "Reading time",
      description: "Adds an estimated reading time to delivered entries.",
    
      settings: [
        { key: "wpm", label: "Words per minute", type: "number", default: 220 },
        { key: "field", label: "Field to measure", type: "text", default: "body" },
      ],
    
      panels: [{ id: "readingtime", label: "Reading time", kind: "settings" }],
    
      register: ctx => {
        ctx.filter("delivery.entry", async ({ payload, type, raw }) => {
          const field = await ctx.getSetting("field", "body")
          const wpm = await ctx.getSetting("wpm", 220)
          const source = (payload.data as Record<string, unknown>)?.[field]
    
          if (typeof source !== "string") return { payload, type, raw }
    
          return {
            payload: { ...payload, readingMinutes: Math.max(1, Math.round(words(source) / wpm)) },
            type,
            raw,
          }
        })
      },
    })
  3. Turn it on

    Plugins in the admin, then enable it. Nothing restarts — plugin routes and hooks resolve per request, so it is live on the next call.

    curl http://localhost:4300/content/post/hello -H "x-api-key: ink_…"
    
    { "slug": "hello", "readingMinutes": 4, … }
A filter cannot break a save

If your filter throws, its input carries forward untouched and the response still goes out. That is the deal: filter hooks transform and degrade to a no-op, emit hooks observe and have their failures isolated. Neither can fail the core path.

Going further

A plugin can also declare its own content types (marked as owned by it, so disabling retires them), its own taxonomies and settings, routes under /ext/<name>/…, and its own database tables through plugin-scoped migrations in plugins/<name>/migrations/. Read the seven bundled plugins — each one demonstrates a different extension point.

Three sites at once

Inkling is single-tenant. Core settings live under one site scope, menu names are globally unique, and PUBLIC_URL is one origin per process — so separate sites means a database each, not one instance with a tenant column.

  1. A database per site

    They can share one Postgres server. What they cannot share is a schema.

    createdb inkling_alpha
    createdb inkling_beta
    createdb inkling_gamma
  2. A configuration per site

    Each gets its own SECRET, so rotating one invalidates that site's sessions and stored credentials and stops there.

    # alpha.env
    DATABASE_URL=postgres://…/inkling_alpha
    PUBLIC_URL=https://cms.alpha.com
    PORT=4300
    SECRET=
    
    # beta.env
    DATABASE_URL=postgres://…/inkling_beta
    PUBLIC_URL=https://cms.beta.com
    PORT=4301
    SECRET=
  3. A process per site

    bun --env-file=alpha.env src/start.ts
    bun --env-file=beta.env  src/start.ts
    bun --env-file=gamma.env src/start.ts

    Media can share a bucket — object keys are dated and randomized, so two uploads of logo.png never collide, whatever site they came from.

When one instance is right

If the three "sites" are really one property — the same editorial team, one set of settings, one menu namespace — run a single instance and give each site a key scoped to the types it renders.

→ alpha's key: scopes ["post", "page"]
→ beta's key:  scopes ["product", "page"]

Each key sees only its own types, including through reference expansion. What they still share is settings, menus, media URLs, and the user list — so this is the right shape only when sharing those is what you wanted.

Mount it inside a site

A site that would rather not deploy a second service can run Inkling in its own process. It hands back a handler rather than owning a port.

import { createInkling } from "inkling"

const inkling = await createInkling({ adminBase: "/admin", siteKeyName: "site" })

Bun.serve({
  fetch: async (request, server) => {
    // Answer the upgrade before anything returns a Response, or the
    // handshake is gone.
    if (request.headers.get("upgrade") === "websocket") {
      if (inkling.upgrade(request, server)) return undefined as unknown as Response
    }
    // null means no Inkling route claimed the path — keep routing.
    return (await inkling.fetch(request, server)) ?? myRouter(request)
  },
  websocket: inkling.websocket,
})
OptionWhat it does
adminBase Confines the admin to a prefix. At "/" every unmatched path becomes the admin; anywhere else, fetch returns null off it so your own routes still run.
siteKeyName Mints a delivery key for the site sharing the process — it has no browser in which to visit the admin and copy one. Derived from SECRET, so it is the same key on every boot.
Pass server through

It is where the real socket peer comes from, and rate-limit buckets and audit rows key on it. Drop it and every request looks like it came from the same nowhere — which turns the per-IP login limit into one global bucket shared by every account.

Connect an assistant

Optional, and absent from the admin until you connect a provider. Nothing to set in the environment for the ordinary path.

  1. Paste a key

    Settings → AI, choose a provider, paste the key. It is sealed with AES-GCM under a key derived from your SECRET, kept in its own table rather than in settings, and never returned by the API — only the last four characters, so you can tell two keys apart.

  2. Use it while writing

    The assistant appears on fields in the editor: draft, rewrite, shorten, expand, summarize, titles, seo, translate, ask. Each one is handed your content model and the entry, so it answers about your site.

  3. Ask Inky for a page

    Inky is in the bottom-right corner of every admin screen, and it knows which screen you are on. Ask it for something you do not have yet:

    you
    We need a page about our return policy.
    
    inky
    You have a Page type with a body and a summary — I've drafted
    one and left it unpublished so you can read it first.
    
    + Page · "Returns" · draft
      summary  Our 30-day policy, in short.
      body     <p>If something isn't right…</p>
                                        [ Apply ]  [ Discard ]

    It reads your types, entries, media, menus, and site details before it answers, then proposes. You get a diff. Applying it is an ordinary save, so it lands in the entry's history under your name and can be restored like any other edit.

    The same conversation can reshape a type ("add a place for customer quotes"), publish or unpublish something, edit your navigation, or change your site title — Inky works out which of those you meant.

  4. Or connect over OAuth instead

    Register a client with the provider against your callback URL, then set its id. The admin offers "Continue with …" only for providers that have one, because a client is registered with the provider and cannot be pasted into a form.

    # redirect URI to register: PUBLIC_URL + /ai/oauth/callback
    AI_OAUTH_ANTHROPIC_CLIENT_ID=
    AI_OAUTH_ANTHROPIC_CLIENT_SECRET=
  5. Answer your visitors, if you want to

    Enable the assistant plugin. It answers from published content only, grounded in the page the reader is on. It is a plugin rather than core because it is the one AI surface that spends your money for anonymous visitors — so it should have a switch.

    It reuses the provider you connected in step one. Write your house rules into Guardrails — this is where you say what it must never promise, and when to hand someone to a human:

    # Plugins → Site assistant → Guardrails
    You answer questions about Ash & Ember, a ceramics studio in Columbia.
    Never quote a price or a lead time — those change weekly.
    For custom commissions, point people at the contact page.
    If you don't know, say so and offer the contact page.

    Then turn on Show a bubble on the public site, list the origins allowed to embed it — with none listed it answers nobody — and add one line to your layout:

    <script src="https://cms.yoursite.com/ext/assistant/widget.js" defer></script>

    That is the whole integration — a bubble in the corner, drawn inside a shadow root so it cannot collide with your CSS and your CSS cannot reach it. If you would rather draw your own, POST /ext/assistant/public-ask is the same answer as JSON.