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

@fullstackdatasolutions/articles

v1.8.5

Published

A self-contained Next.js articles library providing components, server utilities, SEO helpers, and a comments system. Drop it into any Next.js + Tailwind CSS v4 app.

Readme

@fullstackdatasolutions/articles

A self-contained Next.js articles library providing components, server utilities, SEO helpers, and a comments system. Drop it into any Next.js + Tailwind CSS v4 app.

Built by Full Stack Data Solutions for solopreneurs and founders who want a practical articles system without wiring the same SEO, RSS, sitemap, MDX, and content plumbing from scratch.

Support and consulting

If this package saves you time, you can support ongoing maintenance by buying the developer a coffee: buymeacoffee.com/andrewblase.

Need help adding articles, RSS, sitemap, SEO, or MDX workflows to a Next.js app? Contact Full Stack Data Solutions.


Quick start

1. Install

npm install @fullstackdatasolutions/articles
# or
pnpm add @fullstackdatasolutions/articles

2. Add the Tailwind source path

In Tailwind CSS v4, you add a @source directive to your global CSS file — not a tailwind.config.ts. This tells Tailwind to scan the package's source files so all utility classes are generated.

/* app/globals.css */
@import 'tailwindcss';
@plugin "@tailwindcss/typography";

@source "./node_modules/@fullstackdatasolutions/articles/src";

The package components use Tailwind semantic tokens (bg-card, text-muted-foreground, border-border, etc.) that shadcn/ui and v0-generated apps already define — so the styling integrates automatically with your existing theme.

Install @tailwindcss/typography if not already present:

npm install @tailwindcss/typography
# or
pnpm add @tailwindcss/typography

3. Add TypeScript path aliases

If you're using TypeScript and want IDE autocomplete without a pre-built dist/, add these paths to tsconfig.json. This is required in a monorepo (where the package is a workspace dep) and optional when installed from npm (the published dist/ files work without it).

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@fullstackdatasolutions/articles": [
        "./node_modules/@fullstackdatasolutions/articles/src/index.ts"
      ],
      "@fullstackdatasolutions/articles/server": [
        "./node_modules/@fullstackdatasolutions/articles/src/server.ts"
      ]
    }
  }
}

4. Create your site config

// config/articles.ts
import type { ArticlesConfig } from '@fullstackdatasolutions/articles'

export const siteConfig: ArticlesConfig = {
  siteUrl: 'https://yoursite.com',
  siteName: 'Your Site',
  pageSize: 6,
  defaultAuthor: 'jane-doe',
  authors: {
    'jane-doe': {
      slug: 'jane-doe',
      name: 'Jane Doe',
      bio: 'Jane writes about campaign operations and civic technology.',
      avatar: '/authors/jane-doe.jpg',
      social: {
        website: 'https://yoursite.com/about',
        facebook: 'janedoe',
        twitter: '@janedoe',
        x: 'janedoe',
        linkedin: 'https://www.linkedin.com/in/janedoe',
        other: {
          Podcast: 'https://example.com/podcast',
        },
      },
    },
  },
  breadcrumbs: {
    article: [
      { name: 'Resources', url: '/resources' },
      'primaryCategory',
      'folderPath',
      'articleTitle',
    ],
  },
}

5. Wire up the route files

