Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 218 additions & 80 deletions docs/en/tutorials/nextjs-quickstart.md

Large diffs are not rendered by default.

300 changes: 219 additions & 81 deletions docs/vi/tutorials/nextjs-quickstart.md

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions examples/nextjs-blog/.env.example
Original file line number Diff line number Diff line change
@@ -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__
194 changes: 154 additions & 40 deletions examples/nextjs-blog/README.md
Original file line number Diff line number Diff line change
@@ -1,56 +1,170 @@
# 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<T>(...)` 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';
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.

export const lumi = createLumiClient({ url, token, siteId }).with(graphql());
```
This example expects a `posts` collection with the fields `title` (string),
`body` (text) and `author` (string), and at least one item set to
**published**.

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
}
}
```
## Getting started

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).
1. **Configure the environment**

## Getting Started

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 <http://localhost:3000>.

## 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<Schema>({ 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

**Both** routes set `export const revalidate = 60`. The list page needs it, and
so does the detail page — `generateStaticParams` on its own renders each post
once at build time and then caches it forever, so edits made in Studio would
never appear. Declaring `revalidate` is what makes the detail page refresh too.

Publishing or editing in Studio is visible to the API immediately, while the
rendered pages keep serving their cached copy until the window elapses. Lower
`revalidate`, or use `cache: 'no-store'`, if a page must be always-fresh.

A post published *after* the build is not in `generateStaticParams`. The detail
route leaves `dynamicParams` at its default (`true`), so Next renders that post
on demand the first time it is requested and caches it like the rest.

## 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.
7 changes: 7 additions & 0 deletions examples/nextjs-blog/lumibase.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"url": "http://127.0.0.1:1989",
"siteId": "__default__",
"typegen": {
"out": "src/lumibase-types.d.ts"
}
}
6 changes: 4 additions & 2 deletions examples/nextjs-blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions examples/nextjs-blog/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body style={{ margin: 0, backgroundColor: '#fff' }}>{children}</body>
</html>
);
}
38 changes: 15 additions & 23 deletions examples/nextjs-blog/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand All @@ -34,7 +26,7 @@ export default async function HomePage() {
<header style={styles.header}>
<h1 style={styles.title}>LumiBase Next.js Blog</h1>
<p style={styles.subtitle}>
Example app displaying posts fetched via the LumiBase GraphQL API.
Example app displaying posts fetched with the LumiBase SDK.
</p>
</header>

Expand All @@ -49,14 +41,14 @@ export default async function HomePage() {
<div style={styles.grid}>
{posts.map((post) => (
<article key={post.id} style={styles.card}>
<h2 style={styles.cardTitle}>{post.title}</h2>
<h2 style={styles.cardTitle}>{post.data.title}</h2>
<p style={styles.cardMeta}>
By {post.author} • {new Date(post.createdAt || Date.now()).toLocaleDateString()}
By {post.data.author} • {new Date(post.createdAt).toLocaleDateString()}
</p>
<p style={styles.cardExcerpt}>
{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}
</p>
<Link href={`/posts/${post.id}`} style={styles.cardLink}>
Read More →
Expand Down
Loading