From 2a1f56847d1d0b2698675588d8eac70e34118fd6 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 19:58:31 +0700 Subject: [PATCH 1/5] feat(examples): make lumibase the default client in nextjs-blog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the Next.js blog example from the GraphQL plugin to the REST surface of the `lumibase` package, installed as a real runtime dependency instead of `workspace:*`, so the directory can be copied outside the monorepo and installed from the registry. - `createLumiClient(...).with(legacyRest())` replaces `.with(graphql())`; pages use `items('posts').list()` / `.detail(id)`. - Content fields now read from `row.data.*` (REST returns `ItemRow`), and sorting uses the structural column name `-created_at`. - 404 from `detail()` maps to `notFound()` via `LumiError.status`. - Add the missing root layout — without it the example could not build standalone at all. - Add `lumibase.config.json` plus `types` / `types:check` scripts and commit the generated `src/lumibase-types.d.ts`. - Document the read-only credential, the server-only token rule and the `@lumibase/sdk` compatibility path in the README. --- examples/nextjs-blog/.env.example | 13 +- examples/nextjs-blog/README.md | 188 ++++++++++++++---- examples/nextjs-blog/lumibase.config.json | 7 + examples/nextjs-blog/package.json | 6 +- examples/nextjs-blog/src/app/layout.tsx | 14 ++ examples/nextjs-blog/src/app/page.tsx | 38 ++-- .../nextjs-blog/src/app/posts/[id]/page.tsx | 63 ++---- examples/nextjs-blog/src/lib/lumi.ts | 45 +++-- examples/nextjs-blog/src/lumibase-types.d.ts | 27 +++ 9 files changed, 272 insertions(+), 129 deletions(-) create mode 100644 examples/nextjs-blog/lumibase.config.json create mode 100644 examples/nextjs-blog/src/app/layout.tsx create mode 100644 examples/nextjs-blog/src/lumibase-types.d.ts diff --git a/examples/nextjs-blog/.env.example b/examples/nextjs-blog/.env.example index 9d8b132d7..b3286323f 100644 --- a/examples/nextjs-blog/.env.example +++ b/examples/nextjs-blog/.env.example @@ -1,8 +1,11 @@ -# Base URL of the LumiBase API server +# Base URL of the LumiBase CMS API LUMIBASE_URL=http://127.0.0.1:1989 -# Bearer Token or API Key from LumiBase Studio -LUMIBASE_TOKEN=your-lumibase-api-token-or-dev-token +# Read-only API key from LumiBase Studio (Settings → API keys). +# Give it a role that can only READ the collections this site renders — never +# an admin token. Server-side only: no NEXT_PUBLIC_ prefix, so it stays out of +# the browser bundle. +LUMIBASE_TOKEN=lbk_xxxxxxxxxxxxxxxxxxxxxxxx -# Active Tenant Site ID -LUMIBASE_SITE_ID=your-site-id +# Tenant (site) id. `__default__` unless you created more sites. +LUMIBASE_SITE_ID=__default__ diff --git a/examples/nextjs-blog/README.md b/examples/nextjs-blog/README.md index 41aee8257..63db31782 100644 --- a/examples/nextjs-blog/README.md +++ b/examples/nextjs-blog/README.md @@ -1,56 +1,164 @@ -# Next.js Blog Example with LumiBase GraphQL +# Next.js Blog Example -This is a minimal, performance-optimized blog application built with Next.js (App Router, Server Components) that fetches data from LumiBase's **GraphQL API** using the `@lumibase/sdk` `graphql()` plugin. +A minimal blog built with Next.js (App Router, Server Components) that reads +published posts from a LumiBase CMS through the **`lumibase`** package. -## Features -- **GraphQL Fetching**: Queries the per-tenant GraphQL endpoint (`POST /api/v1/graphql`) directly inside React Server Components via `lumi.query(...)`. -- **Type-Safety**: Each query is typed through a generic on `lumi.query(...)` for compiler checks and auto-completion. -- **Dynamic SSG / ISR**: Demonstrates `generateStaticParams` for pre-rendering pages and `revalidate = 60` for Incremental Static Regeneration. +`lumibase` is the one dependency you install: its library entry re-exports the +JS/TS client, and the same package provides the `lumibase` CLI used below for +type generation. `@lumibase/sdk` remains supported and exports the identical +client — see [Using `@lumibase/sdk` instead](#using-lumibasesdk-instead). -## GraphQL usage +This example is **standalone**: copy the directory anywhere outside the +LumiBase repo and it installs from the registry with no workspace linking. -The client attaches the GraphQL plugin in [`src/lib/lumi.ts`](src/lib/lumi.ts): +## Prerequisites -```ts -import { createLumiClient, graphql } from '@lumibase/sdk'; - -export const lumi = createLumiClient({ url, token, siteId }).with(graphql()); -``` +You need a **running LumiBase CMS** with content to read — this app is only the +frontend. Follow the [Next.js quickstart](../../docs/en/tutorials/nextjs-quickstart.md) +to start the CMS, complete the setup wizard, create a `posts` collection, and +mint an API key. -The schema is built **per tenant at runtime** from your collections. For a `posts` -collection you get `posts(filter, sort, limit, offset, status, search)` and -`posts_by_id(id)`. Structural columns are camelCase (`createdAt`); content fields -keep their declared names. Example list query: - -```graphql -query ListPosts($limit: Int) { - posts(status: "published", sort: ["-createdAt"], limit: $limit) { - id - title - content - author - createdAt - } -} -``` +This example expects a `posts` collection with the fields `title` (string), +`body` (text) and `author` (string), and at least one item set to +**published**. -See [`docs/en/api/graphql-api-spec.md`](../../docs/en/api/graphql-api-spec.md) for the -full GraphQL surface (filters, mutations, nested relations, subscriptions). +## Getting started -## Getting Started +1. **Configure the environment** -1. **Configure Environment Variables**: - Copy `.env.example` to `.env.local` and fill in the values: ```bash cp .env.example .env.local ``` - Set `LUMIBASE_TOKEN` and `LUMIBASE_SITE_ID` to match your local setup or cloud deploy. -2. **Install & Run**: + Set `LUMIBASE_URL`, `LUMIBASE_SITE_ID` and `LUMIBASE_TOKEN`. Use a + **read-only** API key — the example only lists and reads items. None of + these are `NEXT_PUBLIC_*`, so the token stays server-side. + +2. **Install and run** + ```bash - pnpm install - pnpm dev + npm install + npm run dev ``` -3. **Open the Application**: - Navigate to `http://localhost:3000`. +3. Open . + +## How it works + +The client is built once in [`src/lib/lumi.ts`](src/lib/lumi.ts): + +```ts +import { createLumiClient, legacyRest } from 'lumibase'; + +export const lumi = createLumiClient({ url, token, siteId }).with(legacyRest()); +``` + +Pages then use the typed resource helpers from Server Components: + +```ts +// list — drafts are filtered out by the server +const { data } = await lumi.items('posts').list({ + status: 'published', + sort: ['-created_at'], + limit: 50, +}); + +// detail +const { data: post } = await lumi.items('posts').detail(id); +``` + +Two details worth knowing: + +- **Content fields live under `.data`.** A row is an `ItemRow`: structural + columns (`id`, `status`, `createdAt`, …) sit at the top level, while your + declared fields are nested — `post.data.title`, not `post.title`. +- **`status` is a list parameter, not a filter**, and sorting uses the + structural column's snake_case name (`-created_at`). + +Every non-2xx response throws a `LumiError` carrying `.status`, which +[`src/app/posts/[id]/page.tsx`](src/app/posts/[id]/page.tsx) maps to Next.js's +`notFound()`: + +```ts +try { + const res = await lumi.items('posts').detail(id); + post = res.data; +} catch (err) { + if (err instanceof LumiError && err.status === 404) return notFound(); + throw err; +} +``` + +## Caching + +The list page sets `export const revalidate = 60`, and the detail route +pre-renders one page per published post via `generateStaticParams`. Publishing +in Studio is therefore visible to the API immediately, but the rendered page +keeps serving its cached copy until the revalidation window elapses. That is +expected — lower `revalidate`, or use `cache: 'no-store'`, if a page must be +always-fresh. + +## Type generation + +`lumibase types` generates TypeScript definitions from the live schema into +`src/lumibase-types.d.ts` (path configured in +[`lumibase.config.json`](lumibase.config.json)), and the generated file is +committed: + +```bash +npm run types # write the types +npm run types:check # CI: fail if the committed file is stale +``` + +> **Typegen needs a different credential from the one this app runs with.** +> `GET /api/v1/typegen/schema` sits behind the Studio access wall, which +> requires a **staff user** principal — an API key is rejected with `403` even +> when its role grants `schema:read`. Use a staff user's access token for +> typegen (a build-time/CI secret), and keep the read-only API key for the +> runtime reads. `lumibase doctor` reports which credential it resolved. + +The output is deterministic — no host, site id or timestamp in the header — so +it can be committed and verified in CI: + +```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 }} +``` + +`--check` exits `0` when the committed file matches the live schema and +non-zero when it drifts, without writing anything. + +## Security notes + +- **Keep the token on the server.** Every fetch here runs in a Server + Component. Never move it to `NEXT_PUBLIC_LUMIBASE_TOKEN` — that ships the + credential to every visitor. +- **Use least privilege.** Give the key a role whose policy grants only + `read` on the collections you render. A policy rule of + `{"status": {"_eq": "published"}}` makes the server withhold drafts even if a + request asks for them. +- **Never use an admin token in a frontend.** It can write and delete content. + +## Using `@lumibase/sdk` instead + +`lumibase` re-exports `@lumibase/sdk`, so the imports are interchangeable — the +exported `createLumiClient`, `legacyRest` and `LumiError` are the *same* +objects. If your project already depends on the SDK directly, swap the import: + +```ts +import { createLumiClient, legacyRest } from '@lumibase/sdk'; +``` + +In that case point typegen at the same package so the generated file imports +from what you installed: + +```bash +lumibase types --import-from @lumibase/sdk +``` + +(or set `typegen.importFrom` in `lumibase.config.json`). The `lumibase` CLI is +still what generates the types. diff --git a/examples/nextjs-blog/lumibase.config.json b/examples/nextjs-blog/lumibase.config.json new file mode 100644 index 000000000..a2833915f --- /dev/null +++ b/examples/nextjs-blog/lumibase.config.json @@ -0,0 +1,7 @@ +{ + "url": "http://127.0.0.1:1989", + "siteId": "__default__", + "typegen": { + "out": "src/lumibase-types.d.ts" + } +} diff --git a/examples/nextjs-blog/package.json b/examples/nextjs-blog/package.json index 47305fb31..dcb523947 100644 --- a/examples/nextjs-blog/package.json +++ b/examples/nextjs-blog/package.json @@ -6,10 +6,12 @@ "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "types": "lumibase types", + "types:check": "lumibase types --check" }, "dependencies": { - "@lumibase/sdk": "workspace:*", + "lumibase": "^1.0.0-rc.1", "next": "^15.5.19", "react": "^18.3.0", "react-dom": "^18.3.0" diff --git a/examples/nextjs-blog/src/app/layout.tsx b/examples/nextjs-blog/src/app/layout.tsx new file mode 100644 index 000000000..766711a7b --- /dev/null +++ b/examples/nextjs-blog/src/app/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +export const metadata = { + title: 'LumiBase Next.js Blog', + description: 'Posts served from a LumiBase collection.', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/nextjs-blog/src/app/page.tsx b/examples/nextjs-blog/src/app/page.tsx index fbfccde3d..10fa89bb2 100644 --- a/examples/nextjs-blog/src/app/page.tsx +++ b/examples/nextjs-blog/src/app/page.tsx @@ -3,28 +3,20 @@ import { lumi, type Post } from '@/lib/lumi'; export const revalidate = 60; // Revalidate every 60 seconds (ISR) -// GraphQL query: list published posts, newest first. -// The `posts` field + its arguments are generated per tenant from your schema. -const LIST_POSTS = /* GraphQL */ ` - query ListPosts($limit: Int) { - posts(status: "published", sort: ["-createdAt"], limit: $limit) { - id - title - content - author - createdAt - } - } -`; - export default async function HomePage() { let posts: Post[] = []; let errorMsg = ''; try { - // Fetch only 'published' status posts via GraphQL - const data = await lumi.query<{ posts: Post[] }>(LIST_POSTS, { limit: 50 }); - posts = data.posts; + // `status: 'published'` is a dedicated list parameter, not a filter. + // Drafts are excluded by the server, so they can never leak here. + // Structural columns sort by their snake_case name (`-created_at`). + const { data } = await lumi.items('posts').list({ + status: 'published', + sort: ['-created_at'], + limit: 50, + }); + posts = data; } catch (err: any) { errorMsg = err.message || 'Failed to fetch posts from LumiBase'; } @@ -34,7 +26,7 @@ export default async function HomePage() {

