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 0.10.0). 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 — first with plain fetch, then with the official @lumibase/sdk.

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

You need: Node.js ≥ 20, 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 -F @lumibase/database 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), status (Select: draft / published, default draft).
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": "admin@example.com", "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": "admin@example.com" }
  }
}

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.


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 (Option A) — Fetch with plain fetch

No extra dependency. Replace app/page.tsx:

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

async function getPosts(): Promise<Post[]> {
  const url = new URL('/api/v1/items/posts', process.env.LUMIBASE_API_URL)
  // The `filter` param accepts two equivalent forms — pick either:
  //   (A) JSON string:
  url.searchParams.set('filter', JSON.stringify({ status: { _eq: 'published' } }))
  //   (B) Bracket form (handy for hand-written URLs):
  //   url.searchParams.set('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!,
    },
    next: { revalidate: 60 }, // ISR-style cache; use 'no-store' for always-fresh
  })

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

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

export default async function Home() {
  const posts = await getPosts()
  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) => (
          <li key={post.id} style={{ marginBottom: '1.5rem' }}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </main>
  )
}

Run it:

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

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

Step 6 (Option B) — Fetch with the SDK

The SDK removes the boilerplate (URL building, headers, filter encoding) and returns typed results.

bash
npm install @lumibase/sdk
ts
// lib/lumibase.ts
import { createClient } from '@lumibase/sdk'

export const lumibase = createClient({
  url: process.env.LUMIBASE_API_URL!,
  siteId: process.env.LUMIBASE_SITE_ID!,
  token: process.env.LUMIBASE_TOKEN!, // static API key — skips the login flow
})
tsx
// app/page.tsx
import { lumibase } from '@/lib/lumibase'

export default async function Home() {
  const posts = await lumibase.items('posts').readMany({
    filter: { status: { _eq: '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: any) => (
          <li key={post.id} style={{ marginBottom: '1.5rem' }}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </main>
  )
}

Same result, less code. For typed post.title instead of any, generate types from your schema — see SDK type generation.


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 SITE_NOT_FOUNDWrong/missing X-Lumi-SiteUse __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)

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 0.10.0)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/sdk createClient({ url, siteId, token }).items(c).readMany(...)

Next steps

Last modified: 23/07/2026