Content and look
Blog
The blog is Nuxt Content: Markdown posts in content/<locale>/blog/, served at
/blog and /<locale>/blog, with an RSS feed per language. Two GitHub
workflows can also write posts with an AI model and open each one as a pull
request. You work here when you write or review posts, set the byline, or tune
the generator.
How it works
content.config.ts(Licensees only) defines ablog_<locale>collection for every locale ini18n.localesand validates each post's frontmatter.- The pages in
app/pages/blog/(Licensees only) list posts, render a post, and list posts per tag. Route rules cache them for an hour, stale-while-revalidate. - The byline comes from an account in your database, named by
NUXT_BLOG_AUTHOR_EMAIL, unless a post names its own author. /blog/rss.xml, and/<locale>/blog/rss.xmlfor every other locale, list the 50 newest posts.- The
blog-postworkflow writes a post in every language through Cloudflare AI Gateway, draws a cover, uploads it to Cloudflare Images and opens a pull request. Nothing is published until you merge it.
Write a post
The file name is the slug. content/en/blog/first-release.md is served at
/blog/first-release:
---
title: Shipping the first release
description: What went into 1.0, and what we cut to get there.
image:
src: https://imagedelivery.net/<hash>/<image-id>/w=1200,h=630,fit=cover
alt: A cardboard box on a loading dock at dawn.
createdAt: 2026-09-28
updatedAt: 2026-09-28
tags:
- release
- product
---
The body is Markdown with MDC components.
draft: truekeeps a post out of the listing, the tag pages, the feed and the sitemap. Its URL still renders, withnoindex.readingTimeis minutes and optional. The generator fills it in.createdAtandupdatedAtare days, and the pages render them in UTC, so every reader sees the same date.image.srcis a Cloudflare Images delivery URL or a path underpublic/, as in the sample post. An Images URL gets a blurred placeholder and a smaller thumbnail on the cards, using the variants inblogImagesinapp/app.config.ts(Licensees only).
Translate a post by adding the same file name under content/sr/blog/. A post
with no translation is served from the default locale at the prefixed URL, and
listed there too.
Set the byline
NUXT_BLOG_AUTHOR_EMAIL=you@example.com
Every post shows that account's name and the avatar it uploaded, so renaming
the account or changing its photo updates every post. The account's role must
grant blog:author, which staff and superadmin do in the shipped
access.rolePermissions. If the address has no account, or the role lacks the
permission, a post shows a byline only when its frontmatter names an author.
pnpm dev logs a [blog] warning saying which case it is. The variable is read at
startup, so restart the dev server after changing it.
A guest post carries an author block, which wins over the account:
author:
name: Ada Lovelace
to: https://example.com/ada
A guest byline shows initials. There is no avatar field, because the content security policy admits images only from the app and the Images delivery host.
Generate posts with AI
Set this up once:
- Add the repository secrets
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID, the same two the deploy workflow uses. The token needs the Workers AI, AI Gateway and Images permissions. - Add the repository variable
CLOUDFLARE_IMAGES_ACCOUNT_HASH, from Images → Overview, or setimages.accountHashinscripts/blog/blog.config.mjs(Licensees only). - Turn on flexible variants under Images → Variants. Cover URLs end in a
variant string such as
w=1200,h=630,fit=cover. - Load credits under AI Gateway → Credits. The default models, Claude Opus
and Grok Imagine, are billed to your Cloudflare account through Unified
Billing, which is prepaid. Workers AI models (
workers-ai/@cf/…) are billed as ordinary Workers AI usage and need no credits. - Turn on Settings → Actions → General → Allow GitHub Actions to create and approve pull requests.
Then run Actions → blog-post → Run workflow, or from a terminal:
gh workflow run blog-post -f topics="Caching on Workers; Picking a payment provider" -f style=isometric
| Input | What it takes |
|---|---|
topics | One post per topic, separated by ;. Empty lets the model pick one not already on main |
style | A cover style from images.styles |
text_model, image_model | A model from models.text and models.image |
dry_run | Writes fixture posts without calling any API |
Each topic runs as its own job, one after another, and opens a pull request
from blog/<slug> holding content/<locale>/blog/<slug>.md for every locale
in the generator's locales. The pull request body shows the cover, the style
and both models. The article, its translations and its metadata are all
written before the cover is drawn, so a run that fails on the text never pays
for an image.
The workflow only runs when you start it. For a weekly post, add a schedule
trigger to blog-post.yml(Licensees only).
To run the generator on your machine, put the two Cloudflare variables in
.env. It writes the files into content/ and opens no pull request:
node --env-file-if-exists=.env scripts/blog/generate-post.mjs --topic "Caching on Workers" --style minimal
It also takes --text-model, --image-model and --dry-run.
Regenerate or edit a post under review
Run Actions → blog-post-regenerate with the pull request's branch
(blog/<slug>), what to regenerate, and optional feedback for the prompt. It
commits to that branch, so the pull request updates in place.
regenerate | What it does |
|---|---|
article | Rewrites the post in every locale and keeps the cover |
image | Draws a new cover and rewrites its alt text in every locale |
both | Both of the above |
edit | Runs the editing skills over the post as it stands, with your feedback, and keeps any hand edits |
A replaced cover is deleted from Images once the new one is on the branch.
edit leaves the cover alone and changes only updatedAt and readingTime in
the frontmatter. To edit a post that is already merged, push a blog/<slug>
branch from main, run edit on it, then open a pull request from that
branch.
Add an editing skill
After the article is written, and again after each translation, the generator
runs the passes listed in article.skills. Each is a folder in
scripts/blog/skills/(Licensees only) holding a SKILL.md, which
becomes the system prompt of one call that edits the post. The kit ships
humanizer, which removes common patterns of AI writing.
For a house style guide, write its rules into
scripts/blog/skills/house-style/SKILL.md and add the folder name:
article: {
skills: ['humanizer', 'house-style'],
},
- Passes run in array order, each on the previous one's output, so the pass that should have the last word goes last.
- Write the rules for editing a finished post. Frontmatter in
SKILL.mdis dropped, and nothing else in the folder is read or run. - A folder name must match
/^[a-z0-9][a-z0-9-]*$/. A missing or emptySKILL.mdstops the run before its first model call. - A pass that changes a fenced code block is discarded with a workflow warning, and the post keeps the text it had before that pass.
- Each skill costs one call per locale that sends the skill and the whole
article.
skills: []turns the passes off.
Add a model or a cover style
Add it to models.text, models.image or images.styles in blog.config.mjs,
and to the matching options list in both
blog-post.yml(Licensees only) and
blog-post-regenerate.yml(Licensees only).
The first entry is the default in each.
test/unit/blog-workflow-choices.test.ts(Licensees only)
fails when the config and the workflows disagree.
Models are named {provider}/{model} from Cloudflare's
model catalog. An image model
maps to the parameters sent with it, unchanged:
image: {
'xai/grok-imagine-image': { aspect_ratio: '16:9', response_format: 'b64_json' },
'workers-ai/@cf/black-forest-labs/flux-1-schnell': { steps: 8 },
},
Upload images yourself
pnpm images:upload tags images the way the generator does and prints their
delivery URLs:
pnpm images:upload ./diagram.png ./screenshot.png
pnpm images:upload ./cover.png --kind cover --slug hello-world --replace
It reads CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN and the account hash
from .env. The token needs the Images permission, which a wrangler login
session does not carry. --replace deletes the images already in that slot,
meaning the same --slug and the same --kind or none, and it takes one file
and requires --slug. --variant changes the variant in the printed URL.
Every image gets an id from Cloudflare, your project id as its creator, and
kind and slug as metadata. List Images V2 finds them:
| To find | Query |
|---|---|
| Every image of this project | ?creator=<project-id> |
| Every blog cover | &meta.kind[eq]=cover |
| One post's images | &meta.slug[eq]=<slug> |
The project id is BLOG_PROJECT_ID, then projectId in blog.config.mjs,
then name in package.json. An image dragged into the dashboard carries no
tags and never shows up in these queries.
Remove the blog
There is no switch in starter.config.ts, so removing the blog means deleting
it. Keep Nuxt Content, content.config.ts and content/<locale>/legal/: the
legal pages use them.
app/pages/blog/,app/composables/useBlogAuthor.ts,app/utils/byline.ts,app/components/BlurImage.vue,app/components/content/ProseImg.vue,app/utils/cf-images.tsandblogImagesinapp/app.config.ts- The blog links in
app/layouts/default.vueandapp/pages/index.vue server/api/blog/,server/routes/blog/,server/routes/[locale]/blog/,server/utils/feed.ts,server/types/platform-feed.ts,shared/utils/blog-posts.tsandbuild/blog-feed.ts- In
nuxt.config.ts: theblogFeedCopyimport, thehandlersblock for the prefixed feeds, the/blogroute rules and theblogruntime config content/<locale>/blog/and theblog_<locale>collections incontent.config.tsscripts/blog/, theimages:uploadscript and both blog workflowsblog:authorinstarter.config.ts, andblogandnav.blogin every locale file- The blog tests,
test/unit/blog-*.test.tsamong them
Nothing checks the list, so search the repository for blog when you are done.
Configuration
| Key or variable | Where | Default | What it does |
|---|---|---|---|
NUXT_BLOG_AUTHOR_EMAIL | .env, Worker | unset | The account every post is bylined with |
NUXT_SITE_URL | .env | none | Origin of the feed's links, read at build. Without it, or NUXT_PUBLIC_SITE_URL on the Worker, the feed answers 500 |
context | blog.config.mjs | '' | One sentence on what the site is, sent with every prompt. BLOG_CONTEXT overrides it; empty falls back to description in package.json |
projectId | blog.config.mjs | '' | The creator tag on uploaded images. BLOG_PROJECT_ID overrides it; empty falls back to name in package.json |
models.text | blog.config.mjs | anthropic/claude-opus-5 first | Text models a run may pick |
models.image | blog.config.mjs | xai/grok-imagine-image first | Image models a run may pick, with their parameters |
gateway | blog.config.mjs | 'default' | AI Gateway id. Cloudflare creates default on the first request |
locales | blog.config.mjs | en, sr | The first is written, the rest translated. Keep it in step with i18n.locales |
author.name, author.to | blog.config.mjs | '' | A guest byline written into every generated post. Empty uses the account byline |
article.system, article.guidelines | blog.config.mjs | staff-writer persona, [] | The writer's system prompt and extra rules |
article.words, .description, .tags | blog.config.mjs | [900, 1400], [120, 160], [3, 5] | Target ranges for length, description characters and tag count, sent in the prompt |
article.skills | blog.config.mjs | ['humanizer'] | Editing passes, in order |
article.maxTokens | blog.config.mjs | 16000 | Output cap of each text call |
images.styles, images.rules | blog.config.mjs | five styles, no lettering | Cover style presets, and the rules added to every cover prompt |
images.accountHash | blog.config.mjs | '' | Builds delivery URLs. CLOUDFLARE_IMAGES_ACCOUNT_HASH overrides it |
images.deliveryBase | blog.config.mjs | '' | A custom delivery prefix with the hash in its path. CLOUDFLARE_IMAGES_DELIVERY_BASE overrides it |
images.variant | blog.config.mjs | w=1200,h=630,fit=cover | The variant stored in a cover's URL |
blogImages.thumb, blogImages.placeholder | app/app.config.ts | w=640,h=336,fit=cover, w=32,blur=25,fit=cover | Variants for card thumbnails and the blurred placeholder |
Good to know
- A pull request the workflow opens gets no
cirun, because GitHub starts no workflow fromGITHUB_TOKEN. Close and reopen it, or push to its branch. To get checks automatically, give the Open pull request step a personal access token throughGH_TOKEN, and the checkout step ofblog-post-regenerate.ymlthe same token throughtoken:. - The feed's title and description are
brand.name,blog.titleandblog.descriptionfrom each locale file, read at build. Keep them plain text: a{,},@or|in one fails the build. - A new language needs its code in the generator's
localesand inBLOG_COLLECTIONSinserver/utils/feed.ts(Licensees only), on top ofi18n.locales(Languages). - On a model with a small context window, such as Workers AI's Llama 3.3 at
24,000 tokens, the humanizer (about 7,000 tokens), the article and a
16,000-token reply don't fit. Lower
article.maxTokensor drop the skill.
A link with a lock opens the kit’s private repository, which every licensee can read.