Skip to content

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

  1. content.config.ts(Licensees only) defines a blog_<locale> collection for every locale in i18n.locales and validates each post's frontmatter.
  2. 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.
  3. The byline comes from an account in your database, named by NUXT_BLOG_AUTHOR_EMAIL, unless a post names its own author.
  4. /blog/rss.xml, and /<locale>/blog/rss.xml for every other locale, list the 50 newest posts.
  5. The blog-post workflow 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: true keeps a post out of the listing, the tag pages, the feed and the sitemap. Its URL still renders, with noindex.
  • readingTime is minutes and optional. The generator fills it in.
  • createdAt and updatedAt are days, and the pages render them in UTC, so every reader sees the same date.
  • image.src is a Cloudflare Images delivery URL or a path under public/, as in the sample post. An Images URL gets a blurred placeholder and a smaller thumbnail on the cards, using the variants in blogImages in app/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:

  1. Add the repository secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, the same two the deploy workflow uses. The token needs the Workers AI, AI Gateway and Images permissions.
  2. Add the repository variable CLOUDFLARE_IMAGES_ACCOUNT_HASH, from Images → Overview, or set images.accountHash in scripts/blog/blog.config.mjs(Licensees only).
  3. Turn on flexible variants under Images → Variants. Cover URLs end in a variant string such as w=1200,h=630,fit=cover.
  4. 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.
  5. 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
InputWhat it takes
topicsOne post per topic, separated by ;. Empty lets the model pick one not already on main
styleA cover style from images.styles
text_model, image_modelA model from models.text and models.image
dry_runWrites 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.

regenerateWhat it does
articleRewrites the post in every locale and keeps the cover
imageDraws a new cover and rewrites its alt text in every locale
bothBoth of the above
editRuns 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.md is 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 empty SKILL.md stops 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 findQuery
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.ts and blogImages in app/app.config.ts
  • The blog links in app/layouts/default.vue and app/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.ts and build/blog-feed.ts
  • In nuxt.config.ts: the blogFeedCopy import, the handlers block for the prefixed feeds, the /blog route rules and the blog runtime config
  • content/<locale>/blog/ and the blog_<locale> collections in content.config.ts
  • scripts/blog/, the images:upload script and both blog workflows
  • blog:author in starter.config.ts, and blog and nav.blog in every locale file
  • The blog tests, test/unit/blog-*.test.ts among them

Nothing checks the list, so search the repository for blog when you are done.

Configuration

Key or variableWhereDefaultWhat it does
NUXT_BLOG_AUTHOR_EMAIL.env, WorkerunsetThe account every post is bylined with
NUXT_SITE_URL.envnoneOrigin of the feed's links, read at build. Without it, or NUXT_PUBLIC_SITE_URL on the Worker, the feed answers 500
contextblog.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
projectIdblog.config.mjs''The creator tag on uploaded images. BLOG_PROJECT_ID overrides it; empty falls back to name in package.json
models.textblog.config.mjsanthropic/claude-opus-5 firstText models a run may pick
models.imageblog.config.mjsxai/grok-imagine-image firstImage models a run may pick, with their parameters
gatewayblog.config.mjs'default'AI Gateway id. Cloudflare creates default on the first request
localesblog.config.mjsen, srThe first is written, the rest translated. Keep it in step with i18n.locales
author.name, author.toblog.config.mjs''A guest byline written into every generated post. Empty uses the account byline
article.system, article.guidelinesblog.config.mjsstaff-writer persona, []The writer's system prompt and extra rules
article.words, .description, .tagsblog.config.mjs[900, 1400], [120, 160], [3, 5]Target ranges for length, description characters and tag count, sent in the prompt
article.skillsblog.config.mjs['humanizer']Editing passes, in order
article.maxTokensblog.config.mjs16000Output cap of each text call
images.styles, images.rulesblog.config.mjsfive styles, no letteringCover style presets, and the rules added to every cover prompt
images.accountHashblog.config.mjs''Builds delivery URLs. CLOUDFLARE_IMAGES_ACCOUNT_HASH overrides it
images.deliveryBaseblog.config.mjs''A custom delivery prefix with the hash in its path. CLOUDFLARE_IMAGES_DELIVERY_BASE overrides it
images.variantblog.config.mjsw=1200,h=630,fit=coverThe variant stored in a cover's URL
blogImages.thumb, blogImages.placeholderapp/app.config.tsw=640,h=336,fit=cover, w=32,blur=25,fit=coverVariants for card thumbnails and the blurred placeholder

Good to know

  • A pull request the workflow opens gets no ci run, because GitHub starts no workflow from GITHUB_TOKEN. Close and reopen it, or push to its branch. To get checks automatically, give the Open pull request step a personal access token through GH_TOKEN, and the checkout step of blog-post-regenerate.yml the same token through token:.
  • The feed's title and description are brand.name, blog.title and blog.description from 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 locales and in BLOG_COLLECTIONS in server/utils/feed.ts(Licensees only), on top of i18n.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.maxTokens or drop the skill.

A link with a lock opens the kit’s private repository, which every licensee can read.