@writbee/next
v0.2.0
Published
Writbee for Next.js: cached server reads, a browser client, and a build-time guard around the secret key.
Readme
@writbee/next
Writbee for Next.js. Two entry points, drawn along the line that matters: the server reads with your secret key through Next's cache, the browser reads with a publishable key. Mixing them up fails the build instead of leaking the key.
npm install @writbee/nextServer
# .env.local
WRITBEE_API_URL=https://api.writbee.com/api
WRITBEE_SECRET_KEY=sk_...// app/blog/page.tsx
import { createServerClient } from "@writbee/next"
export default async function BlogPage() {
const posts = await createServerClient().getPosts()
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
)
}// app/blog/[slug]/page.tsx
import { createServerClient } from "@writbee/next"
import { notFound } from "next/navigation"
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await createServerClient().findPost(slug)
if (!post) notFound()
return <article dangerouslySetInnerHTML={{ __html: post.content ?? "" }} />
}getPosts() returns the first page — 20 posts, or up to 100 with
{ limit }. For a sitemap or anything that needs the whole archive, walk the
cursor with allPosts(), which holds one page at a time:
// app/sitemap.ts
import { createServerClient } from "@writbee/next"
import type { MetadataRoute } from "next"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const entries: MetadataRoute.Sitemap = []
for await (const post of createServerClient().allPosts({ limit: 100 })) {
entries.push({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.published_at,
})
}
return entries
}listPosts({ limit, cursor }) is the same thing one page at a time, returning
{ posts, nextCursor } — see the @writbee/core README for how cursors behave.
Responses are cached for 60 seconds by default and tagged writbee:posts. Pass
revalidate and extra tags to change that, or baseUrl / apiKey to bypass
the environment:
createServerClient({ revalidate: 3600, tags: ["blog"] })
createServerClient({ revalidate: false }) // cached until the tag is revalidatedRevalidating on publish
revalidate: false plus a webhook is the setup worth having: pages stay static
until a post actually changes.
// app/api/writbee/revalidate/route.ts
import { revalidateWritbee } from "@writbee/next"
export async function POST(request: Request) {
if (request.headers.get("x-writbee-secret") !== process.env.WRITBEE_WEBHOOK_SECRET) {
return new Response("Unauthorized", { status: 401 })
}
revalidateWritbee()
return Response.json({ revalidated: true })
}Browser
The key and URL are passed in rather than read from the environment, on purpose:
Next inlines NEXT_PUBLIC_* variables into your app's own code at build time,
and that does not extend to a package inside node_modules. Read them in your
app, where it works, and hand them over.
"use client"
import { createBrowserClient, type Post } from "@writbee/next/client"
import { useEffect, useState } from "react"
export function LatestPosts() {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
const writbee = createBrowserClient({
baseUrl: process.env.NEXT_PUBLIC_WRITBEE_API_URL!,
apiKey: process.env.NEXT_PUBLIC_WRITBEE_PUBLISHABLE_KEY!,
})
const controller = new AbortController()
writbee
.getPosts({ signal: controller.signal })
.then(setPosts)
.catch(() => {})
return () => controller.abort()
}, [])
return <ul>{posts.map((p) => <li key={p.slug}>{p.title}</li>)}</ul>
}Create the client inside the component rather than at module scope. A client
component's module is also evaluated while Next prerenders on the server, so a
missing NEXT_PUBLIC_ variable would fail the build there — far from where you
would look for it.
Only a publishable (pk_) key belongs here, and it must list your domain in the
dashboard — the API enforces that per key, through CORS. There is no caching on
this side, so prefer the server entry whenever the data can be read on the
server.
This package exports functions, not components, so it carries no "use client"
directive: call it from your own client component and it works.
The guards
@writbee/next imports server-only and @writbee/next/client imports
client-only. Import the wrong one and the build fails with a clear
message, instead of your sk_ key quietly reaching the browser. That is the
main thing this package adds over calling @writbee/core yourself.
Errors, methods and the Post type are re-exported from
@writbee/core — see its README for the error codes.
Notes
- Requires Next 14 or newer (App Router), and is ESM only.
sideEffects: falseis deliberately absent: it would let a bundler dropimport "server-only", removing the guard exactly where it is needed.
