Ballad
Integration

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.

~15 min11 sectionsNext.js App Router

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 isessay, 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:

SurfaceWhat it needs
Sitemap, generateStaticParams, archive indexevery post — follow the cursor
RSS feed, "latest posts" widgetsone 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:

TypePayload
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.

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. If sitemap.ts already lists /blog, you will publish it twice. Pass blog.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: pass index: { title, description } to createBlogPages. 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_URL and BALLAD_CONTENT_KEY set — and redeployed
  • On the package: pinned to an exact version, and errors left 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 target and rel; same-domain absolutes don't
  • canonical, OG and JSON-LD wired; publisher and inLanguage added
  • 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.

Next guideConnect Claude to Ballad