@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.
Maintainers
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/articles2. 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/typography3. 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 middlewareThis 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.tsThe 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:
authors: [...]frontmatterauthor: ...frontmatterdefaultAuthorfromArticlesConfig
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 -
ArticlesConfiginstance (required). Must includesiteUrlandsiteNameat minimum. - returns - Object with
GETasync function matching Next.js Route Handler signature. - The handler extracts slug from
params['slug']and strips.mdextension if present. - Uses
getArticleMarkdownResponsefrom the server utilities to fetch and return markdown. - Only returns markdown for articles with
aiCrawl: truein 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 -
NextRequestobject (required). - apiBase - Base path to your markdown handler. Default:
'/api/articles-markdown' - config - Anything carrying
articleBasePath, e.g. yourArticlesConfig. Omit it when articles are served under the default/articles. - returns -
NextResponseif the request matches an article.mdpath,undefinedotherwise. - 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.mdrewrites 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.matcherautomatically 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 middlewareUse 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.jpgno matter what this is set to. The exportedARTICLE_ASSET_BASE_PATHis that fixed prefix. - Your markdown-twin API route. Only the public
.mdURL moves; pair it withmarkdownTwinMatcher(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
@sourceis 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
authorshas exactly one entry and that author'sAuthorProfile.promiseis set,ArticleDetailHerorenders it as a one-sentence line under the byline. Omitted for zero or multiple authors, or whenpromiseis 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
previousandnextarenull.
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 orderMissing 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
