@balladlabs/next
v0.3.0
Published
A Ballad-powered blog for Next.js: typed client for the content API, block renderer, SEO metadata, RSS, sitemap, and refresh-on-publish — with minimal code and full override points.
Maintainers
Readme
@balladlabs/next
A Ballad-powered blog for Next.js, on your domain. Ballad writes and publishes the articles; this package fetches them, renders them, handles SEO, RSS, the sitemap, and refresh-on-publish — with a few lines of code, and a way to override every piece.
Written in TypeScript, shipped as JavaScript with types. App Router only.
Install
npm install @balladlabs/nextTwo values from Ballad, under Settings → Site, in your environment:
BALLAD_API_KEY=blc_your_key_here # Content API key
BALLAD_REVALIDATE_SECRET=your-shared-secret # for refresh on publish
NEXT_PUBLIC_SITE_URL=https://your-site.com # your origin, for permalinksThe short version
One client, one call, five tiny files.
// lib/ballad.ts
import { createBallad } from "@balladlabs/next";
import { createBlogPages } from "@balladlabs/next/pages";
export const ballad = createBallad(); // reads BALLAD_* from the environment
export const blog = createBlogPages(ballad, { // /blog, /blog/[slug], rss, sitemap, revalidate
basePath: "/blog",
publisher: { "@type": "Organization", name: "Acme", url: "https://acme.com" },
});// app/blog/page.tsx
import { blog } from "@/lib/ballad";
export const generateMetadata = blog.index.generateMetadata;
export default blog.index.Page;// app/blog/[slug]/page.tsx
import { blog } from "@/lib/ballad";
export const generateStaticParams = blog.post.generateStaticParams;
export const generateMetadata = blog.post.generateMetadata;
export default blog.post.Page;// app/blog/rss.xml/route.ts
import { blog } from "@/lib/ballad";
export const GET = blog.rss.GET;// app/api/revalidate/route.ts
import { blog } from "@/lib/ballad";
export const POST = blog.revalidate.POST;// app/sitemap.ts
import { blog } from "@/lib/ballad";
export default async function sitemap() {
return [{ url: "https://acme.com" }, ...(await blog.sitemap())];
}Optionally, the default styles:
import "@balladlabs/next/styles.css";Then in Ballad, under Settings → Site, set your site URL and the same revalidate secret. New posts appear within seconds of approval, with no redeploy.
Make it yours
Every layer can be replaced without giving up the ones above it.
Swap a block renderer. Keep the pages; change how one kind of block draws.
createBlogPages(ballad, {
components: {
PullQuote: ({ block }) => <aside className="callout">{block.payload.text}</aside>,
markdown: { a: MyLink, h2: MyHeading }, // elements inside prose
},
});Draw the pages yourself. Keep the data loading, metadata, static params, feed and revalidation; render from the loaded data.
import { Article, PostList } from "@balladlabs/next/react";
createBlogPages(ballad, {
render: {
index: ({ collection, posts, hrefFor }) => (
<main>
<h1>{collection.name}</h1>
<PostList posts={posts} hrefFor={hrefFor} renderItem={(p, href) => <MyCard post={p} href={href} />} />
</main>
),
post: ({ post, summary }) => (
<main>
<Article post={post} author={summary?.author} header={<MyHeader post={post} />}>
<Newsletter />
</Article>
</main>
),
},
layout: (content) => <Shell>{content}</Shell>,
});Or skip the page factories. The client and the components stand on their own.
import { createBallad, postMetadata } from "@balladlabs/next";
import { Article, JsonLd } from "@balladlabs/next/react";
const ballad = createBallad();
const { items } = (await ballad.posts()) ?? { items: [] }; // every published post, newest first
const post = await ballad.post(slug); // blocks + SEO, or null
export const generateMetadata = async ({ params }) => postMetadata(await ballad.post((await params).slug), ballad);Working with blocks
ContentBlock includes an unknown member (a block type Ballad adds later still parses), so block.type === "prose" alone can't narrow the payload. Use the guards:
import { isProse, isHeroImage, knownBlocks, blockOfType } from "@balladlabs/next";
for (const block of post.blocks) {
if (isProse(block)) console.log(block.payload.markdown);
}
const hero = post.blocks.map((b) => blockOfType(b, "hero_image")).find(Boolean);Block types
| Type | Payload | Renders as |
|---|---|---|
| prose | { markdown } | Markdown, raw HTML escaped |
| hero_image | { url, alt, width, height, artUrl? } | <img> |
| pull_quote | { text } | <blockquote aria-hidden>: a sentence of the article, so hidden from screen readers |
| stat_callout | { value, label, sourceTitle?, sourceUrl? } | <figure> with the figure, what it measures, and a <cite> to the source |
| key_takeaways | { items: string[] } | <aside> with a short list |
| faq | { items: { q, a }[] } | <section> of <h3>/<p> pairs, plus FAQPage JSON-LD |
| cta | { text, url? } | A closing line, linked when it has a url |
| code_embed | { code, lang?, caption? } | <figure><pre><code> |
Since 0.2.0 Ballad may add a pull quote, a stat callout, key takeaways and an FAQ to an article after its prose is written. Each has a default renderer and a slot in components (PullQuote, StatCallout, KeyTakeaways, Faq), and createBlogPages emits faqJsonLd(post) beside the article's JSON-LD when the post has an FAQ. On 0.1.x those blocks arrive as unknown and render nothing.
Prefer knownBlocks(post.blocks) over a cast when you render blocks yourself: a cast only silences the type error, while knownBlocks drops a block type Ballad adds later before it reaches your switch, so a new block type can never break your post page.
Two things the marketing site's own migration hit:
- If your sitemap already lists the blog index, call
blog.sitemap({ index: false })or it appears twice. - The index page's description falls back to the collection's tagline, then its theme. Set
index: { description }increateBlogPagesto keep a written one.
Landing pages
Ballad also writes landing pages: a page for one segment (/for/technical-founders),
one use case (/use/launch-week), one comparison (/vs/acme), one integration
(/integrations/slack). They are collections of kind pages, one per type, with
no index, no feed and no dates. Rather than a route per collection, they render
through one root catch-all you add once:
// lib/ballad.ts
import { createSitePages } from "@balladlabs/next/pages";
export const pages = createSitePages(ballad, { publisher });
// app/[...slug]/page.tsx
import { pages } from "../../lib/ballad";
export const revalidate = 300;
export const generateStaticParams = pages.generateStaticParams;
export const generateMetadata = pages.generateMetadata;
export default pages.Page;
// app/api/revalidate/route.ts
import { revalidateRoutes } from "@balladlabs/next/pages";
export const { POST, GET } = revalidateRoutes([blog.revalidate, pages.revalidate]);
// app/sitemap.ts
return [...yourRoutes, ...(await blog.sitemap()), ...(await pages.sitemap())];Next resolves your own routes first; the catch-all only sees paths nothing
else claims, and answers 404 unless the path is a live page in one of Ballad's
page collections. Page collections created later render with no further
wiring. A page marked to receive sent traffic gets a noindex robots tag and
stays out of the sitemap. The JSON-LD is a WebPage (plus FAQPage when a
section is questions), with your publisher merged in.
GET /api/revalidate answers with what the site serves ({ pages, collections,
version }). Ballad reads it before proposing a page, so it never proposes one
your site cannot render; until this is wired, Ballad's audit shows the install
step instead of "Write it". Pass render.page to draw the page yourself, and
components, layout, jsonLd: false, errors: "empty" as for createBlogPages.
What's in the box
| Import | What |
| --- | --- |
| @balladlabs/next | createBallad, postMetadata, collectionMetadata, articleJsonLd, breadcrumbJsonLd, rssFeed, sitemapEntries, formatDate, readingTime, block type guards, types |
| @balladlabs/next/react | Article, Blocks, PostList, PostCard, JsonLd, the default block components |
| @balladlabs/next/pages | createBlogPages, createSitePages, createRevalidateHandler, revalidateRoutes |
| @balladlabs/next/styles.css | optional default styles, ballad-* classes |
How caching works
Every fetch is tagged (ballad, ballad:collection:<slug>, ballad:post:<slug>) and cached for five minutes by default. When Ballad publishes, it POSTs { secret, collection, slug } to your revalidate route; the handler checks the secret in constant time and revalidates exactly those tags and the pages for that post, the index and the feed. Set revalidate: 0 on the client to never cache, or revalidatePaths: ["/"] in createBlogPages if your home page lists posts.
Options
createBallad({
apiKey, // BALLAD_API_KEY
baseUrl, // BALLAD_API_URL, default https://app.balladlabs.com
collection, // BALLAD_COLLECTION, default "blog"
siteUrl, // NEXT_PUBLIC_SITE_URL / BALLAD_SITE_URL
basePath, // "/blog"
revalidate, // seconds, default 300; 0 = no-store
});
createBlogPages(ballad, {
basePath, collection, prefix,
components, // block renderers + markdown elements
render: { index, post },
layout,
index: { title, description },
feed: { title, limit } | false,
publisher, jsonLd,
revalidatePaths, revalidateSecret,
});createSitePages(ballad, {
components, prefix,
render: { page },
layout,
publisher, jsonLd,
revalidateSecret,
errors,
});Unconfigured (no key), the client is inert and the pages render their empty states, so builds and previews succeed before the key exists.
When the API fails (a 5xx, a network error), the pages throw by default: a build fails loudly and your last good deploy stays live, and an ISR regeneration keeps the last good page rather than replacing it with an empty one. Pass errors: "empty" to createBlogPages if you'd rather render as if nothing were published. The RSS route answers 503 either way.
Requirements
Next.js 15 or later on the App Router, React 18 or later. The package depends on react-markdown and remark-gfm to render prose; if your site already uses them, npm shares a compatible version. Content arrives as data, never as raw HTML: markdown renders to React nodes and anything that looks like markup in it is escaped.
A post page calls notFound() only when Ballad answers 404 for the slug. An API failure during a build fails the build; during a regeneration Next keeps the last good page. A live article never turns into a 404 because of an outage.
Labelling posts
Every post and summary carries shape: "essay", "comparison", "guide",
"case_study" or "data" — the kind of piece, and the word a card can print
(most posts are essays, so a label is worth showing only when it isn't one).
The older tier field ("cadence" / "signature") is deprecated: Ballad
retired that distinction, keeps sending the field for compatibility, and marks
every new post "cadence". Don't render it.
License
MIT
