Articles — @sonordev/site-kit/articles
Publication routes and artwork (5.4.0)
Use one routing object for the stock components and their SEO helpers. Existing
sites keep /article/slug unless they opt in. basePath remains supported as a
metadata alias; basePath takes precedence when both are provided.
import { Article, ArticleList } from '@sonordev/site-kit/articles/server-ui'
import {
createPublicationRoutes, generateArticleMetadata, generateArticleSchema,
generateArticleSitemap, generateArticleStaticParams, generateRssFeed,
getArticle,
} from '@sonordev/site-kit/articles/server'
import type { PublicationRoutingOptions } from '@sonordev/site-kit/articles/server'
const siteUrl = 'https://example.com'
const routing = {
basePath: '/journal',
includeCategoryInPath: true,
categoryPath: (slug: string) => `/journal?category=${encodeURIComponent(slug)}`,
} satisfies PublicationRoutingOptions
// Cards, related stories and cluster links now use /journal/category/slug.
const article = <Article slug="build-first" routing={routing} />
const archive = <ArticleList routing={routing} />
// Use these helpers from their corresponding Next.js route exports.
const metadata = await generateArticleMetadata('build-first', { siteUrl, ...routing })
const post = await getArticle('build-first')
const schema = post ? generateArticleSchema(post, { siteUrl, ...routing }) : null
// In app/journal/[category]/[slug]/page.tsx, wrap it to pass the routing:
// export function generateStaticParams() { return generateArticleStaticParams(routing) }
const params = await generateArticleStaticParams(routing) // { category, slug }[]
const sitemap = await generateArticleSitemap(siteUrl, {
...routing,
includeCategories: false, // category filters don't need separate sitemap entries
includeClusters: false, // enable when your site implements cluster routes
})
const rss = await generateRssFeed({ siteUrl, siteName: 'The Journal', ...routing })
const href = createPublicationRoutes(routing).post({ slug: 'build-first', category: 'guides' })routing also works on PublicationLayout, PublicationSidebar, RelatedPosts, AuthorPage,
ClusterLandingPage, and ClusterNavigation. To support another article shape,
provide postPath: post => '/articles/' + encodeURIComponent(post.slug).
Callbacks return local paths; generated metadata, feeds, and sitemaps separately
honor a valid canonical_url. The same routes apply to Atom and generated
breadcrumb/cluster schemas. Supplied schema/schema_json objects stay intact, apart
from template placeholders (an Organization named "Example", example.com URLs,
{post.title}), which generateAllArticleSchemas drops. When nothing real is left,
it generates the article's schema from its own fields instead.
For custom layouts, resolveArticleArtwork(post, 'article') prefers
editorial_image, preserving an empty decorative editorial_image_alt.
resolveArticleArtwork(post, 'card') keeps the complete featured_image. Older API
responses fall back to the featured image for both. The stock UI keeps generated
Sonor cards fully visible, while manually selected photos keep their existing
layout. Social metadata and RSS enclosures use the featured share card.
The stock article renders accessible table scroll regions automatically. Custom
renderers can call wrapArticleTables(html) and include articleTableCss within their
.sk-article-content scope. Both exports are available from article/server and
article/server-ui. The wrapper preserves the original table and is safe to apply
twice. It's a layout transform, not a sanitizer; keep your existing content trust
policy. Scrollbars and keyboard focus use --sk-* tokens.
Import server components from article/server-ui
Article, ArticleList, PublicationLayout, PublicationSidebar and RelatedPosts are async
server components. The @sonordev/site-kit/articles barrel is stamped 'use client'
at build time, and an async component inside a client module is not something Next
can render — the route returns a 500.
import { Article, ArticleList } from '@sonordev/site-kit/articles/server-ui' // ✅
import { Article } from '@sonordev/site-kit/articles' // ❌ 500sThe barrel still re-exports them for backwards compatibility and they will move out
in the next major. Reach for @sonordev/site-kit/articles only for genuine client
components: TableOfContents, ArticleFAQ, AuthorCard, ServiceCallout,
NewsletterWidget.
NewsletterWidget needs either an onSubmit callback or a formSlug
pointing at a managed Sonor form (newsletter routing) — with neither it
renders nothing rather than a form that discards emails. Article mounts a
childless ArticleViewTracker client island that counts real readers (one POST
per post per session, deferred to idle); it needs SiteKitLayout's globals
and silently no-ops without them.
Data helpers live in @sonordev/site-kit/articles/server, which is server-only — a
client import there is a build error rather than a runtime one.
Sonor-managed article with SSG, topic clusters, E-E-A-T author profiles, and full SEO integration. Create posts in the Sonor dashboard — they appear on your site automatically.
Quick Start
Articles Index
// app/article/page.tsx
import { ArticleList, PublicationLayout } from '@sonordev/site-kit/articles/server-ui'
import { generatePublicationMetadata } from '@sonordev/site-kit/articles/server'
export async function generateMetadata() {
return generatePublicationMetadata({ siteName: 'My Site', siteUrl: 'https://example.com' })
}
export default function PublicationPage() {
return (
<PublicationLayout hero={{ title: 'The Journal', subtitle: 'Latest articles' }}>
<ArticleList showCategoryFilter showPagination />
</PublicationLayout>
)
}Single Post
// app/article/[slug]/page.tsx
import { Article } from '@sonordev/site-kit/articles/server-ui'
import {
generateArticleStaticParams,
generateArticleMetadata,
requireArticle,
} from '@sonordev/site-kit/articles/server'
export const generateStaticParams = generateArticleStaticParams
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const { slug } = await params
await requireArticle(slug) // 404 for an unknown slug, before anything streams
return generateArticleMetadata(slug, { siteName: 'My Site', siteUrl: 'https://example.com' })
}
export default async function Post({ params }: Props) {
const { slug } = await params
return <Article slug={slug} showRelated showToc showAuthor />
}A missing post is a 404. requireArticle, generateArticleMetadata and
Article all call Next's notFound() when the post doesn't exist, so a
mistyped or deleted post URL answers 404 with noindex. They used to render a
"Post Not Found" page on a 200 with an indexable title, which is a soft 404.
Calling requireArticle from generateMetadata is what guarantees the status:
metadata resolves before the page streams. To render your own missing-post
state instead, pass notFound: false to generateArticleMetadata (its
placeholder is marked noindex) and notFound={false} to Article.
A post with its own social card. If the post route has an
opengraph-image.tsx beside the page (see @sonordev/site-kit/og/route), pass
images: false:
return generateArticleMetadata(slug, { siteName: 'My Site', images: false })Otherwise the featured image is declared as openGraph.images, and Next lets
declared images beat the file convention: the card never ships. images: false
leaves the keys out entirely (Next checks hasOwnProperty('images'), so even
images: undefined would hide the card). sonor-setup doctor flags a post
route that has a card but still declares the image.
Topic Cluster Landing
// app/article/topics/[slug]/page.tsx
// From `article/server-ui`, not `article` — it's an async server component that fetches
// the cluster, so it must stay out of the client-side `article` barrel.
import { ClusterLandingPage } from '@sonordev/site-kit/articles/server-ui'
export default function ClusterPage({ params }: { params: { slug: string } }) {
return <ClusterLandingPage slug={params.slug} basePath="/article" />
}Components
| Component | Import | Purpose |
|---|---|---|
Article | article/server-ui | Single post with content, TOC, author, related |
ArticleList | article/server-ui | Post grid with pagination and category filter |
PublicationLayout | article/server-ui | Full layout with optional sidebar |
PublicationSidebar | article/server-ui | Categories, recent posts, tags |
RelatedPosts | article/server-ui | Related articles widget |
PublicationPage | article/server-ui | Drop-in publication index page (layout + list) |
ArticlePage | article/server-ui | Drop-in single-post page |
CategoryPage | article/server-ui | Drop-in category archive page |
ClusterLandingPage | article/server-ui | Topic cluster overview with pillar + support articles |
ClusterNavigation | article | Breadcrumb-style cluster nav |
AuthorCard | article | Author profile with E-E-A-T fields |
TableOfContents | article | Auto-generated from H2-H4 headings |
ArticleFAQ | article | FAQ section with schema |
ServiceCallout | article | CTA callout for related services |
NewsletterWidget | article | Email capture; needs onSubmit or formSlug |
Server Functions (article/server)
// Data fetching
getArticle(slug) // Single post with full data, or null
requireArticle(slug) // Same, but calls notFound() when there's no post
getAllArticleSlugs() // All published slugs (for generateStaticParams)
getArticleCategories() // Categories with post counts
getTopicCluster(slug) // Cluster with pillar + support articles
getTopicClusters() // All clusters
// Next.js integration
generateArticleStaticParams(routing?) // [{ slug }], or [{ category, slug }] with includeCategoryInPath
generateCategoryStaticParams() // Returns [{ category }]
generateAuthorStaticParams() // Returns [{ slug }]
// With no routing options, all three can be exported directly as
// `generateStaticParams`; the props Next passes are ignored.
generateArticleMetadata(slug, opts) // Next.js Metadata object; notFound() for a missing post.
// opts.images: false when the route has its own opengraph-image card
generatePublicationMetadata(opts) // Index page metadata
generateArticleCategoryMetadata(name, opts)
// Schema & SEO
generateArticleSchema(post, opts) // JSON-LD Article with FAQ
generateArticleListSchema() // JSON-LD for publication index
generateFaqSchema(items) // FAQ Page schema
generateArticleSitemap(siteUrl) // Sitemap entries for article
// Validation
validateArticleSeo(post) // Returns field-by-field SEO audit
validateSeoTitle(title, keyphrase?) // Title length + keyword checks
validateMetaDescription(desc) // Description length checkMulti-site projects
One Sonor project can serve many domains (example.com plus its city
microsites), each with its own article. A post with no site is project-wide and
shows on every host; a post tagged charlotte.example.com shows only there.
Every article read sends the site host automatically, as ?site= on GETs and a
site field on the related-posts and view-count POSTs. The host resolves from
NEXT_PUBLIC_SITE_URL, which every microsite already sets, so most sites
change nothing. To pin a host explicitly, pass site:
<ArticleList site="charlotte.example.com" /> // also Article, PublicationSidebar, PublicationLayout, RelatedPosts, ClusterLandingPage
await getArticle(slug, { site: 'charlotte.example.com' })
await getAllArticles({ site: 'charlotte.example.com' })getAllArticleSlugs() and getAllAuthorSlugs() take no arguments, so they can
still be exported as generateStaticParams. They always use
NEXT_PUBLIC_SITE_URL. Single-site projects and older API servers ignore
site.
Article Props
interface ArticleProps {
slug: string
showRelated?: boolean // Related posts section
showToc?: boolean // Table of contents
showAuthor?: boolean // Author card
unstyled?: boolean // Skip default styles
notFound?: boolean // Default true: notFound() when the post is missing. false renders a message.
className?: string
children?: (props: { post, toc, related }) => ReactNode // Render prop
}ArticleList Props
interface ArticleListProps {
category?: string // Filter by category slug
tag?: string // Filter by tag
author?: string // Filter by author
featured?: boolean // Featured posts only
search?: string // Search posts
page?: number // Default: 1
perPage?: number // Default: 12
orderBy?: 'published_at' | 'title' | 'view_count'
order?: 'asc' | 'desc'
showCategoryFilter?: boolean
showPagination?: boolean
unstyled?: boolean
className?: string
children?: (props: { posts, pagination, categories }) => ReactNode
}Key Types
interface Article {
slug: string; title: string; excerpt?: string; content: string;
featured_image?: string; author?: ArticleAuthor; category?: ArticleCategory;
tags?: string[]; meta_title?: string; meta_description?: string;
faq_items?: { question: string; answer: string }[];
article_type?: 'pillar' | 'support' | 'comparison' | 'faq' | 'glossary' | 'checklist';
cluster_slug?: string; reading_time?: number; // the API column ('X min read')
published_at?: string; status: 'draft' | 'published' | 'scheduled' | 'archived';
}
interface ArticleAuthor {
name: string; slug: string; bio?: string; avatar_url?: string;
title?: string; credentials?: string[]; expertise_areas?: string[];
years_experience?: number; is_subject_matter_expert?: boolean;
}
interface TopicCluster {
cluster_name: string; cluster_slug: string; core_topic: string;
geo_target?: string; target_service_page?: string; article_count: number;
pillar: Article | null; supports: Article[];
}Styling
Components use .sk-article-* and .sk-article-list-* classes. Import default styles:
Or use unstyled prop + children render prop for complete control.