Set up your Next.js blog
Connect a Next.js App Router site to Ballad's headless content API: routes, block rendering, SEO metadata, and refresh-on-publish.
Ballad writes your long-form content and serves it as structured data. Your site fetches it and renders it on your domain, at your URLs — so the search equity compounds for you, not for us, and the content stays yours if you ever walk away.
This guide wires a Next.js App Router site up to that API: a blog index, post pages, SEO metadata, and automatic refresh when Ballad publishes. There is a package that does all of it, and the long way if you'd rather own the code.
You don't have to wire anything up. A collection can publish by hand instead — Ballad hands you the finished article as rich text, Markdown or HTML with its opening image, you paste it into whatever your site runs on, and you give Ballad the live URL so signals attach to it. That's the default until a site is connected, and it's the right answer if your CMS is its own world. The rest of this guide is for the headless route.
Before you start
You'll need a Next.js app on the App Router, and a content API key — mint one in Ballad under Settings → Site → Content API key. It's shown once, so copy it straight into your environment.
Put both values in .env.local (and in your host's environment):
BALLAD_API_URL=https://app.balladlabs.com
BALLAD_CONTENT_KEY=blc_your_key_here
If you deploy on Vercel, adding an environment variable does not affect deployments that already exist — the values are captured at build time. After adding these, trigger a redeploy or they won't be visible.
The fast path
@balladlabs/next is the whole of this guide as a package: the client, the
block renderer, the index and post pages, the feed, the sitemap entries and the
revalidate handler. It's what runs this site's own blog.
npm install @balladlabs/next
Wire it up once:
// lib/ballad.ts
import { createBallad } from '@balladlabs/next';
import { createBlogPages } from '@balladlabs/next/pages';
export const ballad = createBallad({ siteUrl: process.env.SITE_URL });
export const blog = createBlogPages(ballad, {
basePath: '/blog',
publisher: { '@type': 'Organization', name: 'Your Company' },
});
Then each route is a re-export:
// app/blog/page.tsx
export const generateMetadata = blog.index.generateMetadata;
export default blog.index.Page;
// app/blog/[slug]/page.tsx
export const generateStaticParams = blog.post.generateStaticParams;
export const generateMetadata = blog.post.generateMetadata;
export default blog.post.Page;
// app/blog/rss.xml/route.ts
export const GET = blog.rss.GET;
// app/api/revalidate/route.ts
export const POST = blog.revalidate.POST;
And in app/sitemap.ts, fold the posts in beside your own routes:
return [...yourRoutes, ...(await blog.sitemap())];
More than one lane? A changelog, an engineering blog and a founder
journal are just collections. Call createBlogPages again with the other
slug and its own path, and point a second set of routes at it:
export const changelog = createBlogPages(ballad, {
basePath: '/changelog',
collection: 'changelog',
});
import '@balladlabs/next/styles.css' gives you a plain, readable default.
To keep your own design, pass components for individual blocks and markdown
elements, layout to wrap either page, or render.index / render.post
to draw the pages yourself from the loaded data while the package keeps the
metadata, static params, feed and revalidation. Or skip the pages entirely and
use the client — ballad.posts(), ballad.post(slug) — with <Article>
from @balladlabs/next/react.
When the API fails, it throws. A build fails loudly and your last good
deploy stays up; a regeneration keeps the page it already had rather than
replacing it with an empty one; the feed answers 503. That is almost always
what you want, and errors: 'empty' opts out if it isn't. It needs Next 15 or
newer and React 18 or newer, and it's ESM only.
Or build it yourself
The rest of this guide is the same thing by hand. Read it if you want to own the code, if you're on something other than Next, or if you just want to know what the package is doing on your behalf. The API section below is worth reading either way.
The API
Two endpoints, both authenticated with Authorization: Bearer <key>.
GET /api/content/collections/{slug} returns a collection and a page of its
published posts:
{
"slug": "blog",
"name": "Ballad Blog",
"theme": "AI & Inbound Marketing Blog",
"items": [
{
"slug": "a-post",
"title": "A post",
"shape": "comparison",
"tier": "cadence",
"publishedAt": "2026-09-04T16:42:21.248Z",
"excerpt": "Short summary for cards and feeds…",
"author": "Your Name",
"url": "https://your-site.com/blog/a-post",
"image": "https://app.balladlabs.com/api/content/assets/...",
"artImage": "https://app.balladlabs.com/api/content/assets/..."
}
],
"nextCursor": "opaque-string-or-null"
}
GET /api/content/items/{slug} returns one post: ordered blocks plus a
seo object (title, description, canonical, ogImage, jsonLd).
shape is what kind of article it is — essay, comparison,
guide, case_study, data, or release for a changelog entry — on
every summary and every item. Label
it on a card only when it isn't an essay: most posts are essays, and
labelling the ordinary case tells a reader nothing. Treat an unfamiliar value
as no label rather than printing the raw slug, so a shape added later doesn't
show up as case_study in your design.
tier is deprecated. It is still sent, and every new post is
cadence; older rows may carry signature. It marked a distinction Ballad
no longer makes — don't render it.
Pagination is not optional
This endpoint is always paginated. A call with no parameters returns only
the most recent page (20 posts) — not everything. Use ?limit= (1–100) for a
bigger page, and follow nextCursor until it comes back null for the
complete set.
This matters more than it looks. Anything that must be exhaustive — your sitemap, your prerendered route list, a full archive index — will silently truncate the day you publish your 21st post, with no error to tell you. Decide per surface:
| Surface | What it needs |
|---|---|
Sitemap, generateStaticParams, archive index | every post — follow the cursor |
| RSS feed, "latest posts" widgets | one capped page is enough |
A typed client
// src/lib/ballad-content.ts
const PAGE_SIZE = 100; // the API's maximum
const MAX_PAGES = 25; // so a bad cursor can never spin forever
export function getContentClient() {
const baseUrl = process.env.BALLAD_API_URL;
const apiKey = process.env.BALLAD_CONTENT_KEY;
// Returning null rather than throwing keeps builds and preview deploys
// working before the key exists — the blog just renders an empty state.
if (!baseUrl || !apiKey) return null;
async function get<T>(path: string): Promise<T | null> {
const res = await fetch(baseUrl + path, {
headers: { Authorization: 'Bearer ' + apiKey, Accept: 'application/json' },
next: { revalidate: 300 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error('Ballad content API ' + res.status);
return (await res.json()) as T;
}
function getCollection(slug: string, o: { limit?: number; cursor?: string } = {}) {
const qs = new URLSearchParams();
if (o.limit !== undefined) qs.set('limit', String(o.limit));
if (o.cursor) qs.set('cursor', o.cursor);
const q = qs.toString();
return get<CollectionResponse>('/api/content/collections/' + slug + (q ? '?' + q : ''));
}
async function getAllCollectionItems(slug: string) {
const first = await getCollection(slug, { limit: PAGE_SIZE });
if (!first) return null;
const items = [...first.items];
const seen = new Set<string>();
let cursor = first.nextCursor ?? null;
for (let page = 1; cursor && page < MAX_PAGES; page++) {
if (seen.has(cursor)) break; // a repeating cursor is a bug, not a loop
seen.add(cursor);
const next = await getCollection(slug, { limit: PAGE_SIZE, cursor });
if (!next) break;
items.push(...next.items);
cursor = next.nextCursor ?? null;
}
return { ...first, items, nextCursor: null };
}
return {
getCollection,
getAllCollectionItems,
getItem: (slug: string) => get<ContentItemResponse>('/api/content/items/' + slug),
};
}
Rendering blocks
A post isn't one blob of HTML — it's an ordered list of typed blocks, each
with a type, a position, and a payload:
| Type | Payload |
|---|---|
prose | { markdown } — GitHub-flavoured markdown |
hero_image | { url, alt, width, height }, plus artUrl on brand-card heroes |
pull_quote | { text } |
cta | { text } |
code_embed | { code, lang, caption } |
A brand-card hero comes in two renders: url has the post's title set into
the card, and artUrl is the same card without it (artImage is the
matching field on a collection summary). Show artUrl / artImage wherever
your own layout already prints the title as text — a post page under its h1,
a listing card above its caption — and keep url for og:image, where the
title has to be part of the picture. Both are optional: other hero styles don't
send them, and a post whose card hasn't been re-rendered yet won't either, so
read them as artUrl ?? url and nothing breaks.
Sort by position and render each by type:
export function Blocks({ blocks }: { blocks: ContentBlock[] }) {
return [...blocks]
.sort((a, b) => a.position - b.position)
.map((block) => <Block key={block.position} block={block} />);
}
Two things worth getting right:
Skip unknown block types instead of throwing. If Ballad ships a new block
type, an exhaustive switch that throws takes your whole blog down. Returning
null for anything unrecognised means a new block renders as nothing until
you add support for it — degraded, not broken.
Don't trust the markdown as HTML. It arrives over the network. Render it
with something that escapes raw HTML by default —
react-markdown does, unless you
explicitly add rehype-raw. Don't pipe it into dangerouslySetInnerHTML.
Links
Markdown can't express a link target, so decide in the renderer: internal
cross-links stay in the tab, external citations open in a new one with
rel="noopener noreferrer".
Careful with two cases a naive check gets wrong. Absolute URLs pointing at
your own domain are internal, not external — Ballad emits those in url and
seo.canonical. And //host/path is protocol-relative: it starts with a
slash but is not site-relative, so a startsWith('/') test will wrongly
treat it as internal.
The routes
// app/blog/page.tsx — the index
export const revalidate = 300;
export default async function BlogIndex() {
const client = getContentClient();
const feed = client ? await client.getAllCollectionItems('blog') : null;
const items = feed?.items ?? [];
// …render cards, or an empty state when items is empty
}
// app/blog/[slug]/page.tsx — a post
export const revalidate = 300;
export async function generateStaticParams() {
const client = getContentClient();
if (!client) return []; // build must not fail without a key
const feed = await client.getAllCollectionItems('blog');
return (feed?.items ?? []).map((i) => ({ slug: i.slug }));
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // params is a Promise in recent Next
const client = getContentClient();
const post = client ? await client.getItem(slug) : null;
if (!post) notFound();
// …render post.blocks
}
Leave dynamicParams at its default (true). That's what lets a post
published after your last build render on demand, instead of 404ing until you
redeploy.
SEO
Ballad hands you a seo object per post. Map it onto Next's metadata:
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getContentClient()?.getItem(slug);
if (!post) return {};
const { seo } = post;
return {
title: seo.title,
description: seo.description,
alternates: { canonical: seo.canonical },
openGraph: { type: 'article', title: seo.title, description: seo.description,
url: seo.canonical, images: seo.ogImage ? [seo.ogImage] : undefined },
};
}
Inject seo.jsonLd as an application/ld+json script. It describes the
article — headline, dates, author, image. Two fields are yours, not Ballad's,
because they describe the site doing the publishing: add publisher (an
Organization with a logo) and inLanguage yourself.
Pick one canonical host and make everything agree. If www is canonical,
then your metadataBase, sitemap, feed URLs and Ballad's configured Site URL
must all say www. Mixing the apex and www splits your ranking signals
across two hosts, which is the exact thing this architecture exists to avoid.
Refresh on publish
Ballad publishes on its own schedule, so give it a way to refresh your site without a deploy. Add an endpoint and set the shared secret in both places — your environment, and Ballad under Settings → Site.
// app/api/revalidate/route.ts
import { timingSafeEqual } from 'node:crypto';
import { revalidatePath } from 'next/cache';
export async function POST(request: Request) {
const expected = process.env.BALLAD_REVALIDATE_SECRET;
if (!expected) return Response.json({ error: 'not configured' }, { status: 503 });
const body = await request.json().catch(() => null);
if (!body) return Response.json({ error: 'invalid json' }, { status: 400 });
// Compare in constant time; bail on length mismatch first, since
// timingSafeEqual throws on differing lengths.
const a = Buffer.from(String(body.secret ?? ''));
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
revalidatePath('/blog'); // the index, or the new post
if (body.slug) revalidatePath('/blog/' + body.slug); // is never listed
return Response.json({ ok: true });
}
Ballad POSTs { secret, collection, slug } on publish.
Revalidate the index as well as the post. Refreshing only the post page is the most common mistake here — the new article is live at its URL, but it never appears in your list. Refresh your feed route too, if you have one.
One limitation to know: revalidatePath invalidates pages, layouts and route
handlers — not metadata routes. Calling it on /sitemap.xml generated by
sitemap.ts does nothing at all. Let its own revalidate window handle it;
crawlers don't need the file to be second-fresh.
A few things that will bite you
blog.sitemap()emits your index as well as your posts. Ifsitemap.tsalready lists/blog, you will publish it twice. Passblog.sitemap({ index: false }), or drop your own entry — not both.- The index page's description falls back to the collection's theme. If
you wrote a description for
/blog, keep it: passindex: { title, description }tocreateBlogPages. It's an indexed page, and losing the description is the kind of thing nobody notices for a month. - Put your nav and footer in
layout.tsx, not in each page. Next scrolls a new page into view by walking that page's top-level elements. A<footer>rendered inside the page is one of them, so navigating to a post can land the reader at the bottom of the article. - Cap your RSS feed at the newest 20–50 items. Readers poll it constantly and don't want your entire archive.
- Don't paginate your blog index too early. A single page keeps every post
one click from your root, which is the best crawl structure you can have.
Numbered pages bury older posts, and Google dropped
rel="next"support in 2019.
Checklist
-
BALLAD_API_URLandBALLAD_CONTENT_KEYset — and redeployed - On the package: pinned to an exact version, and
errorsleft at its default unless you actually want an empty page over a failed build - Client returns null when unconfigured, so builds still pass
- Exhaustive surfaces follow
nextCursor; feeds take one capped page - Unknown block types skipped, markdown not rendered as raw HTML
- External links get
targetandrel; same-domain absolutes don't -
canonical, OG and JSON-LD wired;publisherandinLanguageadded - One canonical host everywhere
- Revalidate endpoint live, secret set on both sides, index refreshed too
Once that's in place the loop runs on its own: Ballad drafts, you approve, and the post is live on your domain — indexed, syndicated, and measured — without you touching a deploy.