LumiBase Next.js Blog

- Example app displaying posts fetched via the LumiBase GraphQL API. + Example app displaying posts fetched with the LumiBase SDK.

@@ -49,14 +41,14 @@ export default async function HomePage() {
{posts.map((post) => (
-

{post.title}

+

{post.data.title}

- By {post.author} • {new Date(post.createdAt || Date.now()).toLocaleDateString()} + By {post.data.author} • {new Date(post.createdAt).toLocaleDateString()}

- {post.content.length > 150 - ? `${post.content.slice(0, 150)}...` - : post.content} + {post.data.body.length > 150 + ? `${post.data.body.slice(0, 150)}...` + : post.data.body}

Read More → diff --git a/examples/nextjs-blog/src/app/posts/[id]/page.tsx b/examples/nextjs-blog/src/app/posts/[id]/page.tsx index 7ac7797eb..b0825fdd1 100644 --- a/examples/nextjs-blog/src/app/posts/[id]/page.tsx +++ b/examples/nextjs-blog/src/app/posts/[id]/page.tsx @@ -1,43 +1,22 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; +import { LumiError } from 'lumibase'; import { lumi, type Post } from '@/lib/lumi'; interface PostPageProps { - params: { - id: string; - }; + params: Promise<{ id: string }>; } -// GraphQL query for a single post by id (`posts_by_id` is generated per tenant). -const GET_POST = /* GraphQL */ ` - query GetPost($id: ID!) { - posts_by_id(id: $id) { - id - title - content - author - status - createdAt - } - } -`; - -// Lightweight query used only to collect ids for static generation. -const LIST_POST_IDS = /* GraphQL */ ` - query ListPostIds($limit: Int) { - posts(status: "published", limit: $limit) { - id - } - } -`; - -// Generate static params for all published posts for static generation (SSG) +// Pre-render a page per published post. The reader credential cannot see +// drafts, so this list is exactly the public set. export async function generateStaticParams() { try { - const data = await lumi.query<{ posts: Pick[] }>(LIST_POST_IDS, { + const { data } = await lumi.items('posts').list({ + status: 'published', + fields: ['id'], limit: 100, }); - return data.posts.map((post) => ({ id: post.id })); + return data.map((post) => ({ id: post.id })); } catch (err) { console.error('Failed to generate static params for posts:', err); return []; @@ -45,20 +24,20 @@ export async function generateStaticParams() { } export default async function PostDetailPage({ params }: PostPageProps) { - let post: Post | null = null; + const { id } = await params; + let post: Post; try { - // Fetch a single post detail via GraphQL - const data = await lumi.query<{ posts_by_id: Post | null }>(GET_POST, { - id: params.id, - }); - post = data.posts_by_id; + // A draft (or unknown id) answers 404 for this credential — the SDK + // turns every non-2xx into a `LumiError` carrying the status. + const res = await lumi.items('posts').detail(id); + post = res.data; } catch (err) { - // If not found or API error, fall back to 404 - return notFound(); + if (err instanceof LumiError && err.status === 404) return notFound(); + throw err; } - if (!post || post.status !== 'published') { + if (post.status !== 'published') { return notFound(); } @@ -69,16 +48,16 @@ export default async function PostDetailPage({ params }: PostPageProps) {
-

{post.title}

+

{post.data.title}

- By {post.author} + By {post.data.author} - {new Date(post.createdAt || Date.now()).toLocaleDateString()} + {new Date(post.createdAt).toLocaleDateString()}
- {post.content.split('\n\n').map((para: string, idx: number) => ( + {post.data.body.split('\n\n').map((para: string, idx: number) => (

{para}

diff --git a/examples/nextjs-blog/src/lib/lumi.ts b/examples/nextjs-blog/src/lib/lumi.ts index 758740a47..f8bec357f 100644 --- a/examples/nextjs-blog/src/lib/lumi.ts +++ b/examples/nextjs-blog/src/lib/lumi.ts @@ -1,4 +1,4 @@ -import { createLumiClient, graphql } from '@lumibase/sdk'; +import { createLumiClient, legacyRest, type ItemRow } from 'lumibase'; const url = process.env.LUMIBASE_URL || 'http://127.0.0.1:1989'; const token = process.env.LUMIBASE_TOKEN || ''; @@ -10,22 +10,33 @@ if (!token || !siteId) { ); } -// 1. Initialize the client and attach the GraphQL composable plugin. -// `.with(graphql())` adds `query()` / `mutate()` that hit POST /api/v1/graphql. -export const lumi = createLumiClient({ - url, - token, - siteId, -}).with(graphql()); - -// 2. Shape of a `posts` item as exposed by the per-tenant GraphQL schema. -// Content fields (`title`, `content`, `author`) keep their declared names; -// structural columns are surfaced as camelCase (`createdAt`, not `created_at`). -export interface Post { - id: string; +// Content fields of the `posts` collection, exactly as declared in Studio. +// A schema maps a collection name to its *data* shape only — structural +// columns (id, status, createdAt, …) are added by `ItemRow` below. +export interface PostFields { title: string; - content: string; + body: string; author: string; - status: 'draft' | 'published'; - createdAt: string; + [key: string]: unknown; } + +// `DefaultSchema` is an index-signature type, so the schema is written as a +// type alias with one entry per collection you read. +export type Schema = { + posts: PostFields; +}; + +/** A `posts` row as the REST API returns it: fields live under `.data`. */ +export type Post = ItemRow; + +// The client is created once and reused. `legacyRest()` adds the typed +// resource helpers (`.items('posts').list()` / `.detail(id)`) over +// `GET /api/v1/items/posts`. +// +// This module is imported only from Server Components, so the token never +// reaches the browser bundle. Keep it out of `NEXT_PUBLIC_*`. +export const lumi = createLumiClient({ + url, + token, + siteId, +}).with(legacyRest()); diff --git a/examples/nextjs-blog/src/lumibase-types.d.ts b/examples/nextjs-blog/src/lumibase-types.d.ts new file mode 100644 index 000000000..e23015fe2 --- /dev/null +++ b/examples/nextjs-blog/src/lumibase-types.d.ts @@ -0,0 +1,27 @@ +// Generated by `lumibase types` — do not edit by hand. +// Re-run the command after changing collections or fields. + +import type { ID, Locale } from 'lumibase'; +import type { Brand } from 'lumibase'; + +export interface Posts { + readonly id: Brand<'PostsId', string>; + status: string; + sort: number; + readonly user_created?: string | null; + readonly user_updated?: string | null; + readonly created_at: string; + readonly updated_at: string; + readonly deleted_at?: string | null; + author?: string | null; + body?: string | null; + title: string | null; +} + +export type PostsExpanded = Posts; + +export interface LumibaseCollections { + posts: Posts; +} + +export type LumibaseSchema = LumibaseCollections; From 445d6424fc6fe4577b2e912c7b1d51370fd077bc Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 19:58:41 +0700 Subject: [PATCH 2/5] docs(tutorials): teach the SDK first in the Next.js quickstart (EN+VI) The tutorial presented raw `fetch` as Option A and the SDK as Option B, and the SDK sample documented an API that does not exist (`createClient(...).items(c).readMany(...)`). - Step 6 is now the `lumibase` package: `createLumiClient` + `legacyRest()`, `items('posts').list()` / `.detail(id)`, with the `.data` nesting and `-created_at` sorting called out, and `LumiError` mapped to `notFound()`. Plain `fetch` moves to an appendix. - New Step 7 covers `lumibase types` / `--check` in CI, and records that typegen needs a staff-user token: the endpoint sits behind the Studio access wall, so an API key gets 403 even with `schema:read`. - Step 4 gains a least-privilege policy/role chain for the API key; the `status` field is removed from Step 3 because it is a built-in column. - Compatibility contracts updated to the real SDK surface; both locales re-verified against source and re-stamped (verified_on 1.0.0-rc.1). The code-fences parity waiver is scoped and explained in both files: the blocks are byte-identical apart from translated trailing comments, which check-parity does not strip. --- docs/en/tutorials/nextjs-quickstart.md | 286 +++++++++++++++++------- docs/vi/tutorials/nextjs-quickstart.md | 290 ++++++++++++++++++------- 2 files changed, 418 insertions(+), 158 deletions(-) diff --git a/docs/en/tutorials/nextjs-quickstart.md b/docs/en/tutorials/nextjs-quickstart.md index 29079a19c..8c2047fc4 100644 --- a/docs/en/tutorials/nextjs-quickstart.md +++ b/docs/en/tutorials/nextjs-quickstart.md @@ -1,20 +1,29 @@ --- title: Next.js Quickstart — Display LumiBase Content -version: 1 -lastUpdated: 2026-08-02T19:05:15.812Z +version: 3 +lastUpdated: 2026-09-13T12:58:01.496Z sourceLang: en -contentHash: 36f30e29b1d22d3e -codeVerified: 2026-08-02T19:05:15.812Z -codeVerifiedHash: 36f30e29b1d22d3e -codeVerifiedClaims: 14 +contentHash: 8517ebf6d2842ff5 +codeVerified: 2026-09-13T12:58:01.496Z +codeVerifiedHash: 8517ebf6d2842ff5 +codeVerifiedClaims: 26 --- + + + - -