GenerateSaaS

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/.mdx files in packages/content/en/blog/, locale-mirrored under packages/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:

KeyTypeDefaultDescription
postsPerPagenumber15Posts per listing page.
relatedPostsCountnumber3Related 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.enabledbooleantrueToggle per-article view counting.
viewTracking.dedupWindowHoursnumber24Hours before the same visitor is recounted.
sharePlatforms("x" | "facebook" | "linkedin" | "reddit")[]all fourRestrict 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:

FieldTypeNotes
titlestringRequired.
datestringYYYY-MM-DD; drives sort order.
descriptionstringFalls back to the first paragraph for the excerpt.
featuredbooleanSurfaces in the featured slot.
draftbooleanHides the post from listings.
image / imageAltstringFeatured image and alt text.
imagePositionImagePositionPer-post override of config.blog.imagePosition.
categorystringMust match a slug in blogCategories.
tagsstring[]Free-form.
authorstringMust match a slug in blogAuthors.
dateModifiedstringYYYY-MM-DD; emitted in JSON-LD.
seo{ title?, description?, noindex? }Per-post metadata overrides.
  • blogCategories (slugs) and blogAuthors live in packages/config/src/blog.ts. Category display copy lives in translations under blog.categories.{slug} - resolve a slug into a { slug, name, description } with the auto-imported resolveBlogCategory(t, slug) util; resolve an author with getBlogAuthor(slug).
  • Projects start with no categories and no authors. To add a category: add its slug to blogCategories, then add blog.categories.{slug}: { name, description } to packages/i18n/translations/en/web.json (only edit en; other locales are auto-translated). To add an author: add the object to blogAuthors (slug, name, with optional jobTitle, bio, avatar, and social links).
  • 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.

MethodRouteBehaviour
GET/:slugReturns the view count.
POST/trackIncrements it - skips bots, rate-limited to 30/min per IP via cacheConfig.blogViewRateLimit.
DELETE/:slugResets 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-Key header, compared timing-safely against CONTENT_API_KEY (min 16 chars; 503 if unset, 401 if wrong).
  • Exposes GET/POST/PUT/DELETE on /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 via simple-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.

On this page