Blog
Publish a file-based Markdown blog with categories, tags, search, and view tracking via config.blog.
A file-based Markdown/MDX publishing system reading @repo/content, gated by config.blog in @repo/config. Posts render identically across frontends; it ships on by default.
What you get
When config.blog.enabled is true:
- Posts:
.md/.mdxfiles inpackages/content/en/blog/, locale-mirrored underpackages/content/<locale>/blog/. - Categories, tags, author, and search routes alongside the paginated index.
- View tracking per article (deduplicated, bot-skipping).
- Social share buttons (
x,facebook,linkedin,reddit). - SEO: sitemap entries, OG/Twitter metadata, and an RSS feed (see Marketing & SEO).
When enabled is false: every blog route, the marketing-nav and footer "Blog" links, and the view-tracking API disappear.
config.blog
config.blog is a discriminated union - { enabled: false } makes TypeScript forbid every other key. The enabled shape:
| Key | Type | Default | Description |
|---|---|---|---|
postsPerPage | number | 15 | Posts per listing page. |
relatedPostsCount | number | 3 | Related posts shown beneath each article. |
imagePosition | "top" | "after-first-paragraph" | "before-first-h2" | "none" | "before-first-h2" | Where the featured image is injected into the body. |
viewTracking.enabled | boolean | true | Toggle per-article view counting. |
viewTracking.dedupWindowHours | number | 24 | Hours before the same visitor is recounted. |
sharePlatforms | ("x" | "facebook" | "linkedin" | "reddit")[] | all four | Restrict the share buttons; omit for all. |
Authoring posts
Drop a .md or .mdx file in packages/content/en/blog/ (mirror per-locale under packages/content/<locale>/blog/). Each carries YAML frontmatter:
| Field | Type | Notes |
|---|---|---|
title | string | Required. |
date | string | YYYY-MM-DD; drives sort order. |
description | string | Falls back to the first paragraph for the excerpt. |
featured | boolean | Surfaces in the featured slot. |
draft | boolean | Hides the post from listings. |
image / imageAlt | string | Featured image and alt text. |
imagePosition | ImagePosition | Per-post override of config.blog.imagePosition. |
category | string | Must match a slug in blogCategories. |
tags | string[] | Free-form. |
author | string | Must match a slug in blogAuthors. |
dateModified | string | YYYY-MM-DD; emitted in JSON-LD. |
seo | { title?, description?, noindex? } | Per-post metadata overrides. |
blogCategories(slugs) andblogAuthorslive inpackages/config/src/blog.ts. Category display copy lives in translations underblog.categories.{slug}- resolve a slug into a{ slug, name, description }with the auto-importedresolveBlogCategory(t, slug)util; resolve an author withgetBlogAuthor(slug).- Projects start with no categories and no authors. To add a category: add its slug to
blogCategories, then addblog.categories.{slug}: { name, description }topackages/i18n/translations/en/web.json(only editen; other locales are auto-translated). To add an author: add the object toblogAuthors(slug,name, with optionaljobTitle,bio,avatar, andsociallinks). - Only categories with at least one published post appear in listings and the sitemap; configured-but-empty categories are hidden.
View tracking
When viewTracking.enabled is true, each article fires a view to the internal route at packages/api/src/routes/internal/blog-views.ts, backed by the cache/Redis layer.
| Method | Route | Behaviour |
|---|---|---|
GET | /:slug | Returns the view count. |
POST | /track | Increments it - skips bots, rate-limited to 30/min per IP via cacheConfig.blogViewRateLimit. |
DELETE | /:slug | Resets the count (admin-guarded). |
Repeat views from one visitor are deduplicated within dedupWindowHours via a SHA-256 fingerprint of IP + user-agent + slug, stored with that TTL. When disabled, the endpoints short-circuit and the view UI hides - see Caching.
Content API
The Content API in @repo/api (packages/api/src/routes/public/v1/content.ts) is separately gated by config.contentApi and serves Markdown collections - blog posts plus root-level legal pages - over /v1/content for SEO crawlers, headless dashboards, and automation. Generated projects ship { enabled: false } - the route never mounts and every request 404s until you enable it (optionally with gitCommit).
- Every endpoint requires the
X-Content-Api-Keyheader, compared timing-safely againstCONTENT_API_KEY(min 16 chars;503if unset,401if wrong). - Exposes
GET/POST/PUT/DELETEon/v1/content/:collection[/:slug]plus/v1/content/blog/{categories,authors}. - Writes are rate-limited to 10/min; slugs validated against
^[a-z0-9-]+$with path-traversal confinement. - With
gitCommit: true, each write auto-commits viasimple-git; deleting a blog item clears its cached view count.
Git auto-commit needs a writable repository and filesystem. It is incompatible with read-only serverless filesystems, where commits fail silently and changes are lost on the next deploy. Set gitCommit: false and author content at build time, or self-host on a writable disk.
Frequently asked questions
How do I turn the blog off?
Set blog: { enabled: false } in packages/config/src/index.ts. All routes plus the nav and footer links vanish, and the union forbids the other keys.
Do I need the Content API to run the blog?
No. The blog reads files directly; config.contentApi is an optional external read/write API and can be disabled independently.
Where does the view count live? In the cache/Redis layer keyed by slug, not the database - so it survives without a schema migration. See Caching.