GenerateSaaS

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

KeyTypeDefaultDescription
sectionsContentSection[]blog, alternatives, compareSections your site publishes. Delete an entry to drop that prefix.
defaults.postsPerPagenumber15Documents per listing page.
defaults.relatedCountnumber3Related links beneath a document.
defaults.imagePositionImagePosition"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.enabledbooleantruePer-document view counting.
defaults.viewTracking.dedupWindowHoursnumber24Hours before the same visitor is recounted.

Each section entry carries five keys:

KeyTypeDescription
slugstringURL 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.
hubSlugstringcompare 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.
featuresPartial<ContentFeatures>Overrides the template's display defaults for the whole section.
noindexbooleantrue 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:

RenameHowWhat 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.redirectsNothing 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.

TemplatetocauthordatesreadingTimesharerelatedbreadcrumbs
articleononononononon
alternativesoffoffoffoffoffonoff
compareoffoffoffoffoffonoff
pageoffoffonoffoffoffon

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

RouteShowsPagination
/{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=NThe archive alone: no featured card, no popular block, no category rowsSame ladder
/{section}/category/{slug}One category, most-viewed first, with a Most popular / Newest toggleIn 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-ranking job 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.md

Behaviour

  • 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.txt and RSS. Any other unknown path renders the locale-branded 404 with noindex.
  • Section roots differ by template. An article root is the curated hub above; a page or alternatives root renders that folder's own index.md; a compare root is a hub of the pair pages its product files generate, or a ranked listicle when the folder carries an index.md - see the compare engine.
  • One featured card. The hub renders the newest featured: true document 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 of category, tag, author, search or rss.xml is 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.

MethodRouteBehaviour
GET/:section/:slugReturns the view count.
GET/ranking/:sectionReturns the precomputed popularity windows, or null before the first run.
POST/ranking/:section/refreshAsks for a rebuild, on a five-minute cooldown per section.
POST/trackIncrements it; skips bots, rate-limited to 30/min per IP.
DELETE/:section/:slugResets 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.

On this page