npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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/next

Two 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 permalinks

The 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 } in createBlogPages to 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