ShipAgent

Blog and glossary

Write and publish blog and glossary posts from the admin dashboard or as MDX files - fields, status, visibility, tags, images and the translate button.

The site has two kinds of posts:

Kind Public pages Admin Also as files
Blog /blog, /blog/<slug> Dashboard → Admin → Blogs (/dashboard/admin/blogs) yes, blogs/<code>/*.mdx
Glossary /glossary, /glossary/<slug> Dashboard → Admin → Glossary (/dashboard/admin/glossary) no

The admin pages need an admin account (see Run locally for creating the first one with pnpm create-superadmin <email>). Uploading images needs storage (Storage); the translate button needs an AI model.

You can also ask the agent to write posts. It writes MDX files under blogs/ or uses the same server actions the admin uses (actions/posts/).

Writing a post in the admin

Dashboard → Admin → Blogs → Create New Blog (Glossary works the same way). The list page shows every post with its language, status, visibility and tags, and has a search field and a Manage Tags button.

The form:

Field Notes
Language one of the eight site languages. A post exists in one language; a translation is a separate post with the same slug and another language
Title at least 3 characters
Slug the URL part. Generate builds it from an English title; type it by hand for other languages
Description the summary on cards and in search results
Tags up to 5 per post; create new ones with Manage Tags
Featured image optional. Upload one, or leave it empty and the post gets a generated preview image with its title and your logo.png
Content the rich-text editor (below)
Status draft, published or archived. Only published posts appear on the site
Visibility who can read it (below)
Pin post shows the post at the top of the list

Saving a post refreshes its list page and its own page; no deploy is needed.

Visibility

Value Who reads the post
public everyone
logged_in signed-in users; others see a sign-in prompt
subscribers users with an active or trialing subscription; others see an upgrade prompt

Posts that are not public are kept out of search results (noindex). Note that subscribers checks for a subscription: a customer who bought a one-time license or a credit pack does not count. To change that rule, ask the agent to edit checkUserSubscription in actions/posts/posts.ts.

The editor

The toolbar has bold, italic, underline, headings, lists, quotes, code, a divider, links and tables, plus:

  • Image: paste an image URL, or upload one from your computer.
  • R2: pick images you already uploaded to your storage bucket.
  • Image grid: several images side by side, with captions.
  • Video: embed a YouTube video by its URL.
  • Translate: see below.

The editor toolbar with the Insert Image dialog: an image URL or an upload

The translate button

The last toolbar button translates the editor content into another site language with an AI model (components/tiptap/TranslationButton.tsx, route /api/admin/translate, admin only).

It needs two variables in .env.local and in the deployed env:

NEXT_PUBLIC_AI_PROVIDER=openai      # openai, deepseek, anthropic, xai or google
NEXT_PUBLIC_AI_MODEL_ID=<a text model id of that provider>

and that provider's API key from the AI integration. Without them the button is replaced by an info icon.

The translate menu in the editor toolbar

The menu lists every site language (the screenshot shows three). The translation replaces the content in the editor; title, slug, description and tags stay as they are. To publish a translation:

  1. Open the original post and copy its content, or start a new post and paste it.
  2. Set Language to the target language.
  3. Press translate, choose the language, wait for the text to stream in.
  4. Translate the title and description yourself (or ask the agent), keep the same slug, and save as a new post.

If the translation fails, the editor puts the original content back and shows the provider's error.

Writing a post as a file

Blog posts can also be MDX files in the repo, one folder per language: blogs/en/, blogs/zh/, blogs/ja/ and so on. The starter ships blogs/en/1.demo.mdx as an example:

---
title: demo
description: it is a description
slug: /demo
tags: nextjs,i18n,mdx,starter,robots,sitemap
publishedAt: 2025-02-16
status: draft
# status: draft/archived/published (published is default)
isPinned: false
---

## Introduction

demo
Frontmatter Notes
title, description as in the admin
slug with a leading slash; the post's URL is /blog/<slug>
tags comma-separated
publishedAt the date shown and used for sorting
status published when left out; draft and archived hide the post
isPinned true puts the post at the top
featuredImageUrl optional cover image URL

Differences from admin posts:

  • A file goes live only with a deploy (on Cloudflare: the next daily run, or a manual one).
  • File posts are listed together with admin posts. If a file and an admin post share a slug in the same language, the file wins.
  • visibility is not enforced for files: every file post is public. Use the admin for restricted posts.
  • Glossary entries have no file option.

On Cloudflare Workers the blogs folder ships with the build because it is listed in cloudflare/site-data.json. A new content folder must be added there too, or it is empty in production.

When it fails

What you see Cause Fix
The Admin menu is missing from the dashboard your account is not an admin pnpm create-superadmin <email>
A saved post does not show on /blog its status is draft or archived, or its language is not the one you are viewing set it to published; check the language prefix in the URL
Image upload fails storage is not configured finish the Storage setup
The translate button is an info icon NEXT_PUBLIC_AI_PROVIDER or NEXT_PUBLIC_AI_MODEL_ID is empty set both and restart pnpm dev (redeploy in production)
A file post shows locally but not in production it was not committed and deployed, or its folder is missing from cloudflare/site-data.json commit, deploy, register the folder
A subscribers post is locked for a customer who paid the customer bought a license or credit pack, not a subscription change checkUserSubscription, or use logged_in