🟡 Display LumiBase content in a Next.js app
Go from a clean machine to a Next.js page rendering content from LumiBase.
[!NOTE] Which LumiBase version is this for? Valid from LumiBase
0.9.0onward (last verified on0.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:
- Run LumiBase locally (CMS API + Studio).
- Complete the setup wizard and create a
postscollection with a few published items. - Mint a long-lived API key and find your
siteId. - 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 ≥ 22, pnpm ≥ 9, Docker + Docker Compose, Git.
How the pieces fit together
|
🖥️
Next.js applocalhost:3000frontend (your code) |
GET /api/v1/items/posts
➡️
Authorization: Bearer <token>
X-Lumi-Site: <siteId> ⬅️
{ "data": [ …posts ] }
|
🟡
LumiBaselocalhost:1989 · APIlocalhost:2026 · StudioPostgres · 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
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:
| Service | URL | What it is |
|---|---|---|
| 🔌 CMS API | http://localhost:1989 | The REST API your Next.js app calls |
| 🎛️ Studio | http://localhost:2026 | Admin 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:
- Create the first admin user (email + password — remember these).
- Set a site name and default language.
- 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 yoursiteIdfor everything below. (You can confirm it any time withGET /api/v1/site— see Step 4.)
Verify setup is complete:
curl http://localhost:1989/health
# → { ... "setup_complete": true }
Step 3 — Create a posts collection and add content
In Studio (http://localhost:2026):
| # | Action |
|---|---|
| 1 | Go to Collections → New Collection, name it posts. |
| 2 | Add fields: title (String), body (Text), status (Select: draft / published, default draft). |
| 3 | Save the collection. |
| 4 | Go to Content → posts → New Item. Create 2–3 items and set status = published. |
Prefer the API? Create the collection with
POST /api/v1/collectionsand items withPOST /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):
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):
{
"data": {
"token": "eyJhbGciOi...",
"user": { "id": "usr_...", "email": "admin@example.com" }
}
}
4b. Create a long-lived API key with that session token:
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):
{
"data": {
"id": "...",
"name": "nextjs-frontend",
"prefix": "lbk_",
"token": "lbk_live_xxxxxxxxxxxxxxxx"
}
}
4c. Confirm your siteId (optional sanity check):
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):
npx create-next-app@latest my-lumibase-frontend
cd my-lumibase-frontend
Accept the defaults (App Router, TypeScript). Create .env.local:
# .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:
// 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:
npm run dev # open http://localhost:3000
You should see your published posts. Here's roughly what renders:
Posts from LumiBaseHello, Edge 👋
My first post served from LumiBase.
Why a Content OS
Intent in, reconciled content out.
|
Step 6 (Option B) — Fetch with the SDK
The SDK removes the boilerplate (URL building, headers, filter encoding) and returns typed results.
npm install @lumibase/sdk
// 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
})
// 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
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized | Missing/invalid token | Re-check LUMIBASE_TOKEN; recreate the API key (Step 4b) |
423 SETUP_REQUIRED | Setup not finished | Complete http://localhost:1989/setup (Step 2) |
404 SITE_NOT_FOUND | Wrong/missing X-Lumi-Site | Use __default__ unless you created another site |
Empty data: [] | No published posts | Set items to published in Studio |
404 on items | Collection name mismatch | Collection must be named exactly posts |
| CORS error in browser | Fetching from client code | Fetch from a Server Component (as above) |
429 RATE_LIMITED | Too many requests from one key/IP | Back off; honour the Retry-After header (see below) |
503 RATE_LIMIT_UNAVAILABLE | Server'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 carriesRetry-After(seconds) andX-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.
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:
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 inrewritesvia 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 version | This tutorial | Notes |
|---|---|---|
| 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 covered | Earlier 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/:collectionfilter accepts bothfilter=<JSON>andfilter[field][_op]=valuebracket form (JSON wins if both sent);sort=<csv>GET /api/v1/sitereturns the active tenant; default id__default__@lumibase/sdkcreateClient({ url, siteId, token }).items(c).readMany(...)- Rate limiting returns
429 RATE_LIMITEDwithRetry-After; the "Production & security" section additionally covers503 RATE_LIMIT_UNAVAILABLEandDeprecation/Sunsetheaders, both added in0.24.0(the core flow above still works unchanged from0.9.0)
Next steps
- JavaScript SDK reference — auth, items, files, realtime, Flows.
- API specification — every endpoint, filters, pagination.
- Deployment overview — take this from localhost to dev / staging / production (Cloudflare or Docker).