LumiBaseDocs

🟡 Display LumiBase content in a Next.js app

Go from a clean machine to a Next.js page rendering content from LumiBase.

LumiBase version Level Time Stack

[!NOTE] Which LumiBase version is this for? Valid from LumiBase 0.9.0 onward (last verified on 1.0.0-rc.1). It stays valid for any newer release until one of the API contracts in the Compatibility table changes — see that section to pick the right version, with the newest on top.

You will:

  1. Run LumiBase locally (CMS API + Studio).
  2. Complete the setup wizard and create a posts collection with a few published items.
  3. Mint a long-lived API key and find your siteId.
  4. Build a tiny Next.js app that reads those posts with the official lumibase package (plain fetch is shown afterwards as an alternative).

By the end you'll have a working http://localhost:3000 page listing posts that live in LumiBase.

You need: Node.js ≥ 22, pnpm ≥ 9, Docker + Docker Compose, Git.


How the pieces fit together

🖥️
Next.js app
localhost:3000
frontend (your code)
GET /api/v1/items/posts
➡️
Authorization: Bearer <token>
X-Lumi-Site: <siteId>
⬅️
{ "data": [ …posts ] }
🟡
LumiBase
localhost:1989 · API
localhost:2026 · Studio
Postgres · Redis · …

LumiBase is the headless backend (API + admin Studio). Next.js is just a client that calls the Delivery API over HTTP. Every request carries two things: a bearer token (who you are) and an X-Lumi-Site header (which tenant/site you're reading from).


Step 1 — Run LumiBase locally

bash
git clone https://github.com/khuepm/lumibase.git
cd lumibase

pnpm install

# Backing services: PostgreSQL, Redis, MeiliSearch, Logto
docker compose -f docker/docker-compose.yml up -d

# Database migrations
pnpm db:migrate

# Start CMS API (:1989) + Studio (:2026)
pnpm dev

When pnpm dev is running you should have:

ServiceURLWhat it is
🔌 CMS APIhttp://localhost:1989The REST API your Next.js app calls
🎛️ Studiohttp://localhost:2026Admin UI to model & edit content

See Local Development for the full service list and troubleshooting.


Step 2 — Complete the setup wizard

On first run the database is empty, so the CMS activates a setup wizard. Open http://localhost:1989/setup and:

  1. Create the first admin user (email + password — remember these).
  2. Set a site name and default language.
  3. Finish. The response includes one-time backup codes — store them somewhere safe.

[!IMPORTANT] The setup wizard creates a default site with the id __default__. That is your siteId for everything below. (You can confirm it any time with GET /api/v1/site — see Step 4.)

Verify setup is complete:

bash
curl http://localhost:1989/health
# → { ... "setup_complete": true }

Step 3 — Create a posts collection and add content

In Studio (http://localhost:2026):

#Action
1Go to Collections → New Collection, name it posts.
2Add fields: title (String), body (Text). Do not add a status field — every item already has a built-in status column that the publish workflow drives.
3Save the collection.
4Go to Content → posts → New Item. Create 2–3 items and set status = published.

Prefer the API? Create the collection with POST /api/v1/collections and items with POST /api/v1/items/posts. See the API spec.


Step 4 — Get an API key and confirm your siteId

Your Next.js app authenticates with a bearer token. For a real integration you want a long-lived API key, not the short login token.

4a. Log in to get a session token (used only to create the API key):

bash
curl -X POST http://localhost:1989/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -H "X-Lumi-Site: __default__" \
  -d '{ "email": "[email protected]", "password": "your-password" }'

Response (note: the field is token, single token — there's no separate access_token/refresh_token in this version):

json
{
  "data": {
    "token": "eyJhbGciOi...",
    "user": { "id": "usr_...", "email": "[email protected]" }
  }
}

4b. Create a long-lived API key with that session token:

bash
curl -X POST http://localhost:1989/api/v1/api-keys \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token-from-4a>" \
  -H "X-Lumi-Site: __default__" \
  -d '{ "name": "nextjs-frontend" }'

Response — copy the token (it starts with lbk_ and is shown only once):

json
{
  "data": {
    "id": "...",
    "name": "nextjs-frontend",
    "prefix": "lbk_",
    "token": "lbk_live_xxxxxxxxxxxxxxxx"
  }
}

4c. Confirm your siteId (optional sanity check):

bash
curl http://localhost:1989/api/v1/site \
  -H "Authorization: Bearer lbk_live_xxxxxxxxxxxxxxxx" \
  -H "X-Lumi-Site: __default__"
# → { "data": { "id": "__default__", "name": "My Site", ... } }

[!TIP] Keep the lbk_… key server-side only — never ship it to the browser. We use it from a Next.js Server Component below, so it never leaves your server.

4d. Give the key least privilege. A fresh key carries no permissions. Rather than attaching the Administrator role, create a policy that can only read posts, restrict it to published items, and attach it through a role:

bash
# A policy whose single rule is "read published posts"
curl -X POST http://localhost:1989/api/v1/policies \
  -H "Content-Type: application/json" -H "Authorization: Bearer <token-from-4a>" \
  -H "X-Lumi-Site: __default__" \
  -d '{ "name": "Blog read-only" }'

curl -X POST http://localhost:1989/api/v1/policies/<policy-id>/permissions \
  -H "Content-Type: application/json" -H "Authorization: Bearer <token-from-4a>" \
  -H "X-Lumi-Site: __default__" \
  -d '{ "collection": "posts", "action": "read",
        "permissions": { "status": { "_eq": "published" } } }'

# A role that carries the policy, then attach the role to the key
curl -X POST http://localhost:1989/api/v1/roles \
  -H "Content-Type: application/json" -H "Authorization: Bearer <token-from-4a>" \
  -H "X-Lumi-Site: __default__" -d '{ "name": "Blog Reader" }'

curl -X POST http://localhost:1989/api/v1/roles/<role-id>/policies \
  -H "Content-Type: application/json" -H "Authorization: Bearer <token-from-4a>" \
  -H "X-Lumi-Site: __default__" -d '{ "policyId": "<policy-id>" }'

curl -X POST http://localhost:1989/api/v1/api-keys/<key-id>/roles \
  -H "Content-Type: application/json" -H "Authorization: Bearer <token-from-4a>" \
  -H "X-Lumi-Site: __default__" -d '{ "roleId": "<role-id>" }'

The rule is enforced server-side: a request from this key that omits status=published, or asks for a draft by id, still gets back only published items (404 for the draft). A write attempt answers 403.


Step 5 — Create the Next.js app

In a separate directory (outside the LumiBase repo):

bash
npx create-next-app@latest my-lumibase-frontend
cd my-lumibase-frontend

Accept the defaults (App Router, TypeScript). Create .env.local:

bash
# .env.local — server-side only, NOT prefixed with NEXT_PUBLIC_
LUMIBASE_API_URL=http://localhost:1989
LUMIBASE_SITE_ID=__default__
LUMIBASE_TOKEN=lbk_live_xxxxxxxxxxxxxxxx

We call LumiBase from a Server Component, so the token stays on the server and there is no CORS to configure. This is the recommended pattern for production too.


Step 6 — Fetch with the SDK

Install lumibase. One package gives you both the client you import at runtime and the lumibase CLI used in Step 7:

bash
npm install lumibase

Create the client once. It is imported only from Server Components, so the token never reaches the browser:

ts
// lib/lumibase.ts
import { createLumiClient, legacyRest, type ItemRow } from 'lumibase'

// Your collection's content fields, as declared in Studio.
export interface PostFields {
  title: string
  body: string
  [key: string]: unknown
}

export type Post = ItemRow<PostFields>

export const lumibase = createLumiClient<{ posts: PostFields }>({
  url: process.env.LUMIBASE_API_URL!,
  siteId: process.env.LUMIBASE_SITE_ID!,
  // static API key — skips the login flow
  token: process.env.LUMIBASE_TOKEN!,
}).with(legacyRest())
tsx
// app/page.tsx
import { lumibase, type Post } from '@/lib/lumibase'

export default async function Home() {
  // `status` is a dedicated list parameter, not a filter. Sorting uses the
  // structural column's snake_case name.
  const { data: posts } = await lumibase.items('posts').list({
    status: 'published',
    sort: ['-created_at'],
    limit: 20,
  })

  return (
    <main style={{ maxWidth: 640, margin: '2rem auto', fontFamily: 'system-ui' }}>
      <h1>Posts from LumiBase</h1>
      {posts.length === 0 && <p>No published posts yet.</p>}
      <ul>
        {posts.map((post: Post) => (
          <li key={post.id} style={{ marginBottom: '1.5rem' }}>
            <h2>{post.data.title}</h2>
            <p>{post.data.body}</p>
          </li>
        ))}
      </ul>
    </main>
  )
}

[!IMPORTANT] Content fields live under .data. A row is an ItemRow: structural columns (id, status, createdAt, …) are at the top level, while the fields you declared are nested — post.data.title, not post.title.

Run it:

bash
# open http://localhost:3000
npm run dev

You should see your published posts. Here's roughly what renders:

Posts from LumiBase

Hello, Edge 👋
My first post served from LumiBase.
Why a Content OS
Intent in, reconciled content out.
Illustration of the rendered page (not a live screenshot).

Reading a single post works the same way. Every non-2xx answer throws a LumiError carrying the status, which maps cleanly onto notFound():

tsx
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'
import { LumiError } from 'lumibase'
import { lumibase, type Post } from '@/lib/lumibase'

// Required. Without it this route is cached indefinitely and edits made in
// Studio never reach the page.
export const revalidate = 60

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  let post: Post

  try {
    const res = await lumibase.items('posts').detail(id)
    post = res.data
  } catch (err) {
    if (err instanceof LumiError && err.status === 404) return notFound()
    throw err
  }

  return (
    <article>
      <h1>{post.data.title}</h1>
      <p>{post.data.body}</p>
    </article>
  )
}

A draft the credential cannot see answers 404 — the same path as an unknown id — so a draft that was never published has no route into these pages at all.

[!IMPORTANT] Set revalidate on every cached route, not just the list. A route with generateStaticParams but no revalidate is rendered once at build time and then cached forever, so edits made in Studio never appear on it. A post published after the build is still served: dynamicParams defaults to true, so Next renders it on demand the first time it is requested.

[!WARNING] Unpublishing is not the same as never having published, and this setup has no hard takedown guarantee. A draft that never went live cannot appear — the API answers 404 and no page was ever generated. But a post that was live and is then unpublished stays readable from the cache, because ISR serves the stale HTML and revalidates in the background. One measurement, against a live CMS with revalidate = 60: unpublishing a post whose page had just been regenerated, the API stopped serving it to the reader credential immediately, while the detail and list pages both kept serving it for 64 s. Editing a post already past its window showed in 3–4 s, and a post published after the build was reachable at its URL immediately (the list took ~60 s to include it). Read revalidate as how often a page may go looking for fresh data, not as an upper bound on how long stale content can be served. Three things stretch the window past it, and none of them are errors: the first request after expiry is answered from the stale cache by design, a page nobody requests is never revalidated at all, and a failed regeneration keeps the previous HTML in place. The 64 s above is one path — traffic present, regeneration successful — not a ceiling. So if your content has a takedown requirement, do not rely on this default. Lower revalidate, use cache: 'no-store' on the routes that must never serve withdrawn content, or trigger on-demand revalidation from a LumiBase webhook when an item leaves published — that last one is the only option here that acts on the change rather than waiting for a clock.

Already depend on @lumibase/sdk? It exports the identical client — lumibase simply re-exports it so one name covers both the client and the CLI. Swap from 'lumibase' for from '@lumibase/sdk' and everything above works unchanged.


Step 7 — Generate types in CI

lumibase types turns the live schema into TypeScript definitions. Commit the output and let CI fail when it drifts from the schema.

Create lumibase.config.json next to package.json (it is meant to be committed — it holds no secret):

json
{
  "url": "http://localhost:1989",
  "siteId": "__default__",
  "typegen": { "out": "src/lumibase-types.d.ts" }
}
bash
# write src/lumibase-types.d.ts — commit it
npx lumibase types
# exits non-zero if the file is stale
npx lumibase types --check
# show the resolved config and probe connectivity
npx lumibase doctor

[!IMPORTANT] Typegen needs a staff user token, not an API key. GET /api/v1/typegen/schema sits behind the Studio access wall, which requires a user principal. An API key is rejected with 403 even when its role grants schema:read. Use a staff user's access token as a build-time secret for typegen, and keep the read-only API key from Step 4 for the runtime reads your pages do.

The generated file is deterministic — no host, site id or timestamp in its header — so the same committed file verifies against any instance:

yaml
- run: npm ci
- run: npx lumibase types --check
  env:
    LUMIBASE_URL: ${{ secrets.LUMIBASE_URL }}
    LUMIBASE_SITE_ID: ${{ secrets.LUMIBASE_SITE_ID }}
    LUMIBASE_TOKEN: ${{ secrets.LUMIBASE_TYPEGEN_TOKEN }}

Alternative — the same read with plain fetch

The SDK only wraps the HTTP API; nothing stops you calling it directly if you would rather not add a dependency. You give up typed results and typegen, and you rebuild the URL, headers and filter encoding yourself:

tsx
// app/page.tsx
type Post = { id: string; status: string; data: { title: string; body: string } }

async function getPosts(): Promise<Post[]> {
  const url = new URL('/api/v1/items/posts', process.env.LUMIBASE_API_URL)
  url.searchParams.set('status', 'published')
  // The `filter` param accepts two equivalent forms — pick either:
  //   (A) JSON string:       filter={"status":{"_eq":"published"}}
  //   (B) Bracket form:      filter[status][_eq]=published
  url.searchParams.set('sort', '-created_at')

  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${process.env.LUMIBASE_TOKEN}`,
      'X-Lumi-Site': process.env.LUMIBASE_SITE_ID!,
    },
    // ISR-style cache; use 'no-store' for always-fresh
    next: { revalidate: 60 },
  })

  if (!res.ok) throw new Error(`LumiBase responded ${res.status}: ${await res.text()}`)

  const json = (await res.json()) as { data: Post[] }
  return json.data
}

Troubleshooting

SymptomLikely causeFix
401 UnauthorizedMissing/invalid tokenRe-check LUMIBASE_TOKEN; recreate the API key (Step 4b)
423 SETUP_REQUIREDSetup not finishedComplete http://localhost:1989/setup (Step 2)
404 TENANT_NOT_FOUNDWrong X-Lumi-Site — the header is well-formed but no such site existsUse __default__ unless you created another site
Empty data: []No published postsSet items to published in Studio
404 on itemsCollection name mismatchCollection must be named exactly posts
CORS error in browserFetching from client codeFetch from a Server Component (as above)
429 RATE_LIMITEDToo many requests from one key/IPBack off; honour the Retry-After header (see below)
503 RATE_LIMIT_UNAVAILABLEServer's rate-limit cache is down (fail-closed deployments)Transient — retry with backoff; not your app's fault

Production & security notes for frontends

The happy path above works on localhost. Before you ship, wire in the contracts a LumiBase client is expected to respect. These matter more once your frontend is a public deployment (Next.js on Vercel/Cloudflare, etc.).

1. Keep the API key on the server — always

The lbk_… key is a bearer credential. Read it only in Server Components, Route Handlers, or Server Actions — never in a 'use client' component and never behind a NEXT_PUBLIC_ env var (those are inlined into the browser bundle). If the browser genuinely needs data, proxy it through your own Route Handler so the key stays server-side.

2. Handle rate limiting (429) and, if fail-closed, 503

LumiBase throttles per principal/IP and per site. Two responses your fetch layer should handle:

  • 429 RATE_LIMITED — you're over the window. The response carries Retry-After (seconds) and X-RateLimit-Reset. Back off; don't hammer.
  • 503 RATE_LIMIT_UNAVAILABLE (LumiBase ≥ 0.24.0) — only in deployments that run the limiter fail-closed (LUMIBASE_RATE_LIMIT_FAIL_CLOSED=true): the limiter's cache is momentarily down. It's transient and not your app's fault — retry with backoff.
ts
async function lumibaseFetch(url: string | URL, init?: RequestInit, attempt = 0): Promise<Response> {
  const res = await fetch(url, init)
  if ((res.status === 429 || res.status === 503) && attempt < 3) {
    const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** attempt
    await new Promise((r) => setTimeout(r, retryAfter * 1000))
    return lumibaseFetch(url, init, attempt + 1)
  }
  return res
}

3. Watch for Deprecation / Sunset headers (LumiBase ≥ 0.24.0)

A retiring endpoint returns RFC 8594 headers: Deprecation, Sunset (a date), and a Link rel="deprecation" to the changelog. Log them in your client so an endpoint doesn't disappear on you:

ts
if (res.headers.get('Deprecation')) {
  console.warn('[LumiBase] deprecated endpoint; sunset:', res.headers.get('Sunset'))
}

4. Calling from the browser? Configure CORS deliberately

The Server-Component pattern above needs no CORS. If you must call the API from client code, the CMS only allows exact-match origins listed in CORS_ALLOWED_ORIGINS — a credentialed response is never returned for a wildcard *. Add your frontend origin explicitly (e.g. https://app.example.com), and remember client calls expose whatever token they carry, so use a short-lived/narrow-scoped token, not the lbk_… key.

5. /test-auth is a dev-only playground

The interactive auth page at /test-auth is developer tooling. From LumiBase ≥ 0.24.0 it returns 404 in production — don't build anything that depends on it being reachable on a production host.

6. Keep Next.js patched — SSRF advisories

Frontend framework hygiene is part of your API's attack surface. Recent Next.js releases fixed server-side request forgery issues:

  • GHSA-89xv-2m56-2m9x — SSRF in Server Actions on custom servers.
  • GHSA-p9j2-gv94-2wf4 — SSRF in rewrites via an attacker-controlled destination host.

Use next ≥ 16.2.11, and never build a rewrites/Server-Action destination from unvalidated user input (a user-supplied hostname or full URL). If you must fetch a user-provided URL server-side, validate it against an allowlist and block private/metadata IP ranges — the same discipline LumiBase applies in its own SSRF guard.


Compatibility

This tutorial is pinned to a minimum LumiBase version and only re-verified when an API contract it relies on actually changes. Pick the row matching your LumiBase version (newest on top):

LumiBase versionThis tutorialNotes
0.9.0 → latest✅ This page (verified on 1.0.0-rc.1)Login returns { data: { token } }; API keys via POST /api/v1/api-keys (lbk_ prefix); items filter accepts JSON and bracket form; default site __default__.
< 0.9.0⚠️ Not coveredEarlier releases predate the contracts above. Upgrade to ≥ 0.9.0, or adapt the auth/filter calls to your version.

Contracts this tutorial depends on (if any of these change in a future release, bump the table above and re-verify — see DoD §5):

  • POST /api/v1/auth/login → { data: { token, user } }
  • POST /api/v1/api-keys → { data: { token: "lbk_…" } }
  • GET /api/v1/items/:collection filter accepts both filter=<JSON> and filter[field][_op]=value bracket form (JSON wins if both sent); sort=<csv>
  • GET /api/v1/site returns the active tenant; default id __default__
  • lumibase (re-exports @lumibase/sdk) — createLumiClient({ url, siteId, token }).with(legacyRest()).items(c).list(...) / .detail(id); rows are ItemRow with content fields under .data
  • lumibase types / types --check read GET /api/v1/typegen/schema, which requires a staff user principal (an API key gets 403)
  • Rate limiting returns 429 RATE_LIMITED with Retry-After; the "Production & security" section additionally covers 503 RATE_LIMIT_UNAVAILABLE and Deprecation/Sunset headers, both added in 0.24.0 (the core flow above still works unchanged from 0.9.0)

Next steps

Last modified: 26/09/2026