CMS
The blog and glossary content system - its tables, statuses and visibility, MDX files in the repo, the admin editor, AI translation and the sitemap.
The site has two content types, blog and glossary. Both are stored in one posts table and edited in the admin dashboard with a TipTap editor. Blog posts can also be MDX files committed to the repository. Published public posts go into the sitemap.
For the day-to-day use of the editor, see CMS. This page covers the code.
| Layer | Files |
|---|---|
| Tables | posts, tags, post_tags in lib/db/schema.ts |
| Per-type settings | components/cms/post-config.ts (POST_CONFIGS) |
| Reading (MDX files plus database) | lib/cms/index.ts (blogCms, glossaryCms) |
| Server Actions | actions/posts/posts.ts, actions/posts/tags.ts, actions/posts/views.ts |
| Admin pages | app/[locale]/(protected)/dashboard/admin/blogs/, …/admin/glossary/ |
| Public pages | app/[locale]/(basic-layout)/blog/, …/glossary/ |
| Editor | components/tiptap/ |
| MDX files | blogs/<locale>/*.mdx |
| Sitemap | app/sitemap.ts |
Tables
posts:
| Column | Meaning |
|---|---|
language |
the locale code, for example en or ja |
post_type |
blog or glossary |
author_id |
the admin who wrote it |
title, slug, description, content |
content is the editor's Markdown/HTML |
featured_image_url |
cover image |
is_pinned |
pinned posts sort first |
status |
draft, published, archived |
visibility |
public, logged_in, subscribers |
published_at, created_at, updated_at |
timestamps with time zone |
The pair (language, slug, post_type) is unique, so the same slug can exist once per language: an English post and its Japanese translation share the slug.
tags has a name and a post_type (each content type has its own tags, unique by name within a type). post_tags joins posts and tags; deleting either removes the link. The editor allows up to 5 tags per post.
Status and visibility
Only published posts are shown on the site. draft and archived are visible in the admin only.
| Visibility | Who sees the content |
|---|---|
public |
everyone |
logged_in |
signed-in users; others see components/cms/ContentRestrictionMessage.tsx |
subscribers |
users whose latest personal subscription is active or trialing and whose period has not ended |
The check is checkUserSubscription in actions/posts/posts.ts, marked TODO: [custom]: it looks at personal subscriptions only, not team subscriptions or one-time purchases such as licenses. Change it there if your product gates content differently. The title, description and cover stay visible for restricted posts; only the content is withheld.
MDX posts in the repository
POST_CONFIGS.blog.localDirectory is 'blogs', so blog posts are also read from blogs/<locale>/*.mdx. The repo ships demo files for en, zh and ja. Glossary entries come from the database only.
Front matter:
---
title: My first post
description: One sentence for the list and the meta description.
slug: /my-first-post
tags: nextjs,i18n
publishedAt: 2026-11-08
status: published # draft | archived | published (published is the default)
isPinned: false
---
## Introduction
The post body in Markdown.
- Lookup by slug: a matching non-draft MDX file wins; otherwise the database post is used.
- The list page: shows the published MDX files and the published database posts, pinned first, then newest first.
- On Cloudflare Workers, which have no file system, the
blogsfolder ships as private static assets (registered incloudflare/site-data.json) and is read throughlib/site-files.ts. Visitors cannot fetch those files directly.
The demo MDX files are status: draft, so they do not appear on the site. Delete them or replace them with your own.
Admin pages
Under /dashboard/admin (admins only):
| Page | What you can do |
|---|---|
/dashboard/admin/blogs |
list, search by title, slug or description, create, edit, duplicate, delete; manage tags |
/dashboard/admin/glossary |
the same for glossary entries |
/dashboard/admin/r2-resources |
the R2 media library the editor's image picker reads from |
Every create, update and delete action in actions/posts/posts.ts checks isAdmin(). Saving revalidates the list path and the post path for the post's language.
The editor (components/tiptap/TiptapEditor.tsx) supports image grids, tables, a table of contents on the public page, and cover images by upload, URL or R2 picker. Related posts (same tag) show under each post.
View counts
Off by default (viewCount.enabled: false per type in post-config.ts). When turned on it needs Redis. Mode all counts every page load; unique counts one view per IP per hour.
AI translation
The editor's translate button (components/tiptap/TranslationButton.tsx) translates the current content into any of the site's locales:
- It sends the content and a translation prompt to
POST /api/admin/translate. - The route checks
isAdmin(), builds the model withgetLanguageModel(provider, modelId)fromconfig/ai-providers.ts, and streams the answer back with the AI SDK'sstreamText. - The editor content is replaced as the text streams in. If it fails, the original content is restored.
The model is set by two variables: NEXT_PUBLIC_AI_PROVIDER (one of openai, deepseek, anthropic, xai, google, per .env.example) and NEXT_PUBLIC_AI_MODEL_ID. That provider's API key must be set too (see AI).
The button translates in place. To publish a translation as its own post, create a post in the target language with the same slug and paste the result there.
Sitemap
app/sitemap.ts lists, for every locale in LOCALES (the default locale without a prefix, others under /<locale>):
- the home page;
/blogand/glossarylist pages, dated by the newest post of that type;- each published MDX blog file;
- each database post with
status = 'published'andvisibility = 'public', for both blog and glossary.
Drafts, archived posts and logged_in or subscribers posts are left out. The sitemap reads the database on each request, so a new post appears without a deploy. A tool page you add with the add-tool-page skill gets its own sitemap entry from that skill.
When it fails
| Symptom | Likely cause | Fix |
|---|---|---|
| A post is missing from the site | status is not published, or it is in another language |
Check status and language in the admin list |
| The same slug shows an old version | An MDX file with that slug exists and wins over the database post | Delete or rename the MDX file |
| Translate button shows an error | NEXT_PUBLIC_AI_PROVIDER / NEXT_PUBLIC_AI_MODEL_ID empty, or that provider's key is missing |
Set both and the key, rebuild (the NEXT_PUBLIC_ values are read at build time) |
| A subscriber sees "subscribers only" | They subscribe through a team, or bought a one-time plan | Adapt checkUserSubscription in actions/posts/posts.ts |