Content sections
Buyer-defined URL prefixes typed by config.content, each with a listing page and one of four templates, all rendered by a single shared route family.
Content lives in packages/content/{locale}/{section}/, is typed by config.content in @repo/config, and renders through one [section] route family at /{section} and /{section}/{slug}. A section is a URL prefix plus a template. Three ship configured: blog, alternatives and compare.
config.content
| Key | Type | Default | Description |
|---|---|---|---|
sections | ContentSection[] | blog, alternatives, compare | Sections your site publishes. Delete an entry to drop that prefix. |
defaults.postsPerPage | number | 15 | Documents per listing page. |
defaults.relatedCount | number | 3 | Related links beneath a document. |
defaults.imagePosition | ImagePosition | "before-first-h2" | Where a featured image lands in the body. |
defaults.sharePlatforms | ("x" | "facebook" | "linkedin" | "reddit")[] | unset (all four) | Restrict the share buttons. |
defaults.viewTracking.enabled | boolean | true | Per-document view counting. |
defaults.viewTracking.dedupWindowHours | number | 24 | Hours before the same visitor is recounted. |
Each section entry carries five keys:
| Key | Type | Description |
|---|---|---|
slug | string | URL prefix and folder name. Lowercase, hyphenated, unique, and never an app route name (listed in apps/web/lib/content/reserved.ts). A bad slug fails the build. |
template | "article" | "alternatives" | "compare" | "page" | Which template renders its documents, and what the /{slug} root shows. |
hubSlug | string | compare only. Serves the hub at /{hubSlug} while the pair pages, the folder and the copy keys stay on slug. Same rules as a slug; never another section's slug or hub. |
features | Partial<ContentFeatures> | Overrides the template's display defaults for the whole section. |
noindex | boolean | true keeps the whole section out of robots, the sitemap and llms.txt. Routes still render. |
// packages/config/src/index.ts
content: {
sections: [
{ slug: "blog", template: "article" },
{ slug: "alternatives", template: "alternatives" },
{ slug: "compare", template: "compare" }
],
defaults: {
postsPerPage: 15,
relatedCount: 3,
imagePosition: "before-first-h2",
viewTracking: { enabled: true, dedupWindowHours: 24 }
}
}Renaming a section
Old addresses are in search results and in other people's links, so a rename keeps them working. Two mechanisms, and the first is one field:
| Rename | How | What 308s, built in |
|---|---|---|
| The compare hub alone (to its keyword) | { slug: "compare", template: "compare", hubSlug: "saas-boilerplates" } - pairs, folder and copy keys stay on compare | /compare to /saas-boilerplates; /saas-boilerplates/{a}-vs-{b} to /compare/{a}-vs-{b} |
| A whole section (pages included) | Change slug, rename the folder under packages/content/{locale}/, add { from: "/old-slug", to: "/new-slug" } to content.redirects | Nothing on its own - the entry moves the prefix and everything beneath it |
Never redirect a slug you still serve
content.redirects runs before routing. An entry whose from is a live slug or hubSlug
is ignored rather than obeyed, so a stale entry cannot loop - but it is still a mistake to
delete.
Display features per template
Template defaults first, then section.features, then a document's own features frontmatter.
| Template | toc | author | dates | readingTime | share | related | breadcrumbs |
|---|---|---|---|---|---|---|---|
article | on | on | on | on | on | on | on |
alternatives | off | off | off | off | off | on | off |
compare | off | off | off | off | off | on | off |
page | off | off | on | off | off | off | on |
alternatives and compare ship every document feature off: they are marketing pages, not
posts, and a compare page is GENERATED from two product files, so a byline, a reading time
and a publish date have nothing to read.
Structured data is unaffected (the BreadcrumbList and article schema come from the route),
and the verified-date badge reads the document's lastVerified, not dates. Turn any of them
back on per section or per page.
Categories, tags, search, RSS and view counts are not flags: the category, tag, author, search and rss.xml sub-routes follow template: "article" and 404 under any other template. The archive is not a sub-route at all - it lives on the hub.
Article section routes
| Route | Shows | Pagination |
|---|---|---|
/{section} | Curated hub: featured document, Most popular, a block per category with three or more documents on the page, then All articles - the complete archive, newest first | ?page=N ladder |
/{section}?page=N | The archive alone: no featured card, no popular block, no category rows | Same ladder |
/{section}/category/{slug} | One category, most-viewed first, with a Most popular / Newest toggle | In place |
The ladder's footer row carries Load more articles on the left and numbered links on the right, both plain navigation. Load-more is the next numbered link wearing a friendlier label - a locale-aware <Link href="/{section}?page=N+1"> that navigates and re-renders the page. It hides on the last rung.
Sorting never changes a URL, anywhere. It re-queries the whole listing server-side and returns the first page in the chosen order, so a section never forks into one crawlable copy per ordering. On the hub, an active sort hides Load more articles (it navigates, and a sorted view has nowhere to navigate to) and switches the numbered controls to in-place buttons. On every other listing, load-more continues the sorted sequence and the numbers hide.
Tag, author and search listings page in place too. See the SEO kit for how each URL is indexed.
Popularity ordering
The hub blocks and the category listings order by views, not by date.
- The window cascades 7d, 30d, 90d, all time; the freshest one carrying as much of the listing as any window does wins. No window name is shown to the reader.
- A block asks for six ranked documents but takes what the ranking can field: a section where three documents have ever been read still ranks on those three. A ranking covering none of them leaves the listing in date order.
- A category block is filled, not shortened: the ranked documents lead, then the newest of whatever the ranking did not cover.
- Each render spends one cache read and computes nothing, so a project with no ranking yet renders by date at once.
- The
content-views-rankingjob rebuilds it hourly - see Background jobs and Caching.
File layout
packages/content/
en/
terms.md # root page, unchanged
blog/hello-world.md # /blog/hello-world
alternatives/index.md # /alternatives - the roundup anchored to your own product
alternatives/orbit.md # /alternatives/orbit - a conquest page, anchored to a rival
alternatives/_template.md # authoring stub, never rendered
compare/orbit.md # NOT a page: a product fact sheet
compare/skyloom.md # the pair of them generates /compare/orbit-vs-skyloom
compare/index.md # optional: turns /compare into a ranked "best {category}" listicle
ro/
blog/hello-world.mdBehaviour
- Empty sections 404. With no published document for a locale, the listing and every sub-route 404, and the section leaves the navbar, footer, sitemap,
llms.txtand RSS. Any other unknown path renders the locale-branded 404 withnoindex. - Section roots differ by template. An
articleroot is the curated hub above; apageoralternativesroot renders that folder's ownindex.md; acompareroot is a hub of the pair pages its product files generate, or a ranked listicle when the folder carries anindex.md- see the compare engine. - One featured card. The hub renders the newest
featured: truedocument above the blocks and lists every other document below, its own category count included, so a second featured document is never stranded off the page. - Locale fallback: a document missing in a locale renders the default-locale file with that locale's chrome, and stays out of that locale's sitemap so one page never gets two canonical URLs.
- Nested slugs are allowed (
blog/guides/self-hosting.md) and breadcrumbs derive from the path, but a first segment ofcategory,tag,author,searchorrss.xmlis shadowed by that route and is a guard error. _-prefixed files are authoring stubs; the collection, the translator and the Content API skip them.- A document missing a required field is noindexed and rendered degraded, never a build failure.
View tracking
Article-section documents fire a view to packages/api/src/routes/internal/blog-views.ts, backed by the cache layer rather than the database, so counts need no schema migration.
| Method | Route | Behaviour |
|---|---|---|
GET | /:section/:slug | Returns the view count. |
GET | /ranking/:section | Returns the precomputed popularity windows, or null before the first run. |
POST | /ranking/:section/refresh | Asks for a rebuild, on a five-minute cooldown per section. |
POST | /track | Increments it; skips bots, rate-limited to 30/min per IP. |
DELETE | /:section/:slug | Resets the count (admin-guarded). |
Repeat views dedupe within dedupWindowHours on a SHA-256 fingerprint of IP, user-agent, section and slug. Counter keys follow {section}:views:{slug}, with a per-day hash written beside them that only the ranking job reads. Only flat slugs are tracked, so a nested document shows no count. See Caching.
Authoring
Frontmatter, shortcodes, categories, authors, and the Content API.
Alternatives pages
The roundup and its conquest pages: frontmatter, the guard, and derived pricing.
Compare engine
Product fact sheets that generate one head-to-head per pair, behind a quality gate.
Editorial checklist
What to check before you publish a page.
SEO kit
Metadata, breadcrumbs, JSON-LD, and OG images for content pages.
Caching & Rate Limiting
The cache layer and limiter keys behind view tracking.
Internationalization
How locale-mirrored content is resolved per request.
Impersonation
Sign in as another user to reproduce their problem - how a session starts and ends, what the self-target guard covers, and what the audit log attributes.
Authoring content
Base frontmatter for every content document, in-body shortcodes, categories and authors, hand localization, and the optional Content API.