ShipAgent

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 blogs folder ships as private static assets (registered in cloudflare/site-data.json) and is read through lib/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:

  1. It sends the content and a translation prompt to POST /api/admin/translate.
  2. The route checks isAdmin(), builds the model with getLanguageModel(provider, modelId) from config/ai-providers.ts, and streams the answer back with the AI SDK's streamText.
  3. 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;
  • /blog and /glossary list pages, dated by the newest post of that type;
  • each published MDX blog file;
  • each database post with status = 'published' and visibility = '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