// app/articles/page.tsx
import type { Metadata } from 'next'
import { ArticlesPage } from '@fullstackdatasolutions/articles'
import {
  generateArticlesIndexMetadata,
  getAllArticles,
  getAllCategories,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'
export const metadata: Metadata = generateArticlesIndexMetadata(siteConfig)
export default async function Page() {
  // Fetching server-side and passing as initialArticles/initialCategories means the
  // first response already contains real <a href> article links — required for
  // crawlers that don't execute client JS. Omitting these props still works, but
  // falls back to a client-side fetch and a "Loading articles..." placeholder in
  // the initial HTML.
  const [articles, categories] = await Promise.all([getAllArticles(siteConfig), getAllCategories()])
  return (
    <ArticlesPage config={siteConfig} initialArticles={articles} initialCategories={categories} />
  )
}

// app/articles/[...slug]/page.tsx
import { notFound } from 'next/navigation'
import {
  ArticleDetailHero,
  ArticleNavigation,
  ArticleSEO,
  ArticleSocialShare,
  ArticleViewTracker,
  Breadcrumb,
  CommentsSection,
  RelatedArticlesSection,
  ScrollToTop,
} from '@fullstackdatasolutions/articles'
import {
  ArticleContent,
  ArticleTOC,
  buildArticleBreadcrumbs,
  generateArticleMetadata,
  generateArticleStaticParams,
  getAdjacentArticles,
  getArticleAuthors,
  getArticleMetadata,
  getRelatedArticlesByCategory,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'
type ArticlePageProps = Readonly<{ params: Promise<{ slug: string[] }> }>
function normalizeSlug(slug: string[]): string {
  return slug.join('/')
}
export function generateStaticParams(): { slug: string[] }[] {
  return generateArticleStaticParams().map((params) => ({ slug: params.slug.split('/') }))
}
export async function generateMetadata({ params }: ArticlePageProps) {
  const { slug: slugSegments } = await params
  const slug = normalizeSlug(slugSegments)
  return generateArticleMetadata(slug, siteConfig)
}
export default async function Page({ params }: ArticlePageProps) {
  const { slug: slugSegments } = await params
  const slug = normalizeSlug(slugSegments)
  const article = await getArticleMetadata(slug, siteConfig)
  if (!article) notFound()
  const { previous, next } = await getAdjacentArticles(slug)
  const related = await getRelatedArticlesByCategory(slug, article.category, 3, siteConfig)
  const authors = getArticleAuthors(article, siteConfig)
  const breadcrumbItems = buildArticleBreadcrumbs(article, siteConfig)
  const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
  const articleUrl = `${siteUrl}/articles/${slug}`
  return (
    <>
      <ArticleSEO
        article={article}
        articleUrl={articleUrl}
        siteName={siteConfig.siteName}
        authors={authors}
        config={siteConfig}
      />
      <Breadcrumb items={breadcrumbItems} />
      <ArticleDetailHero
        article={article}
        authors={authors}
        categoryBasePath="/articles/category"
        config={siteConfig}
      />
      <ArticleViewTracker article={article} config={siteConfig} />
      {siteConfig.showToc !== false && article.toc && article.toc.length > 0 && (
        <ArticleTOC toc={article.toc} />
      )}
      {/* See "Discovery & reader-journey layer" below for afterHero/afterIntro/midContent/afterContent slots */}
      <ArticleContent article={article} config={siteConfig} />
      <ArticleSocialShare
        title={article.title}
        url={articleUrl}
        excerpt={article.excerpt}
        articleSlug={slug}
        config={siteConfig}
      />
      {siteConfig.comments && <CommentsSection articleSlug={slug} config={siteConfig.comments} />}
      <RelatedArticlesSection articles={related} category={article.category} />
      <ArticleNavigation previous={previous} next={next} basePath="/articles" />
      <ScrollToTop />
    </>
  )
}

// app/articles/category/[category]/page.tsx
import { notFound } from 'next/navigation'
import { CategoryArticlesPage } from '@fullstackdatasolutions/articles'
import {
  generateCategoryMetadata,
  getArticlesByCategory,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'
export { generateCategoryStaticParams as generateStaticParams } from '@fullstackdatasolutions/articles/server'
type CategoryPageProps = Readonly<{ params: Promise<{ category: string }> }>
export async function generateMetadata({ params }: CategoryPageProps) {
  const { category } = await params
  return generateCategoryMetadata(category, siteConfig)
}
export default async function Page({ params }: CategoryPageProps) {
  const { category } = await params
  const articles = await getArticlesByCategory(category, siteConfig)
  if (articles.length === 0) notFound()
  return <CategoryArticlesPage category={category} articles={articles} config={siteConfig} />
}

// app/articles/authors/[author]/page.tsx
import { notFound } from 'next/navigation'
import { AuthorArticlesPage, AuthorDetailHero, Breadcrumb } from '@fullstackdatasolutions/articles'
import {
  buildAuthorBreadcrumbs,
  generateAuthorMetadata,
  generateAuthorStaticParams,
  getArticlesByAuthor,
  getAuthorBySlug,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'
type AuthorPageProps = Readonly<{ params: Promise<{ author: string }> }>
export function generateStaticParams(): { author: string }[] {
  return generateAuthorStaticParams(siteConfig)
}
export async function generateMetadata({ params }: AuthorPageProps) {
  const { author } = await params
  return generateAuthorMetadata(author, siteConfig)
}
export default async function Page({ params }: AuthorPageProps) {
  const { author: authorSlug } = await params
  const author = getAuthorBySlug(authorSlug, siteConfig)
  if (!author) notFound()
  const articles = await getArticlesByAuthor(authorSlug, siteConfig)
  return (
    <>
      <Breadcrumb items={buildAuthorBreadcrumbs(author, siteConfig)} />
      <AuthorDetailHero author={author} />
      <AuthorArticlesPage author={author} articles={articles} config={siteConfig} />
    </>
  )
}

6. Set up markdown handler (Next.js 16+)

Create an API route to serve article markdown at /api/articles-markdown/[...slug]:

// app/api/articles-markdown/[...slug]/route.ts
import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
import { siteConfig } from '@/config/articles'

export const { GET } = createArticleMarkdownHandler(siteConfig)

Then wire the markdown rewrite into your proxy.ts (Next.js 16+ edge proxy):

// proxy.ts
import { NextResponse, NextRequest } from 'next/server'
import { rewriteArticleMarkdown } from '@fullstackdatasolutions/articles/middleware'

export async function proxy(request: NextRequest) {
  const mdRewrite = rewriteArticleMarkdown(request)
  if (mdRewrite) return mdRewrite

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

This pattern rewrites requests from /articles/slug.md to /api/articles-markdown/slug where the handler serves the markdown. See the AI markdown twins section below for details on how to expose markdown and configure AI crawling rules.

For Next.js 14/15, use the middleware.ts pattern below instead.

6b. Set up middleware (Next.js 14/15 only)

If using Next.js 14 or 15 and cannot use a proxy, create middleware.ts to rewrite markdown requests:

// middleware.ts
import { NextRequest } from 'next/server'
import { middleware, config } from '@fullstackdatasolutions/articles/middleware'

export { config }
export default middleware

This pattern does NOT work in Next.js 16 (use the proxy.ts pattern instead). You still need the handler route above to actually serve the markdown.

6c. Copy the comments API routes

These cannot live inside the package — Next.js file-based routing requires them in your app. Copy these files from the reference implementation:

app/api/articles/route.ts
app/api/articles/comments-store.ts
app/api/articles/[slug]/comments/route.ts
app/api/articles/[slug]/comments/[commentId]/route.ts

The route implementations import from @fullstackdatasolutions/articles/server. Do not put custom business logic in them; they should stay as thin wrappers. The same server entry can also be imported from a plain Node 24 ESM validation script, such as a sitemap or frontmatter audit; it does not require a Next runtime just to load server-side article data.

7. Add your content

Create public/articles/[slug]/article.md or public/articles/[slug]/article.mdx for each article. Required frontmatter:

---
title: Your Article Title
excerpt: A one-sentence summary shown in cards and meta descriptions.
authors: [jane-doe]
tags: [campaigns, strategy]
---

Article body in Markdown...

Article directories can be nested to any depth below public/articles. For example, public/articles/game-system/article-name/article.mdx is discovered as the slug game-system/article-name, and public/articles/tov/subdirectory/article-name/article.md is discovered as tov/subdirectory/article-name. Use a catch-all Next.js route such as app/articles/[...slug]/page.tsx and join the slug segments before calling the server helpers.

Articles can have zero, one, or many authors:

---
title: Team Update
excerpt: A post with no visible author.
authors: []
---
---
title: Joint Post
excerpt: A post with multiple authors.
authors:
  - jane-doe
  - alex-smith
---

The legacy author: Jane Doe string still works. Resolution order is:

  1. authors: [...] frontmatter
  2. author: ... frontmatter
  3. defaultAuthor from ArticlesConfig

Use authors: [] when an article should intentionally show no author, even if defaultAuthor is configured.

Video articles

An article about a video can declare it in frontmatter. The package emits a schema.org VideoObject twice: as a standalone node, which is what Google reads for video rich results and video search, and nested on the Article node as video, which states the article is about that video.

---
title: How to Run Session Zero
excerpt: A walkthrough of the conversation that sets up the whole campaign.
video:
  name: How to Run Session Zero
  description: A walkthrough of the conversation that sets up the whole campaign.
  thumbnailUrl: https://i.ytimg.com/vi/abc123/hqdefault.jpg
  uploadDate: '2026-01-02'
  embedUrl: https://www.youtube-nocookie.com/embed/abc123
  contentUrl: https://youtu.be/abc123
  duration: PT8M32S
---

| Field | Required | Notes | | -------------- | -------- | ------------------------------------------------------------------------- | | name | yes | Video title. | | description | yes | What the video covers. | | thumbnailUrl | yes | A string or a list. Always emitted as an array, the shape Google prefers. | | uploadDate | yes | Any date string Date parses. Normalized to ISO 8601 on output. | | embedUrl | no | Player URL, e.g. a YouTube /embed/ link. | | contentUrl | no | Direct URL to the video file or its watch page. | | duration | no | ISO 8601 duration, e.g. PT8M32S. Recommended by Google. |

The four required fields are Google's. A block missing any of them, carrying an unparseable uploadDate, or supplying no usable thumbnail is dropped whole rather than emitted incomplete - an invalid VideoObject is a validation error on the page, not a partial win. Supply at least one of embedUrl or contentUrl: Google needs one to surface the video.


Markdown handler exports

./nextjs — Article markdown handler factory

Import from @fullstackdatasolutions/articles/nextjs. Node.js runtime only (use in API routes, not Edge middleware).

createArticleMarkdownHandler(config)

Factory function that returns a { GET } handler for serving article markdown.

import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
import { siteConfig } from '@/config/articles'

export const { GET } = createArticleMarkdownHandler(siteConfig)
  • config - ArticlesConfig instance (required). Must include siteUrl and siteName at minimum.
  • returns - Object with GET async function matching Next.js Route Handler signature.
  • The handler extracts slug from params['slug'] and strips .md extension if present.
  • Uses getArticleMarkdownResponse from the server utilities to fetch and return markdown.
  • Only returns markdown for articles with aiCrawl: true in frontmatter (returns 404 otherwise).

./middleware — Article markdown rewrite helpers

Import from @fullstackdatasolutions/articles/middleware. Edge runtime safe (no Node.js built-ins).

rewriteArticleMarkdown(request, apiBase?, config?)

Rewrites article *.md requests to your markdown handler route.

import { rewriteArticleMarkdown } from '@fullstackdatasolutions/articles/middleware'

export async function proxy(request: NextRequest) {
  const mdRewrite = rewriteArticleMarkdown(request)
  if (mdRewrite) return mdRewrite

  return NextResponse.next()
}
  • request - NextRequest object (required).
  • apiBase - Base path to your markdown handler. Default: '/api/articles-markdown'
  • config - Anything carrying articleBasePath, e.g. your ArticlesConfig. Omit it when articles are served under the default /articles.
  • returns - NextResponse if the request matches an article .md path, undefined otherwise.
  • Intended use: call in proxy.ts (Next.js 16+) or middleware.ts. If result is truthy, return it; otherwise continue processing.
  • Example: request to /articles/my-post.md rewrites to /api/articles-markdown/my-post.

createArticleMarkdownMiddleware(apiBase?, pathConfig?)

Factory for building custom middleware/proxy with a custom API base path. Pass pathConfig (your ArticlesConfig, or any object with articleBasePath) when articles are not served under /articles - the returned config.matcher is built to match.

import { createArticleMarkdownMiddleware } from '@fullstackdatasolutions/articles/middleware'

const { middleware, config } = createArticleMarkdownMiddleware('/api/my-custom-path')

export { config }
export default middleware
  • apiBase - Base path to your markdown handler. Default: '/api/articles-markdown'
  • returns - Object with { middleware, config } ready to export from middleware.ts.
  • config.matcher automatically targets /articles/:path*.md.

middleware (default export)

Pre-built middleware using the default API base ('/api/articles-markdown').

// middleware.ts
import { middleware, config } from '@fullstackdatasolutions/articles/middleware'

export { config }
export default middleware

Use this if you don't need a custom API base path. For custom paths, use createArticleMarkdownMiddleware() instead.


ArticlesConfig reference

| Field | Type | Default | Description | | ----------------------- | --------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | siteUrl | string | — | Canonical base URL. Used in metadata and JSON-LD. | | siteName | string | — | Site name in title tags and JSON-LD. | | articleBasePath | string | '/articles' | URL prefix for every article, category, tag, series, and author path. '' serves articles at root-level slugs (/my-post). See below. | | pageSize | number | 6 | Articles per page / per "Load more" click. | | categoriesPageSize | number | 8 | Category cards before "Load more categories". | | layout | ArticlesSection[] | see below | Ordered sections to render. Omit a key to hide it. | | theme | ArticlesTheme | — | CSS custom-property overrides for colors and fonts. | | categoryDescriptions | Record<string, string \| CategoryDescription> | — | Short/long text per category slug. | | tagDescriptions | Record<string, string \| CategoryDescription> | — | Short/long text per tag slug. Same shape as categoryDescriptions. | | hero | HeroConfig | — | Hero title, description, background, and buttons. Omit to use built-in defaults. | | latest | LatestConfig | — | "Latest Articles" heading and description. Omit to use built-in defaults. | | comments | CommentsConfig | — | Comments feature config. Omit to disable entirely. | | showToc | boolean | true | Show the table of contents on article detail pages. Set to false to hide on all articles. | | showBackToArticles | boolean | true | Show the back-navigation link on article detail pages. Set to false to hide ArticleBackLink. | | showAuthor | boolean | true | Show author names in UI and metadata. Set to false to omit author display, OpenGraph authors, JSON-LD author fields, and RSS authors. | | showAuthorPage | boolean | true | Include configured author pages in sitemap output and enable author page helpers. Set to false to suppress author page generation. | | authors | Record<string, AuthorProfile> | {} | Configured author profiles keyed by slug. Slugs are used by frontmatter, author URLs, and JSON-LD Person data. | | defaultAuthor | string | — | Default author slug or name used when article frontmatter omits both authors and author. | | breadcrumbs | false \| BreadcrumbsConfig | default trails | Set to false to hide breadcrumbs, or configure article/category/author breadcrumb trails, custom URL items, separator, schema output, and labels. | | linkTargetStrategy | 'external-new-tab' \| 'all-new-tab' \| 'same-tab' | 'external-new-tab' | Controls article body links. Internal links open in the same window by default; external http/https links open in a new window. | | mdxComponents | Record<string, ComponentType<never>> | - | Components exposed to article .mdx bodies by tag name. Merged with the built-in MDX image override. | | description | string | — | Short description used as the RSS feed channel description. Falls back to siteName if omitted. | | entities | Record<string, EntityReference> | — | Shared entity vocabulary. Article about entries may reference a key instead of repeating a name/sameAs pair. | | lastmodFallback | 'published' \| 'none' \| 'fileMtime' | 'published' | Where lastmod comes from when frontmatter omits it. See Freshness. | | onAiCrawl | (event: AiCrawlEvent) => void | — | Fired when an AI crawler fetches a markdown twin. The only place to measure whether AI-readable content is being read. | | organization | OrganizationConfig | — | Publishing entity behind the site. Set it to emit OrganizationSchema/WebSiteSchema and have every article reference one @id. | | titleTemplate | string | '{title} \| {siteName}' | Page <title> template. Supports {title} and {siteName}. Set '{title}' to drop the site-name suffix - see Title length. | | language | string | 'en' | BCP 47 tag. Emitted as Article inLanguage and the RSS channel <language>. | | isAccessibleForFree | boolean | true | Set false for paywalled content. Emitted as Article isAccessibleForFree. | | speakableSelectors | string[] | — | CSS selectors for the Article speakable field. Omitted by default - correct selectors depend on your markup. | | deriveFaqFromHeadings | boolean | false | Derive FAQPage entries from question-shaped ## headings. Explicit faq frontmatter always wins. | | aiCrawlDefault | boolean | false | Default aiCrawl for articles whose frontmatter omits it. Set true to opt the whole corpus in; per-article aiCrawl: false still wins. | | categoryNoindex | boolean | false | Serve category listing pages as noindex, follow. follow is always kept - a category's value is as a crawl path to its articles. Applies to /page/N too. | | tagNoindex | boolean | true | Serve tag listing pages as noindex, follow. Defaults to true, unlike categoryNoindex - tag and category pages list the same articles (see below). Set false to index them. | | markdownTwinHeader | boolean | true | Prefix markdown twins with title, excerpt, canonical source URL, dates, author, and site name. Set false to serve the bare body. | | listingPagination | 'load-more' \| 'pages' | 'load-more' | 'load-more' keeps the client-only "Load more" button (no URL change). 'pages' opts every listing surface into real, crawlable paginated routes - see the Pagination section. |

Default layout order: ['hero', 'search', 'featured', 'latest', 'categories']

To add a newsletter section, pass layout: ['hero','search','featured','latest','categories','newsletter'] — the library renders null for 'newsletter' so you can append your own component after <ArticlesPage />.

Root-level article URLs (articleBasePath)

By default every path this package emits is built under /articles. Set articleBasePath: '' when the site's whole purpose is the corpus and the /articles segment is a wasted path element:

export const siteConfig: ArticlesConfig = {
  siteUrl: 'https://example.com',
  siteName: 'Example',
  articleBasePath: '', // '/my-post' instead of '/articles/my-post'
}

Any other value is normalized to /segment shape, so 'blog', '/blog', and '/blog/' are the same setting. The option moves every URL together - page links, canonicals, RSS, llms.txt, markdown twins, sitemap entries, breadcrumbs, and JSON-LD - so the whole site keeps one URL shape. Because it changes published URLs, pick it before launch; changing it later needs redirects.

Two things deliberately do not move:

  • Article assets. Content still lives in public/articles/<slug>/, so images resolve from /articles/<slug>/hero.jpg no matter what this is set to. The exported ARTICLE_ASSET_BASE_PATH is that fixed prefix.
  • Your markdown-twin API route. Only the public .md URL moves; pair it with markdownTwinMatcher(config) for the middleware matcher.

Two consequences of root mode worth planning for. Your article route becomes a catch-all at the site root, so every non-article page needs a more specific route (Next matches static segments before dynamic ones). And articlesIndexPath(config) resolves to /, because there is no separate listing route left to point at - the components that link "back to articles" send readers to the site root.

Path helpers are exported from both entry points if you need to build the same URLs yourself: articlePath, articleMarkdownPath, articlesIndexPath, categoryPath, categoryBasePath, tagPath, seriesPath, authorPath, feedPath, markdownTwinMatcher, authorAssetPath, and resolveArticleBasePath.

Custom MDX components

Pass React components through mdxComponents to make them available to article .mdx files. The component key is the JSX tag authors write in the article.

Both Markdown and MDX bodies include syntax highlighting for language-tagged code fences. It works from the package's CommonJS and ESM server entry points; consumers do not need to configure a highlighter separately.

// config/articles.ts
import type { ArticlesConfig } from '@fullstackdatasolutions/articles'
import { LeadMagnetCTA } from '@/components/LeadMagnetCTA'

export const siteConfig: ArticlesConfig = {
  siteUrl: 'https://yoursite.com',
  siteName: 'Your Site',
  mdxComponents: {
    LeadMagnetCTA,
  },
}
Article intro paragraph.

<LeadMagnetCTA system="DND" segment="new-players" />

Imports inside individual .mdx article files are not supported. Define components in the consuming app and register them through mdxComponents.

Authors

Author profiles are optional. If an article has no configured author and no plain author frontmatter, author UI and author metadata are omitted for that article.

authors: {
  'jane-doe': {
    slug: 'jane-doe',
    name: 'Jane Doe',
    bio: 'Jane writes about campaign operations and civic technology.',
    avatar: '/authors/jane-doe.jpg',
    url: 'https://example.com/team/jane-doe',
    social: {
      website: 'https://example.com',
      facebook: 'janedoe',
      twitter: '@janedoe',
      x: 'janedoe',
      linkedin: 'https://www.linkedin.com/in/janedoe',
      instagram: 'janedoe',
      youtube: '@janedoe',
      tiktok: '@janedoe',
      github: 'janedoe',
      bluesky: 'janedoe.bsky.social',
      threads: 'janedoe',
      mastodon: 'https://mastodon.social/@janedoe',
      medium: 'janedoe',
      newsletter: 'https://example.com/newsletter',
      other: {
        Podcast: 'https://example.com/podcast',
      },
    },
  },
}

Only social fields with values are rendered. Handle-style values such as twitter: '@janedoe' and linkedin: 'janedoe' are normalized to full profile URLs; absolute URLs are left unchanged. social.other lets you add custom social or profile links with your own labels.

Breadcrumbs

Breadcrumbs are visible by default. Disable them with breadcrumbs: false, or customize an article trail:

breadcrumbs: {
  separator: '/',
  showSchema: true,
  article: [
    { name: 'Resources', url: '/resources' },
    'primaryCategory',
    'folderPath',
    { name: 'Campaign Library', url: 'https://example.com/library' },
    'articleTitle',
  ],
  category: ['home', { name: 'Resources', url: '/resources' }, 'category'],
  author: ['home', 'articles', { name: 'Team', url: '/team' }, 'authorName'],
}

Breadcrumb trails can mix built-in tokens with custom URL items shaped as { name: string, url: string }. Relative custom URLs beginning with / are resolved against siteUrl; absolute http and https URLs are left unchanged. The folderPath article token expands the article slug folders. For public/articles/guides/field/example/article.md, the trail above renders Resources, the primary tag, Guides, Field, Campaign Library, and the article title.

Use breadcrumbs: { show: false } to disable both visible breadcrumbs and breadcrumb JSON-LD while keeping the rest of the breadcrumb config nearby. Use showSchema: false to keep visible breadcrumbs but omit breadcrumb JSON-LD.


Theming

All theme values are applied as CSS custom properties on a wrapper <div>. Components reference them through Tailwind so no extra CSS file is needed.

theme: {
  fontFamily:       "'Inter', sans-serif",
  headerFontFamily: "'Cinzel', serif",
  headerColor:      '#111827',
  textColor:        '#6b7280',
  backgroundColor:  '#ffffff',
  linkColor:        '#4f46e5',
  linkHoverColor:   '#4338ca',
  borderRadius:     '0.5rem',
}

All fields are optional. Omitted fields fall back to your Tailwind theme tokens. headerFontFamily styles article titles and card/section headings (ArticleDetailHero, RelatedArticlesSection, ArticleCard, ArticlesHero, LatestArticlesSection) separately from body text; omit it to let headings inherit fontFamily like everything else.

Since 1.7.0 it also reaches the headings inside the rendered article body. Those are produced by the rehype pass rather than by a React component, so they cannot take a prop; they read --articles-header-font-family from the wrapper ArticleContent sets. Set no theme and the property is absent, inherit applies, and the markup is unchanged.


Hero section

Customize the listing hero through HeroConfig. Omit the hero field entirely to use built-in defaults.

hero: {
  title: 'My App Articles',
  description: 'Insights and updates from our team.',
  background: 'linear-gradient(to right, #f4eedf, #e9dfc9)',
  actions: [
    { label: 'Browse the archive', href: '#latest' },
    { label: 'Get in touch', href: '/#contact' },
  ],
}

| Field | Default | Notes | | ------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | title | 'Vox Populus Insights' | The <h1> on the listing page. | | description | built-in copy | Paragraph under the title. | | background | a gray wash (from-gray-50 to to-gray-100) | Any CSS background value. The default ignores your theme, so set this to keep the band on-brand. | | actions | [{ 'Browse Latest Articles' -> '#latest' }, { 'Contact Our Team' -> '/contact' }] | The buttons under the copy. The first renders filled, the rest outlined. /contact 404s on any site without that route, so set this or pass [] to render none. |

The hero title follows theme.headerFontFamily when set.


Latest Articles section

The "Latest Articles" heading and its paragraph are configurable through LatestConfig. The built-in description mentions voter engagement and civic technology, which belongs to the site this package was first built for - set your own or it ships on your listing page.

latest: {
  title: 'From the archive',
  description: 'Writing on GraphQL and federation.',
}

The search-results state ("Found N articles matching your search.") is unaffected by description.


ArticleDetailHero

ArticleDetailHero renders the article's featured image full-width behind the title, category tags, and metadata (date, read time, author). When you render ArticleDetailHero exactly as shown below the hero image and linked categories appear automatically—no additional styling or extra <Link> wiring is required in the consuming app. Just pass the article payload and categoryBasePath (defaults to /articles/category) so the tags render as links.

import { ArticleDetailHero } from '@fullstackdatasolutions/articles'
;<ArticleDetailHero article={article} categoryBasePath="/articles/category" />

| Prop | Type | Required | Description | | ------------------ | --------- | -------- | --------------------------------------------------------------------------------------------------------------- | | article | Article | yes | Article object returned by getArticleMetadata | | categoryBasePath | string | no | Base path for category links. Defaults to "/articles/category". Pass "" to render categories as plain text. | | showDate | boolean | no | Show the article date. Defaults to false (hidden). |

  • Category tags are capped at 4. When an article has more than 4 categories, only the first 4 are shown.
  • The date field is hidden by default. Pass showDate={true} to display it.
  • All layout styles are inline so the component renders correctly regardless of whether @source is configured for the package.
  • Hero image / categories behave automatically once you use this component; apps do not need to reimplement the hero section for those features.
  • Author authority statement (Phase 27E): when authors has exactly one entry and that author's AuthorProfile.promise is set, ArticleDetailHero renders it as a one-sentence line under the byline. Omitted for zero or multiple authors, or when promise is unset - purely additive, no existing output changes.

ArticleSocialShare

ArticleSocialShare renders a row of sharing buttons (LinkedIn, Facebook, Twitter, Reddit, WhatsApp, Telegram, Email, Copy Link). Use it below the article body in your app/articles/[slug]/page.tsx.

import { ArticleSocialShare } from '@fullstackdatasolutions/articles'
;<ArticleSocialShare
  title={article.title}
  url={articleUrl}
  excerpt={article.excerpt}
  shareMessage="Optional footer note shown below the buttons."
/>

| Prop | Type | Required | Description | | -------------- | -------- | -------- | --------------------------------------------------- | | title | string | yes | Article title - used in share text | | url | string | yes | Canonical URL to share | | excerpt | string | no | Used as email body pre-fill | | shareMessage | string | no | Optional paragraph rendered below the share buttons |


ArticleNavigation

ArticleNavigation renders previous/next article links at the bottom of an article. Returns null when both previous and next are absent (no output, no wrapper element). Use getAdjacentArticles from the server entry to fetch the data.

import { ArticleNavigation } from '@fullstackdatasolutions/articles'
import { getAdjacentArticles } from '@fullstackdatasolutions/articles/server'

const { previous, next } = await getAdjacentArticles(slug)

<ArticleNavigation previous={previous} next={next} basePath="/articles" />

| Prop | Type | Required | Description | | ---------- | ----------------------------------------- | -------- | -------------------------------------------------------- | | previous | { slug: string; title: string } \| null | yes | Previous article, or null | | next | { slug: string; title: string } \| null | yes | Next article, or null | | basePath | string | yes | Route prefix (e.g. "/articles") prepended to each slug |

  • Titles longer than 60 characters are truncated with an ellipsis.
  • No output is rendered when both previous and next are null.

RelatedArticlesSection

RelatedArticlesSection renders a grid of other articles in the same category as the current article, using ArticleCard for each one - real <a href> links, not a client-only "Load more" interaction. Use getRelatedArticlesByCategory from the server entry to fetch the data.

import { RelatedArticlesSection } from '@fullstackdatasolutions/articles'
import { getRelatedArticlesByCategory } from '@fullstackdatasolutions/articles/server'

const related = await getRelatedArticlesByCategory(slug, article.category, 3, siteConfig)

<RelatedArticlesSection articles={related} category={article.category} />

| Prop | Type | Required | Description | | ---------- | ----------- | -------- | ----------------------------------------------------------------- | | articles | Article[] | yes | Related articles to render, already excluding the current one | | category | string | yes | Category name shown in the section heading ("More in {category}") |

getRelatedArticlesByCategory(currentSlug, category, limit?, config?) returns up to limit (default 3) other articles sharing category with currentSlug, most recent first, built on the same category-slug matching getArticlesByCategory already uses. Returns [] (and the component renders nothing) when no other articles share the category.


Pagination

By default (listingPagination unset, or 'load-more'), /articles, every category page, and every author page use a client-only "Load more" button - clicking it never changes the URL, so a crawler that doesn't execute client JS never sees article 7+ on a listing with more than one page of content.

Set listingPagination: 'pages' in ArticlesConfig to opt into real, directly-navigable paginated routes instead: /articles/page/2, /articles/category/[category]/page/2, /articles/authors/[author]/page/2, each server-rendered with real <a href> prev/next links and its own canonical URL. This is a config choice, not a migration - 'load-more' (the default) keeps working exactly as before, and each listing surface only switches to 'pages' behavior when both config.listingPagination === 'pages' and the surface's page/totalPages props are passed in.

The package does not ship the route files themselves (Next.js requires them to live in your app's app/ directory) - it exports the primitives to build them: pagination math (./server), a prev/next nav component (PaginationNav, from the main entry), and per-page metadata generators (./server).

Pagination primitives (./server)

| Export | Description | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | getTotalPages(totalCount, pageSize) | Total page count, rounded up. Returns 1 for a zero/negative count or pageSize. | | paginateArticles(articles, page, pageSize) | Slices articles to the requested page. Out-of-range page numbers are clamped into [1, totalPages]. Returns { articles, page, totalPages, hasPrevious, hasNext }. | | buildPageUrl(basePath, page) | Page 1 returns basePath unchanged; page N>1 returns ${basePath}/page/${N}. | | buildPaginationLinks(basePath, page, totalPages) | Returns { canonicalUrl, prevUrl, nextUrl } (prevUrl/nextUrl are null at the ends). | | generateListingPageStaticParams(totalPages) | Returns { page: string }[] for pages 2..totalPages (page 1 has no /page/1 route - it's the base route itself). | | parsePageParam(raw) | Parses a route param string to a page number; defaults to 1 for missing/invalid input. | | isPageOutOfRange(page, totalPages) | true when page < 1 or page > totalPages - call notFound() when this is true. |

PaginationNav (main entry)

Tag pages

tags frontmatter drives two listing surfaces, and it is important to understand that they overlap.

Categories are derived from tags (see the frontmatter table below), so /articles/category/graphql and /articles/tag/graphql list the same articles under the same slug. They are duplicate content by construction. For that reason tagNoindex defaults to true - tag pages are served noindex, follow unless you opt in with tagNoindex: false. follow is kept in both modes, and /page/N variants inherit the setting.

Add a tag route only if you want the URL to exist - for example to receive 301s from a previous blog's /tags/[tag] pages. Nothing is created until you add the route.

// app/articles/tag/[tag]/page.tsx
import { TagArticlesPage } from '@fullstackdatasolutions/articles'
import {
  getAllTags,
  getArticlesByTag,
  generateTagMetadata,
  generateTagStaticParams,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'

export const dynamicParams = false

export function generateStaticParams() {
  return generateTagStaticParams(siteConfig)
}

export async function generateMetadata({ params }: { params: Promise<{ tag: string }> }) {
  return generateTagMetadata((await params).tag, siteConfig)
}

export default async function Page({ params }: { params: Promise<{ tag: string }> }) {
  const { tag } = await params
  const [articles, tags] = await Promise.all([
    getArticlesByTag(tag, siteConfig),
    getAllTags(siteConfig),
  ])
  // Pass the resolved name so the heading shows the authored spelling
  // ("WunderGraph Cosmo"), which a title-cased slug cannot reproduce.
  const tagName = tags.find((t) => t.slug === tag)?.name
  return <TagArticlesPage tag={tag} tagName={tagName} articles={articles} config={siteConfig} />
}

getAllTags groups by slug, so GraphQL and graphql are one entry labeled with whichever spelling the most articles use. The authored spelling survives on tags, while the category path rewrites hyphens to spaces - hub-and-spoke lists as the tag hub-and-spoke and the category hub and spoke, both resolving to the slug hub-and-spoke.

A tag/ markdown twin is served automatically by getMarkdownTwinResponse at /articles/tag/[tag].md, alongside the category, author, and series twins.

Renders real Previous/Next <a href> links plus <link rel="prev"/"next"> tags (React 19 hoists these into <head> automatically wherever they're rendered in the tree). Rendered automatically by LatestArticles/ArticlesPage/CategoryArticlesPage/AuthorArticlesPage when you pass pagination props - you generally won't render it directly.

| Prop | Type | Required | Description | | ------------ | -------- | -------- | --------------------------------------------------------- | | basePath | string | yes | Un-paginated route path, e.g. /articles. | | page | number | yes | Current page number. | | totalPages | number | yes | Total page count. Renders nothing when totalPages <= 1. |

rel=next/prev and Google: Google Search Central's pagination guidance no longer treats rel="next"/rel="prev" as an indexing or ranking signal (dropped in 2019) - what actually makes page 2+ indexable to Google is each page having its own self-referencing canonical (never pointing back to page 1) plus real crawlable links between pages, both of which are handled here (generate*PageMetadata below for the canonical, PaginationNav's visible links for crawlability). rel=next/prev remains valid HTML and is still read by Bing and some third-party tools, so PaginationNav emits it anyway - it's just not the mechanism Google uses.

Per-page metadata (./server)

generateArticlesIndexPageMetadata, generateCategoryPageMetadata, and generateAuthorPageMetadata wrap the existing generate*Metadata functions, adding a " - Page N" title suffix (page 1 is unchanged) and pointing alternates.canonical/openGraph.url at that page's own URL instead of page 1's. Paginated pages stay index, follow - the entire point is making them indexable, not excluding them.

Wiring the routes

// app/articles/page/[page]/page.tsx
import { notFound } from 'next/navigation'
import { ArticlesPage } from '@fullstackdatasolutions/articles'
import {
  generateArticlesIndexPageMetadata,
  generateListingPageStaticParams,
  getAllArticles,
  getAllCategories,
  getTotalPages,
  isPageOutOfRange,
  paginateArticles,
  parsePageParam,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'

type PageProps = Readonly<{ params: Promise<{ page: string }> }>

export async function generateStaticParams() {
  const totalPages = getTotalPages(
    (await getAllArticles(siteConfig)).length,
    siteConfig.pageSize ?? 6
  )
  return generateListingPageStaticParams(totalPages)
}

export async function generateMetadata({ params }: PageProps) {
  const page = parsePageParam((await params).page)
  const totalPages = getTotalPages(
    (await getAllArticles(siteConfig)).length,
    siteConfig.pageSize ?? 6
  )
  return generateArticlesIndexPageMetadata(page, totalPages, siteConfig)
}

export default async function Page({ params }: PageProps) {
  const page = parsePageParam((await params).page)
  const [allArticles, categories] = await Promise.all([
    getAllArticles(siteConfig),
    getAllCategories(),
  ])
  const { articles, totalPages } = paginateArticles(allArticles, page, siteConfig.pageSize ?? 6)
  if (isPageOutOfRange(page, totalPages)) notFound()
  return (
    <ArticlesPage
      config={siteConfig}
      initialArticles={articles}
      initialCategories={categories}
      page={page}
      totalPages={totalPages}
      totalCount={allArticles.length}
    />
  )
}

// app/articles/category/[category]/page/[page]/page.tsx
import { notFound } from 'next/navigation'
import { CategoryArticlesPage } from '@fullstackdatasolutions/articles'
import {
  generateCategoryPageMetadata,
  generateListingPageStaticParams,
  getAllCategories,
  getArticlesByCategory,
  getTotalPages,
  isPageOutOfRange,
  paginateArticles,
  parsePageParam,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'

type CategoryPageProps = Readonly<{ params: Promise<{ category: string; page: string }> }>

export async function generateStaticParams() {
  const categories = await getAllCategories()
  const perCategory = await Promise.all(
    categories.map(async (cat) => {
      const totalPages = getTotalPages(
        (await getArticlesByCategory(cat.slug, siteConfig)).length,
        siteConfig.pageSize ?? 6
      )
      return generateListingPageStaticParams(totalPages).map((p) => ({
        category: cat.slug,
        page: p.page,
      }))
    })
  )
  return perCategory.flat()
}

export async function generateMetadata({ params }: CategoryPageProps) {
  const { category, page: pageParam } = await params
  const page = parsePageParam(pageParam)
  const totalPages = getTotalPages(
    (await getArticlesByCategory(category, siteConfig)).length,
    siteConfig.pageSize ?? 6
  )
  return generateCategoryPageMetadata(category, page, totalPages, siteConfig)
}

export default async function Page({ params }: CategoryPageProps) {
  const { category, page: pageParam } = await params
  const page = parsePageParam(pageParam)
  const allArticles = await getArticlesByCategory(category, siteConfig)
  if (allArticles.length === 0) notFound()
  const { articles, totalPages } = paginateArticles(allArticles, page, siteConfig.pageSize ?? 6)
  if (isPageOutOfRange(page, totalPages)) notFound()
  return (
    <CategoryArticlesPage
      category={category}
      articles={articles}
      config={siteConfig}
      page={page}
      totalPages={totalPages}
      totalCount={allArticles.length}
    />
  )
}

// app/articles/authors/[author]/page/[page]/page.tsx
import { notFound } from 'next/navigation'
import { AuthorArticlesPage } from '@fullstackdatasolutions/articles'
import {
  generateAuthorPageMetadata,
  generateListingPageStaticParams,
  getArticlesByAuthor,
  getAuthorBySlug,
  getTotalPages,
  isPageOutOfRange,
  paginateArticles,
  parsePageParam,
} from '@fullstackdatasolutions/articles/server'
import { siteConfig } from '@/config/articles'

type AuthorPageProps = Readonly<{ params: Promise<{ author: string; page: string }> }>

export async function generateMetadata({ params }: AuthorPageProps) {
  const { author: authorSlug, page: pageParam } = await params
  const page = parsePageParam(pageParam)
  const totalPages = getTotalPages(
    (await getArticlesByAuthor(authorSlug, siteConfig)).length,
    siteConfig.pageSize ?? 6
  )
  return generateAuthorPageMetadata(authorSlug, page, totalPages, siteConfig)
}

export default async function Page({ params }: AuthorPageProps) {
  const { author: authorSlug, page: pageParam } = await params
  const author = getAuthorBySlug(authorSlug, siteConfig)
  if (!author) notFound()
  const page = parsePageParam(pageParam)
  const allArticles = await getArticlesByAuthor(authorSlug, siteConfig)
  const { articles, totalPages } = paginateArticles(allArticles, page, siteConfig.pageSize ?? 6)
  if (isPageOutOfRange(page, totalPages)) notFound()
  return (
    <AuthorArticlesPage
      author={author}
      articles={articles}
      config={siteConfig}
      page={page}
      totalPages={totalPages}
      totalCount={allArticles.length}
    />
  )
}

ArticlesPage, CategoryArticlesPage, and AuthorArticlesPage all accept the same three optional props - page, totalPages, totalCount - which are only read when config.listingPagination === 'pages'; passing them with any other (or unset) listingPagination value is a no-op, so the default behavior can never regress from adding this wiring. totalCount (the true total across every page, not just the current slice) feeds CollectionPageSchema.articleCount/numberOfItems; omit it and it falls back to the length of the articles/initialArticles array you pass, matching prior behavior.

Search stays unpaginated in both modes. ArticlesPage's search bar (ArticleSearchBar/useArticles) fetches from /api/articles?q=... and shows every match on one page regardless of listingPagination - search result URLs aren't meant to be indexed, so real pagination adds no SEO value there, and LatestArticlesSection explicitly drops the pagination prop while a search query is active.

Sitemap: paginated listing URLs (/articles/page/2, etc.) are intentionally not added to getArticleSitemapEntries. Every individual article, category, and author URL is already listed directly in the sitemap output, so a paginated listing page adds no new discoverable URLs to it - the reason 'pages' mode exists is internal link equity (real <a href> links between pages) and crawlers/AI agents that weight on-page links over sitemaps, not sitemap coverage.


Discovery & reader-journey layer (Phase 27F)

Search/social metadata overrides, series and curated paths, article-detail extension points, an app-owned primary action reference, reusable related-content selection, a vendor-neutral event contract, and a package validator. All additive - every legacy consumer with none of these fields/props set renders/behaves identically to before this phase.

Discovery metadata overrides

Optional frontmatter fields let one article optimize its search-result snippet differently from its social-share card, without touching canonical URLs, JSON-LD, RSS, or ArticleCard (those always read title/excerpt/featuredImage directly):

---
title: The Real Article Title
excerpt: The real, on-page excerpt.
searchTitle: 'A Punchier, SEO-Optimized Title' # <title>/meta description ONLY
searchDescription: 'A keyword-forward summary for search snippets.'
socialTitle: 'A Scroll-Stopping Social Headline' # Open Graph/Twitter Card ONLY
socialDescription: 'A more casual, share-friendly description.'
socialImage: social-card.png # falls back to featuredImage when unset
---

generateArticleMetadata's <title>/meta description prefer searchTitle/searchDescription, falling back to title/excerpt. Its Open Graph/Twitter Card output prefers socialTitle/socialDescription/socialImage, falling back to title/excerpt/featuredImage. Both resolvers are also exported directly for custom metadata generation:

import {
  resolveSearchMetadata,
  resolveSocialMetadata,
} from '@fullstackdatasolutions/articles/server'

const { title, description } = resolveSearchMetadata(article, config)
const {
  title: ogTitle,
  description: ogDescription,
  imageUrl,
} = resolveSocialMetadata(article, config.siteUrl)

A blank string (searchTitle: "") is treated as unset, same as omitting the key. Recommended length limits (checked by validateArticles, warnings only): searchTitle 60 chars, searchDescription 160 chars, socialTitle 95 chars, socialDescription 200 chars.

Series as a navigable reader journey

series (a display label) keeps working exactly as before. Two new optional fields turn it into a real, orderable journey:

---
series: New GM Starter Path # label-only, unchanged
seriesSlug: new-gm-path # machine-safe id
seriesOrder: 2 # position within the series
---
import {
  getArticlesBySeries,
  getAdjacentArticlesInSeries,
  generateSeriesStaticParams,
  generateSeriesMetadata,
} from '@fullstackdatasolutions/articles/server'
import { SeriesArticlesPage } from '@fullstackdatasolutions/articles'

// app/articles/series/[series]/page.tsx
export async function generateStaticParams() {
  return generateSeriesStaticParams(siteConfig)
}

export async function generateMetadata({ params }: { params: Promise<{ series: string }> }) {
  const { series } = await params
  return generateSeriesMetadata(series, siteConfig)
}

export default async function SeriesPage({ params }: { params: Promise<{ series: string }> }) {
  const { series } = await params
  const articles = await getArticlesBySeries(series, siteConfig)
  return <SeriesArticlesPage seriesSlug={series} articles={articles} config={siteConfig} />
}

getArticlesBySeries(seriesSlug, config?) sorts by seriesOrder ascending, falling back to getAllArticles' date order (stable sort) for articles missing seriesOrder. getAdjacentArticlesInSeries(currentSlug, seriesSlug, config?) is a series-aware sibling of getAdjacentArticles - it walks seriesOrder within the series instead of global date order. It returns the same { previous, next } shape, so the existing ArticleNavigation component works unchanged for series navigation:

const { previous, next } = await getAdjacentArticlesInSeries(article.slug, article.seriesSlug!, siteConfig)
<ArticleNavigation previous={previous} next={next} basePath="/articles" />

Path: curated "start here" journeys

A Path is a distinct primitive from series - a curated, ordered journey that can cross series and categories, configured (not frontmatter-driven) via ArticlesConfig.paths:

const siteConfig: ArticlesConfig = {
  siteUrl: 'https://example.com',
  siteName: 'Example',
  paths: {
    'new-gm': {
      name: 'New GM Starter Path',
      promise: 'Run your first session with confidence.',
      articles: ['intro-to-gming', 'prepping-your-first-session', 'running-combat'],
      nextAction: { label: 'Get the GM checklist', href: '/lead-magnets/gm-checklist' },
    },
  },
}
import { getPath, getPathArticles } from '@fullstackdatasolutions/articles/server'

const path = getPath('new-gm', siteConfig) // PathDefinition | null
const orderedArticles = await getPathArticles('new-gm', siteConfig) // resolved Article[], path order

Missing or unpublished (draft: true) article references are not silently dropped into a dead step - they're hard failures caught by validateArticles (see below). getPathArticles itself resolves what it can (skipping unresolvable slugs) so a broken reference degrades gracefully at runtime while still failing your CI/build check.

Article-detail extension points (slots)

Four typed, optional slots on ArticleContent place consumer