# Put it live Source: https://docs.agentblog.dev/deploy Summary: Deploy the blog, set the two environment variables it needs, and verify that a crawler can actually read the result. Your blog deploys with the rest of your app. There is nothing to host separately. What this page covers is the small amount of configuration that only matters once the site is public, and the one check worth running the day you ship. ## Environment variables [#environment-variables] | Variable | Where | Needed for | | ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------ | | `AGENTBLOG_REVALIDATE_SECRET` | Production and preview | The publish webhook. Without it, `/api/publish` refuses every request | | `INDEXNOW_KEY` | Production | Telling Bing, Yandex, Seznam, and Naver about a new post the same day | | `AGENTBLOG_PUBLIC_SITE` | Production, on non-Vercel hosts | Allowing crawlers at all. Read the warning below before skipping it | | `AGENTBLOG_DEPLOY_WAIT_MS` | Optional | How long the publish webhook waits for a deploy hook. Defaults to 45000 milliseconds | `agentblog doctor --fix` generates the first two into `.env.local` if they are missing, and writes the `public/.txt` file IndexNow requires. Copy both values into your host's environment settings. If the key file and `INDEXNOW_KEY` disagree, IndexNow rejects every submission, and `doctor` reports it. `app/robots.ts` decides whether to allow crawling from the deployment environment, and it fails closed. Vercel sets `VERCEL_ENV` for it. Nothing else does, so on any other host you must set `AGENTBLOG_PUBLIC_SITE=true` in production or your live site serves a blanket `Disallow: /`. Failing closed is deliberate. Guessing permissively gets a staging site indexed and competing with your production pages, which takes weeks to undo. Guessing restrictively costs one environment variable and is visible the moment anyone opens `/robots.txt`. Preview deployments stay closed to crawlers with no configuration, which is what you want: two indexable copies of the same post compete with each other. ## The day you first deploy [#the-day-you-first-deploy] ### Check `robots.txt` by eye [#check-robotstxt-by-eye] Open `https://yoursite.com/robots.txt`. If you see `Disallow: /`, read the warning above. This takes five seconds and it is the failure that costs the most. ### Check what a crawler receives [#check-what-a-crawler-receives] ```bash npx agentblog@latest doctor --url https://yoursite.com/blog/your-post ``` This fetches your live URL five times, once each as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Googlebot, and asserts every one of them gets a 200 with the article text present and the `` inside `<head>`. Run it from your own machine or from CI, never from inside the deployment. The request originates wherever the CLI runs, and a request from inside your network can bypass the exact CDN rule the check exists to find. If any crawler comes back blocked, go to [when your CDN blocks crawlers](/troubleshooting/cdn-blocking-crawlers). This is common, it is not your install, and it is fixable. </Step> <Step> ### Submit the sitemap [#submit-the-sitemap] Add `https://yoursite.com/sitemap.xml` in [Google Search Console](https://search.google.com/search-console) and [Bing Webmaster Tools](https://www.bing.com/webmasters). You only do this once. Verification tokens go in `agentblog.config.ts` under `verification`, so they land in your root layout rather than in a file you have to keep. </Step> </Steps> ## Tell search engines about a new post [#tell-search-engines-about-a-new-post] Publishing an MDX post is a git push, and the deploy rebuilds everything, so the sitemap and the feed are current the moment the deploy finishes. That is enough. To also push the URL rather than wait to be crawled: ```bash npx agentblog@latest ping do-ai-crawlers-run-javascript ``` That calls your own `/api/publish` endpoint, which revalidates the post, the index, the category and tag pages, the sitemap, and the feed, and then submits the URL to IndexNow. It prints the IndexNow response code with its meaning attached, because a 403 for an invalid key and a 200 look identical from the caller's side otherwise. The endpoint takes `{ "slug": "my-post" }` and the shared secret, so a CMS webhook or a publishing agent can call it directly: ```bash curl -X POST https://yoursite.com/api/publish \ -H "content-type: application/json" \ -H "authorization: Bearer $AGENTBLOG_REVALIDATE_SECRET" \ -d '{"slug":"do-ai-crawlers-run-javascript"}' ``` <Callout title="Why the endpoint revalidates more than the post"> `sitemap.xml` and `feed.xml` are cached route handlers. A publish step that revalidates only the post pages leaves both serving their build-time output, and then pings IndexNow about a URL your own sitemap does not list. You end up telling a search engine that a page is new and important and that it does not exist on your site. The endpoint revalidates both explicitly for that reason. </Callout> ## Keeping it healthy [#keeping-it-healthy] Add `doctor` to your CI, where it costs nothing and catches a config change someone made for another reason: ```bash npx agentblog@latest doctor --offline ``` `--offline` skips the network checks, which is what you want on every commit. Run the `--url` form on a schedule instead, weekly is plenty, because the thing it catches is a CDN rule someone else changed. <Cards> <Card title="Measure what AI traffic arrives" href="/guides/measure-ai-traffic" description="Separating assistant referrals from ordinary search in the analytics you already run." /> <Card title="Troubleshooting" href="/troubleshooting" description="The failures that actually happen after a deploy." /> </Cards> --- # Write your first post Source: https://docs.agentblog.dev/first-post Summary: Scaffold a post file, have your coding agent draft it in the format AI search engines can use, and check it before you publish. A post is one MDX file in `content/blog/`. You create the file, your agent writes the body, and `agentblog audit` tells you whether it is ready. This page walks through all three. <Steps> <Step> ### Create the file [#create-the-file] ```bash npx agentblog@latest new "Do AI crawlers run JavaScript?" --author you --category ai-search ``` That writes `content/blog/do-ai-crawlers-run-javascript.mdx` with complete frontmatter, today's date with a UTC offset, and `draft: true`. It does not write the post. <Callout type="warn" title="Pass --author and --category"> `new` does not read `agentblog.config.ts`, so without those two flags it writes the placeholders `author: your-name` and `category: general`. Neither names a real record, and `draft: true` does not save you: drafts are validated like every other post, so the next build fails with `unknown author slug`. Both values must exist in `content/authors.json` and `content/categories.json`. </Callout> The file name is the slug. `do-ai-crawlers-run-javascript.mdx` is served at `/blog/do-ai-crawlers-run-javascript`. Rename the file to change the URL. </Step> <Step> ### Ask your agent to write it [#ask-your-agent-to-write-it] ```text Write the post at content/blog/do-ai-crawlers-run-javascript.mdx. Follow the write-blog-post skill. ``` Your coding agent picks up the `write-blog-post` skill that AgentBlog installed into `.claude/skills/`, reads your existing posts to match your voice and link into them, and drafts the body in the format described below. Two rules are in that skill's always-loaded context rather than in a file it might not read: never invent a statistic, a quotation, or a source, and never use an em dash. Both are checked again by `agentblog audit`. You are still the editor. Read the draft, fix what is wrong, and cut what is padding. </Step> <Step> ### Check it before publishing [#check-it-before-publishing] ```bash npx agentblog@latest audit do-ai-crawlers-run-javascript ``` Every check reports pass or fail with the value it actually found, and the command never reports success on a failure. Run it until it is clean, then set `draft: false` and commit. </Step> </Steps> ## What goes in the frontmatter [#what-goes-in-the-frontmatter] Here is the top of a real post. Every field below `tags` is optional, and the ones you skip cost you specific things rather than breaking the build. ```yaml title="content/blog/do-ai-crawlers-run-javascript.mdx" --- title: Do AI Crawlers Run JavaScript? description: A description between 50 and 160 characters, because that is what fits in a search result. answerCapsule: >- Forty to sixty words that answer the title directly, with no links and no hedging. This is the paragraph an assistant lifts when it quotes you. datePublished: 2026-08-06T09:00:00Z dateModified: 2026-08-06T09:00:00Z author: editorial category: ai-search tags: - AI crawlers - GPTBot relatedPosts: - what-makes-an-ai-engine-cite-your-post citations: - name: The rise of the AI crawler url: https://vercel.com/blog/the-rise-of-the-ai-crawler author: Vercel and MERJ datePublished: 2024-12-17 kind: industry faq: - question: Do AI crawlers execute JavaScript? answer: >- No. Answer in two or three sentences, complete on its own. draft: true --- ``` The full field list, with the rules on each one, is in the [post frontmatter reference](/reference/post-frontmatter). Three of these are worth understanding on the first post: **`answerCapsule`** is the direct answer that renders under the H1. It is the most valuable field in the file, because a retrieval system lifts a chunk of your page and quotes it, and this paragraph is written to be that chunk. **`author` and `category`** are references, not free text. Each must name a record in `content/authors.json` or `content/categories.json`. A typo is a build failure, which is the correct outcome: a post attributed to nobody has no credibility signal at all. **`datePublished` and `dateModified`** must carry a UTC offset, and the type system refuses a timestamp without one. Google falls back to Googlebot's timezone when the offset is missing, which quietly shifts every published date. ## How the body is structured [#how-the-body-is-structured] The two example posts that shipped with your install are the specification. When your agent writes post fifty, it imitates them, so it is worth reading one before you delete them. | Element | Why it is there | | ---------------------------- | --------------------------------------------------------------------------------------------------- | | Question-shaped H2 headings | People and assistants both ask questions. A heading that matches the question is easier to retrieve | | A direct answer under each | 40 to 60 words, no links, complete on its own, so it survives being lifted out of the page | | Sections of 150 to 300 words | Long enough to stand alone as a chunk, short enough to be about one thing | | Real tables | A comparison written as prose cannot be extracted. A table can | | Named entities, not pronouns | "GPTBot" retrieves. "It" does not | | Citations in frontmatter | They render as a source list and become part of the structured data | | Internal links | A post nothing links to is a post search engines treat as unimportant | The reasoning behind each of those, with the strength of the evidence attached, is in [the GEO playbook](/concepts/geo-playbook). ## Components you can use in the body [#components-you-can-use-in-the-body] Beyond ordinary Markdown, posts can use a small set of components that the install writes into `components/mdx/`. ```mdx <Callout variant="warning">A boxed aside for something that costs money to get wrong.</Callout> <Stat value="2.5x" label="more citations for pages that answer the question in the first paragraph" source="Your source" href="https://example.com/study" /> <KeyTakeaways items={['One sentence.', 'Another sentence.']} /> ``` The FAQ section is not a component you write. It renders from the `faq:` block in your frontmatter, and only when there are entries, so the structured data never describes questions the page does not show. The full list with props is in the [MDX components reference](/reference/mdx-components). ## Publish it [#publish-it] Setting `draft: false` and deploying is enough for the post to appear. To have search engines find it the same day rather than the next crawl, see [publishing and pinging](/deploy#tell-search-engines-about-a-new-post). <Cards> <Card title="Plan what to write" href="/guides/plan-your-content" description="Choosing topics, clustering them, and deciding when to refresh." /> <Card title="The pre-publish checklist" href="/guides/pre-publish-checklist" description="What audit checks, and what only you can." /> </Cards> --- # What AgentBlog is Source: https://docs.agentblog.dev/ Summary: A blog for your Next.js app that search engines rank and AI assistants cite, installed with one command, with agent skills that know how to write for it. AgentBlog is a blog you install into a Next.js app you already have. One command writes 71 files into your repository: the routes, the structured data, the sitemap, the feed, the social images, and a set of skills your coding agent uses to write the posts. The files are yours after that, the same way shadcn/ui components are yours. ## Who this is for [#who-this-is-for] You have a product with a website, and you know a blog would bring people to it. You would rather not spend a week reading about canonical tags to find out whether yours are right. That is the whole audience. You do not need to know what JSON-LD is. You do need a Next.js app and about ten minutes. <Cards> <Card title="Quickstart" href="/quickstart" description="Install it and see a post render. About five minutes." /> <Card title="Full installation" href="/installation" description="Both install paths, the four files you edit, and what each one does." /> </Cards> ## Why it exists [#why-it-exists] Three things go wrong between wanting a blog and having one that works. ### You are not sure how to build one [#you-are-not-sure-how-to-build-one] The usual answers are a separate WordPress site, a Webflow or Framer page, or a hosted platform on a subdomain. Each of those splits your site in two: your product on one domain, your writing on another, two design systems, two bills, and search engines treating them as unrelated. A blog inside your Next.js app has none of that. It is a route in your existing project. It reads your design tokens, deploys with your app, and lives on your domain, where the links people give you help the pages that sell your product. ### You cannot tell whether it is set up correctly [#you-cannot-tell-whether-it-is-set-up-correctly] This is the expensive one. A blog can look completely finished and be invisible. The page renders in your browser, so you assume a crawler sees the same thing. It usually does not. Content that only appears after JavaScript runs is missing for AI crawlers, which do not run JavaScript at all. A new post never reaches `sitemap.xml` because that route was cached. The author field is a plain string, so nothing connects the post to a person. None of that shows up as an error. Nothing turns red. You find out months later when nothing ranks and nothing cites you. AgentBlog was built from the current guidance Google and the AI search vendors publish, and the parts that cannot be verified by reading are verified by fetching. `agentblog doctor` reads your config and reports what is wrong. `agentblog doctor --url` fetches your live site as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Googlebot, and tells you which of them your CDN is turning away. ### You do not want to write the posts [#you-do-not-want-to-write-the-posts] Posts are MDX files in your repository, which makes them something a coding agent can genuinely maintain. Installing AgentBlog also writes four skills into `.claude/skills/`, so your agent already knows the format: the direct answer under each heading that an AI assistant can lift, the question-shaped headings, the tables, the citations, and the internal links that keep a new post from being an orphan. You ask for a post. The agent writes a draft in your voice, using your existing posts as the reference. You edit it and merge it. ## How it installs [#how-it-installs] The same way you install a shadcn/ui component, because it uses the same mechanism. ```bash npx agentblog@latest init ``` That command checks your project meets the requirements, adds the AgentBlog registry to your `components.json`, copies the files in, patches the two config files a file copier cannot reach, and asks you for your site URL and brand name. It prints everything it is about to do first, backs up every file it changes, and does nothing when you run it twice. There is no package of ours in your `package.json` afterwards. Nothing to upgrade around, nothing that breaks when we ship a change you did not ask for. ## What you actually get [#what-you-actually-get] | Piece | What it means for you | | -------------------- | --------------------------------------------------------------------------------------------- | | Blog routes | An index, post pages, category and tag pages, and author pages, all prerendered as plain HTML | | Structured data | The machine-readable summary Google and the assistants read, connected and typed | | Sitemap, feed, pings | Search engines find new posts on the day you publish rather than the week after | | Social images | A generated card per post, so a shared link is not a bare URL | | Your design system | The blog uses your colours, your fonts, and your components. It ships no theme of its own | | Agent skills | Four skills that write posts, refresh them, finish the setup, and audit before you publish | | `agentblog doctor` | One command that tells you whether any of the above is actually working | ## What it is not [#what-it-is-not] It is not a hosted platform, and there is no dashboard. It is not a template you clone, because your app already exists. It is not a theme: the components carry no colours of their own, so `/blog` looks like the rest of your product on the day you install it. <Cards> <Card title="Start installing" href="/quickstart" description="The five minute path." /> <Card title="How search and AI read a site" href="/concepts/how-ai-search-reads-your-site" description="The background, if you want it before you commit." /> </Cards> --- # Installation Source: https://docs.agentblog.dev/installation Summary: The two ways to install AgentBlog, what each one does and does not do for you, and how to verify the result. There are two ways in. `agentblog init` runs the whole install and patches your config. `shadcn add @agentblog/blog` copies the files and leaves the config to you. They converge on the same project: `init` calls `shadcn add` internally, so the registry is not an alternative to the CLI, it is how the CLI delivers files. <Callout type="warn" title="Pre-release"> `https://agentblog.dev/r/{name}.json` is not serving yet, so every `@agentblog/*` command on this page describes the shape of the install rather than a host you can fetch from today. Until then, use [install from a local checkout](#install-from-a-local-checkout). This notice comes down when the registry is live. </Callout> ## Which path [#which-path] | | `agentblog init` | `shadcn add @agentblog/blog` | | ---------------------------------------------- | ------------------------------------- | -------------------------------------- | | Copies the blog files | Yes | Yes | | Patches `next.config.ts` and `app/layout.tsx` | Yes | No, run `agentblog doctor --fix` after | | Writes `agentblog.config.ts` from your answers | Yes | Writes a template you fill in | | Backs up every file it touches | Yes, and `agentblog revert` undoes it | Nothing to back up | | Lets you read the payload first | `--dry-run` prints the diff | `--dry-run` prints every file | | Installs part of the blog | No, all or nothing | Yes, item by item | If you are not sure, use `init`. If you want to read everything before it lands in your repository, use the registry path and then run `doctor --fix`. ## Requirements [#requirements] `init` checks all of these before it writes anything, and refuses with the command to run rather than guessing. | Requirement | Why it is a requirement | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Next.js 16.3 or newer | The crawler metadata behaviour this product is built around is specific to Next.js 16, and the `AGENTS.md` handling arrives in 16.3 | | The App Router | Every route AgentBlog writes is an App Router route | | React 19 | Next.js 16's baseline | | Tailwind CSS v4 | v3 stores colours in a format the shipped stylesheet cannot read. See [Roadmap](/project/roadmap) | | `components.json` | AgentBlog builds on your shadcn primitives. Without the file there is no design system to build on | | Node 20.9 or newer | Next.js 16's floor | ## The CLI path [#the-cli-path] ```bash npx agentblog@latest init ``` In order, it: 1. **Checks the requirements above.** A missing one is a refusal with instructions. It will not run `shadcn init` for you, because that command picks a component base and a base colour and writes CSS variables into your stylesheet, which is choosing a design system on your behalf. 2. **Reads your project.** Root or `src/` layout, package manager, monorepo, and any existing `app/blog/**`. A blog you already wrote is a refusal rather than an overwrite. `--force` proceeds anyway. 3. **Asks four questions.** Content source, production site URL, brand name, and default author slug. 4. **Adds the `@agentblog` namespace to `components.json`**, before anything else. `shadcn add @agentblog/blog` resolves that prefix through this file, so the entry has to exist before the install runs. 5. **Runs `shadcn add`** for the blog and your chosen content source. 6. **Patches `next.config.ts`:** `htmlLimitedBots`, `images.qualities`, and `images.remotePatterns`. 7. **Patches `app/layout.tsx`:** `metadataBase`, `title.template` alongside `title.default`, and the RSS link under `alternates.types`. 8. **Writes** `agentblog.config.ts`, two entries in `.env.local`, and the IndexNow key file in `public/`. 9. **Writes** the `AGENTS.md` block and the skills in `.claude/skills/`. 10. **Runs `agentblog doctor`** and prints the result. Config edits are made through the TypeScript AST rather than by pattern matching, so a patch either applies to the real syntax or declines and says why. Every file it modifies is copied to `.agentblog/backup/<timestamp>/` first, `--dry-run` prints the unified diff and writes nothing, and `agentblog revert` restores the last backup. Running `init` twice does nothing the second time. That is asserted on every commit, because a config tool that produces an unexplained diff on a re-run is a tool people quietly stop running. ### What `init` still leaves to you [#what-init-still-leaves-to-you] Two things, both because they are yours rather than ours. * **The stylesheet import.** It will not write CSS into a file you own. See [the one line nothing tells you about](#the-one-line-nothing-tells-you-about). * **Your author record.** It will not invent a name or a bio. ## The registry path [#the-registry-path] Add the namespace to `components.json`: ```json title="components.json" { "$schema": "https://ui.shadcn.com/schema.json", "registries": { "@agentblog": "https://agentblog.dev/r/{name}.json" } } ``` Then: ```bash npx shadcn@latest add @agentblog/blog npx agentblog@latest doctor --fix ``` The second command is not optional. It is what finishes the two config edits a registry cannot make, and the section below explains why one of them fails silently if you skip it. You can also install a part rather than the whole thing: | Item | What it gives you | | ------------------------ | ---------------------------------------------- | | `@agentblog/blog` | Everything: routes, components, config, skills | | `@agentblog/blog-schema` | The structured data builders on their own | | `@agentblog/blog-ui` | The reading components on their own | | `@agentblog/agent-kit` | The four agent skills on their own | The full catalogue is at [agentblog.dev/registry](https://agentblog.dev/registry). ### What adding a registry authorises [#what-adding-a-registry-authorises] A registry URL is a code delivery channel, and it is worth being plain about that. Adding `@agentblog` to `components.json` authorises `shadcn add` to fetch source files, npm dependencies, and CSS from `agentblog.dev` and write them into your application, every time you run it. There is no lockfile for a registry, no integrity hash, and no review step unless you ask for one. That is the same trust you extend to an npm publisher, delivered at install time rather than pinned in a lockfile. Two things genuinely reduce your exposure: * `npx shadcn@latest add @agentblog/blog --dry-run` prints every file it would write, before it writes anything. * `npx shadcn@latest add <item> --diff` shows what changed upstream before you accept an update. That is the mechanism behind [taking an update](/guides/take-an-update). Pinning a GitHub ref pins the GitHub path only. The `agentblog.dev` URL always serves current. ```bash npx shadcn@latest add goldk3y/agentblog/blog npx shadcn@latest add goldk3y/agentblog/blog#v1.2.0 ``` ## What a registry cannot do for you [#what-a-registry-cannot-do-for-you] A shadcn registry can write files, install npm dependencies, merge CSS variables, and add environment variable entries. It has no step that edits an existing file. So two things are left over, and they fail in opposite ways. ### `htmlLimitedBots` in `next.config.ts` [#htmllimitedbots-in-nextconfigts] Without this setting, Next.js streams page metadata into `<body>` rather than `<head>` for any user agent it does not recognise as a bot, on any page that renders dynamically. GPTBot, ClaudeBot, OAI-SearchBot, and PerplexityBot are not on the list Next.js ships. The result is a blog that looks completely correct and serves its `<title>` in the wrong element to the exact crawlers you installed this for. ```bash npx agentblog@latest doctor --fix ``` Four separate things tell you if you forget: a warning on every `next dev` and every build, the text `shadcn add` prints during the install, the `agentblog-setup` skill your agent can act on, and the block in your `AGENTS.md`. <Callout type="warn" title="Do not hand-write this value"> `htmlLimitedBots` **replaces** the Next.js default bot list rather than adding to it. A value containing only the AI crawlers silently drops Googlebot, Bingbot, Applebot, and every social preview bot. `doctor --fix` writes the union of both lists, which is why the generated value is long. </Callout> ### The one line nothing tells you about [#the-one-line-nothing-tells-you-about] The install writes `styles/agentblog.css`, and nothing imports it for you. Add it to your global stylesheet, after the Tailwind import: ```css title="app/globals.css" @import 'tailwindcss'; @import '../styles/agentblog.css'; ``` The file lands at your project root, so the path depends on where your stylesheet is. From `app/globals.css` it is `../styles/agentblog.css`. From `src/app/globals.css` it is `../../styles/agentblog.css`. That file binds article typography to your theme tokens. Skip it and the blog builds, renders, passes `doctor`, and serves article prose with no typography at all. Correct HTML, correct structured data, unstyled body text, and no error anywhere. This is the one wiring step with no redundant warning, against four for `htmlLimitedBots`. So check it by eye: if a post renders in one undifferentiated font size with no visible heading hierarchy, this is why. ## Install from a local checkout [#install-from-a-local-checkout] This is how to evaluate AgentBlog while the registry is not being served. You are pointing your project at a registry running on your own machine, which is fine for a scratch app and not for anything you deploy. The `registries` map takes a URL and only a URL. A relative path is joined onto the default registry origin and 404s, and a `file://` URL is rejected. So the checkout has to serve HTTP. <Steps> <Step> ### Build the registry [#build-the-registry] From a clone of [goldk3y/agentblog](https://github.com/goldk3y/agentblog): ```bash cd apps/web && npx shadcn build --output public/r ``` </Step> <Step> ### Serve it [#serve-it] From the repository root, in a second terminal: ```bash node scripts/serve-registry.mjs ``` It serves `apps/web/public` on `http://127.0.0.1:4477` and prints the directory it is serving. Pass `--port` to move it. </Step> <Step> ### Point your project at it [#point-your-project-at-it] ```json title="components.json" { "registries": { "@agentblog": "http://127.0.0.1:4477/r/{name}.json" } } ``` `npx shadcn@latest add @agentblog/blog` now resolves against your own machine. Everything else on this page is unchanged, including `doctor --fix`. </Step> </Steps> Re-run `shadcn build` after any change to the registry source, or the served JSON is stale. ## Verify the install [#verify-the-install] ```bash npx agentblog@latest doctor ``` Then, once you have deployed, the check that matters most: ```bash npx agentblog@latest doctor --url https://yoursite.com/blog/your-post ``` That fetches your live URL as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Googlebot and asserts each one gets a 200 with the article text in it. It is the only check that catches a correct install sitting behind a CDN that turns crawlers away. See [when your CDN blocks crawlers](/troubleshooting/cdn-blocking-crawlers). Run it from your own machine or from CI, never from inside the deployment. The request originates wherever the CLI runs, and a request from inside the network can bypass the exact rule you are testing for, which turns the most valuable check in the product into one that always passes. The version that needs no tooling at all: ```bash curl -s -A "GPTBot" https://yoursite.com/blog/your-post | grep "a distinctive sentence" ``` If that sentence is not in the response, no AI crawler can see it. Use view source rather than the element inspector, which shows you the page after JavaScript has run. ## Next [#next] <Cards> <Card title="Write your first post" href="/first-post" description="Scaffold it, have your agent draft it, audit it." /> <Card title="Configuration" href="/reference/configuration" description="Every field in agentblog.config.ts." /> <Card title="Deploy" href="/deploy" description="Two environment variables, one webhook, one verification." /> <Card title="Something is wrong" href="/troubleshooting" description="The failures that actually happen, and what each looks like." /> </Cards> --- # Quickstart Source: https://docs.agentblog.dev/quickstart Summary: Install AgentBlog into a Next.js app you already have, and see a real post render at /blog, in about ten minutes. One command installs the blog. Two small edits afterwards are yours to make, because they are decisions about your site rather than about the software. At the end of this page you have a working `/blog` running locally. <Callout type="warn" title="Pre-release"> `agentblog.dev` is not serving the registry yet, so the commands below resolve to nothing today. To try AgentBlog now, follow [installing from a local checkout](/installation#install-from-a-local-checkout) instead. This notice comes down when the registry is live. </Callout> ## Before you start [#before-you-start] You need an existing Next.js app. AgentBlog adds a section to a project you already have, it does not create one. | You need | How to check | | --------------------- | ------------------------------------------------------------- | | Next.js 16.3 or newer | `npx next --version` | | The App Router | You have an `app/` folder rather than a `pages/` folder | | React 19 | Comes with Next.js 16 | | Tailwind CSS v4 | `@import 'tailwindcss';` at the top of your global stylesheet | | shadcn/ui set up | A `components.json` file at the root of your project | | Node 20.9 or newer | `node --version` | If you do not have `components.json` yet, run `npx shadcn@latest init` first. That command asks you to choose a base colour and a component style, and those are your choices to make, so AgentBlog will not run it for you. Starting from nothing, `npx agentblog@latest create my-blog` scaffolds a new Next.js project and installs the blog into it. Everything below then applies inside that directory. ## Install it [#install-it] <Steps> <Step> ### Run the installer [#run-the-installer] ```bash npx agentblog@latest init ``` It checks the requirements above, then asks you four questions: where your site lives in production, your brand name, the slug you want as the default author, and where posts should come from (answer `mdx` unless you know you want something else). Then it copies the files in and patches the two config files a file copier cannot reach. It shows you every change first and backs up every file it edits, so `npx agentblog@latest revert` puts everything back. </Step> <Step> ### Add one line to your stylesheet [#add-one-line-to-your-stylesheet] Open your global stylesheet, usually `app/globals.css`, and add the second line here, after the Tailwind import. ```css title="app/globals.css" @import 'tailwindcss'; @import '../styles/agentblog.css'; ``` In a `src/` layout the path is `'../../styles/agentblog.css'`. This is the one step nothing will remind you about. That file carries the article typography. Without it your posts render as correct HTML in a single undifferentiated font size, and no error appears anywhere. </Step> <Step> ### Put yourself in the author list [#put-yourself-in-the-author-list] Open `content/authors.json` and replace the placeholder record with your own. ```json title="content/authors.json" [ { "slug": "editorial", "name": "Your Name", "role": "Founder", "bio": "One or two sentences about who you are and why you know this subject.", "sameAs": ["https://www.linkedin.com/in/you", "https://github.com/you"] } ] ``` Keep the `editorial` slug for now. The two example posts reference it, and a post that names an author who does not exist fails the build. `sameAs` is worth filling in. It is how a search engine or an assistant connects "this post's author" to a real person rather than a name, which is the whole mechanism behind author credibility signals. </Step> <Step> ### Look at it [#look-at-it] ```bash npm run dev ``` Open `http://localhost:3000/blog`. You should see two example posts, styled with your own colours and fonts, and each one opening onto a full article page. If the text looks unstyled, step 2 did not take. If the build printed a warning mentioning `htmlLimitedBots`, run `npx agentblog@latest doctor --fix`. </Step> <Step> ### Check the install [#check-the-install] ```bash npx agentblog@latest doctor ``` This reads your config and reports anything still missing, with the fix for each one. It exits non-zero when it finds an error, so it also works as a CI step. </Step> </Steps> ## What you have now [#what-you-have-now] * `/blog`, `/blog/<post>`, category pages, tag pages, and an author page for everyone in `content/authors.json`. * `sitemap.xml`, `robots.txt`, and `feed.xml`, kept in step with your posts. * A generated social image for every post. * Two example posts, which are also the format specification: they are what your agent imitates when it writes a new one. * Four skills in `.claude/skills/`, so your coding agent knows how to write and audit a post without being told. ## Next [#next] <Cards> <Card title="Write your first post" href="/first-post" description="Scaffold a post, ask your agent to write it, and check it before publishing." /> <Card title="Put it live" href="/deploy" description="Deploy, set two environment variables, and verify a crawler can read the result." /> <Card title="Full installation guide" href="/installation" description="The registry path, what each edit does, and installing from a local checkout." /> <Card title="Configuration" href="/reference/configuration" description="Every field in agentblog.config.ts and what breaks when it is wrong." /> </Cards> --- # The GEO playbook Source: https://docs.agentblog.dev/concepts/geo-playbook Summary: How to write a post an AI search engine will cite, with the strength of the evidence attached to every claim. Generative engine optimization is writing so that a retrieval system can find your page, lift a coherent chunk out of it, and attribute that chunk to you. It overlaps heavily with good SEO, and where it does not, the differences are specific enough to write down. Every claim below carries an evidence grade, because the honest state of this field is that some of it is peer-reviewed and most of it is vendor correlation. The rank order of the techniques is more reliable than any individual percentage. This is the page the `write-blog-post` skill implements. You do not have to read it to use AgentBlog. It is here because you should be able to check what your tools are doing on your behalf. ## The one thing that has to be true first [#the-one-thing-that-has-to-be-true-first] AI crawlers fetch your HTML once and do not execute JavaScript. Vercel and MERJ instrumented roughly 1.3 billion AI crawler fetches across Vercel's network over a month and reported that none of the major AI crawlers render JavaScript. GPTBot requested `.js` files in 11.50% of its requests and ClaudeBot in 23.84%, and neither executed any of it, which is the detail that makes server logs misleading: fetching a script is not running it. Googlebot renders. Bingbot renders on a delay. GPTBot, ClaudeBot, PerplexityBot, and CCBot do not. **Evidence grade: vendor-reported, from production traffic.** [The rise of the AI crawler](https://vercel.com/blog/the-rise-of-the-ai-crawler), Vercel and MERJ, 17 December 2024. So the first question about any page is not how it is written, it is whether the text is in the first response at all. Check it the way a crawler does: ```bash curl -s -A "GPTBot" https://yoursite.com/blog/your-post | grep "a distinctive sentence" ``` If that fails, nothing else on this page matters. Use view source, not the element inspector, which shows you the page after JavaScript has run. Four things break this regardless of framework: client-side data fetching in an effect, content mounted on interaction such as accordions and tabs that render their children only on click, infinite scroll, and text that exists only inside an image. ## Structure the page answer-first [#structure-the-page-answer-first] ### The answer capsule [#the-answer-capsule] Under the H1, and under each H2, write a direct answer of 40 to 60 words containing no links. That paragraph is the chunk a retrieval system lifts. A link inside it fragments the chunk and pulls the reader out of the answer at the exact moment the answer is being given. Length matters because too short reads as a fragment and too long stops being liftable. Answer capsules were present in 72.4% of the blog posts ChatGPT cited in one analysis of 7,500 ChatGPT referral sessions, and more than nine in ten of those capsules contained no links. **Evidence grade: industry, correlational.** Adam Gnuse, [Search Engine Land](https://searchengineland.com/how-to-get-cited-by-chatgpt-the-content-traits-llms-quote-most-464868), 19 November 2025. ### Question-format headings [#question-format-headings] Write H2s as the question a person would type. "Do AI crawlers run JavaScript?" rather than "JavaScript execution". Across 3 million ChatGPT responses and 30 million citations, cited content was about twice as likely to contain a question mark, and 78.4% of the citations tied to questions came from headings rather than body text. **Evidence grade: industry, correlational.** Kevin Indig's study, [as reported by Search Engine Land](https://searchengineland.com/chatgpt-citations-content-study-469483), 18 February 2026. The mechanism is more convincing than the correlation: a heading phrased as a question matches the query shape a retrieval system is matching against. Every H2 and H3 needs a stable `id`. That helps readers, enables jump links in search results, and gives a retrieval system clean section boundaries. ### Section length [#section-length] 150 to 300 words per section, each self-contained. A section that only makes sense after reading the previous one is a bad chunk, because it will be retrieved on its own. ## Write for chunk quality [#write-for-chunk-quality] This is the most important writing rule on the page and the least intuitive. **Repeat entity names instead of using pronouns.** "AgentBlog writes the sitemap from content dates" survives being lifted out of context. "It writes the sitemap from content dates" does not. Human copy editors remove this repetition. Retrieval systems need it. The same logic governs the rest of this section: * Define an acronym in every section that uses it, not once at the top. * Do not open a section with "as mentioned above". * Make each section's first sentence say what the section is about, without borrowing from its heading. ## Formatting elements, ranked [#formatting-elements-ranked] Every figure is given against a named metric, because that is where the secondary literature on this paper falls apart. The GEO paper reports two metrics, Position-Adjusted Word Count and Subjective Impression, and a summary that quotes one number without saying which metric it belongs to is why five blog posts will give you five different rankings. Both columns are relative to the paper's printed baseline of 19.3 on both metrics. | Rank | Element | Position-Adjusted Word Count | Subjective Impression | Evidence grade | | ---- | ----------------------------- | ---------------------------- | --------------------- | -------------- | | 1 | Quotations from named sources | **+40.9%** | +28.0% | Peer-reviewed | | 2 | Statistics with numbers | +30.6% | +22.8% | Peer-reviewed | | 3 | Fluency optimization | +28.0% | +13.5% | Peer-reviewed | | 4 | Outbound citations | +27.5% | +13.5% | Peer-reviewed | | 5 | Technical terms | +17.6% | +10.9% | Peer-reviewed | | 6 | Easy-to-understand language | +14.0% | +6.2% | Peer-reviewed | | 7 | Authoritative tone alone | +10.4% | +18.7% | Peer-reviewed | | 8 | Unique words | +6.2% | +5.7% | Peer-reviewed | | 9 | **Keyword stuffing** | **-8.3%** | +4.7% | Peer-reviewed | The peer-reviewed figures come from Table 1 of the GEO paper ([Aggarwal et al., KDD 2024](https://arxiv.org/abs/2311.09735)), measured on GPT-3.5-turbo against top-5 Google sources in 2023. Directionally valid, not guarantees on current engines. Treat the rank order as the finding and the decimals as decoration. Three rows are worth reading twice: * **Authoritative tone is weak on the primary metric**, ranking 7th of 9 there. Its stronger second number is why it gets quoted as a headline technique. The paper's own conclusion is that a more persuasive and authoritative tone produced no significant improvement. Writing more confidently is not a strategy. Adding evidence is. * **Outbound citations sit below fluency optimization**, not in a 30 to 40 per cent band. The paper's prose groups citing sources with the top methods, which is where the inflated number comes from. * **Keyword stuffing is the only technique measured below baseline.** It is not neutral. It measures worse than doing nothing. Two non-peer-reviewed rows belong alongside these, kept separate because the evidence is a different kind: | Element | Reported effect | Evidence grade | | ------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Structural formatting | 17.3% relative improvement in citation rate across six engines | Preprint, [arXiv:2603.29979](https://arxiv.org/abs/2603.29979) | | Tables present on a cited page | On 40.0% of top-decile cited pages against 28.2% of bottom-half cited pages | Industry, correlational, [Trakkr Research](https://trakkr.ai/trakkr-research/anatomy-of-an-ai-citation), 16 April 2026 | ### Tables [#tables] Put comparison and specification data in a real table. Not a bulleted list that describes a table, and not a paragraph that enumerates one. A table is structurally unambiguous about which value belongs to which row, which is exactly what a retrieval system needs and exactly what prose obscures. ### Statistics and quotations [#statistics-and-quotations] One real statistic and one quotation from a named source per post, each cited, is the floor. Both are the highest-effect techniques in the table, and both are the two things a language model will happily invent if you let it. The `write-blog-post` skill carries the instruction never to fabricate either, in its always-loaded text. Treat it the same way when you are writing by hand. ## Word count [#word-count] Longer is not better, and the widely quoted claim that long-form posts are cited three times more often has no source anyone here could retrieve. The primary data points the other way: across 174,048 pages appearing in 560,346 AI Overviews, 53.4% of cited pages were under 1,000 words, the average cited page ran 1,282 words, and the correlation between word count and being cited was 0.04, which is no relationship at all. **Evidence grade: industry, correlational.** [Ahrefs](https://ahrefs.com/blog/short-vs-long-content-in-ai-overviews/), 3 December 2025. Length is a proxy for completeness, not a target. Write exactly as long as it takes to fully answer the query and its sub-questions, then stop. A 900-word post that fully answers a narrow question beats a padded 2,500-word one, and the padding actively degrades the chunk quality of every section it touches. ## Freshness [#freshness] Recency is worth acting on, and the specific figures in circulation are not worth quoting. The most repeated one, that 76.4% of ChatGPT's most-cited pages were updated within 30 days, traces to a study whose publisher's domain no longer resolves. **Evidence grade: none we could retrieve.** The `refresh-blog-post` skill is built on the mechanism rather than the number. Update your best posts on a schedule, and change `dateModified` only when the content actually changed. A date that moves on every deploy teaches every consumer of that field to ignore it, which costs you the signal you were trying to send. ## Internal links [#internal-links] Five to fifteen contextual links per post. Every new post should also receive at least one inbound link from an existing post, added in the same commit. Those numbers are a working range rather than a measured optimum: the figure attached to them online comes from a vendor page that describes its own data as synthesized rather than measured. Orphan posts are the most common structural failure on a blog that otherwise does everything right. A post with no inbound internal links is discovered only through the sitemap, and it inherits none of the topical context that makes the rest of your cluster legible. Never put a link inside an answer capsule. ## Entity building [#entity-building] An AI answer engine has to resolve "who published this" to something. That resolution is what turns a citation into attribution. * `Organization.sameAs` pointing at profiles a third party can verify: LinkedIn, Crunchbase, GitHub, YouTube, Wikidata. * An author page emitting `Person`, linked from the byline. * An editorial policy page with a real corrections process. * Consistent naming. The same organisation name everywhere, spelled the same way. Off-site mentions carry more weight than raw backlink counts for AI citation specifically. Across 75,000 brands, Ahrefs measured Spearman correlations of 0.664 for branded web mentions and 0.218 for backlinks against AI Overview brand visibility, with Domain Rating at 0.326. A separate Ahrefs study of the same brand set put YouTube mentions at 0.737 against ChatGPT brand visibility. **Evidence grade: industry, correlational, and correlation is not causation here.** [AI Overview brand visibility factors](https://ahrefs.com/blog/ai-overview-brand-correlation/), 26 May 2025, and [AI brand visibility correlations](https://ahrefs.com/blog/ai-brand-visibility-correlations), 12 December 2025. Being discussed on Reddit, in a comparison post, or in documentation someone else maintains is closer to the mechanism than acquiring a link. ## What to avoid [#what-to-avoid] * **Keyword stuffing.** Measurably worse than baseline. * **Content behind interaction.** Accordions and tabs that mount their children on click. * **Text only in images.** A crawler that does not run OCR sees nothing. Mirror it in HTML. * **Marked-up facts that are not on the page.** The most enforced structured data policy, and violations can earn a manual action that removes rich result eligibility entirely. * **`llms.txt`.** See [why the blog does not ship one](/concepts/why-no-llms-txt). * **Padding.** Every filler paragraph makes the chunks around it worse. ## The pre-publish gate [#the-pre-publish-gate] Everything above is checkable. See [the checklist](/guides/pre-publish-checklist), which is what `agentblog audit` runs. ## Caveats [#caveats] The AI crawler situation changes every quarter. OAI-SearchBot appeared in 2024, Claude-SearchBot split off in 2025, Cloudflare changed its defaults in September 2026. Any specific list of user agents has a half-life measured in months, which is why the blog vendors the list and checks it against upstream on a schedule rather than transcribing it once. The correlational figures come from vendors, several of whom have a product to sell. Ronald Sielinski's [Quantifying Uncertainty in AI Visibility](https://arxiv.org/abs/2603.08924) (9 March 2026) sampled Perplexity Search, OpenAI SearchGPT, and Google Gemini repeatedly and found that confidence intervals on a frequently cited domain's citation share span 3 to 6 percentage points, and that overlapping intervals are the norm for domains appearing to differ by less than 5 to 7 points. So an intervention that looks like it moved your citation share by three points may have moved nothing. Treat the correlational rows as hypotheses worth testing on your own content, not as settled numbers. Several figures that circulate widely are absent from this page on purpose, because their sources could not be retrieved. The full list, with what happened to each, ships with the `write-blog-post` skill. *** *This page is licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Reproduce it, translate it, quote it. Credit `agentblog.dev` with a link.* --- # How search and AI read your site Source: https://docs.agentblog.dev/concepts/how-ai-search-reads-your-site Summary: What a crawler actually receives, why so many blogs are invisible to AI assistants without any error appearing, and which parts of that AgentBlog handles for you. You do not need this page to use AgentBlog. It is here so that the decisions the install makes on your behalf are decisions you can check rather than trust. ## A crawler receives one response [#a-crawler-receives-one-response] An AI crawler sends one GET request, reads the bytes your server returns, and moves on. No browser starts. No scripts run. No second request fires for data. Whatever text sits in that first response body is the entire article as far as the crawler is concerned. Googlebot is the exception that confuses everyone: it does render JavaScript, on a delay and at a cost. Bingbot renders too. GPTBot, ClaudeBot, PerplexityBot, and CCBot do not, and they are the ones deciding whether ChatGPT, Claude, and Perplexity can quote you. So a page can rank on Google and arrive at ChatGPT as an empty shell. That is not a bug in either system. They read the web differently. ### How to check your own page in ten seconds [#how-to-check-your-own-page-in-ten-seconds] ```bash curl -s -A "GPTBot" https://yoursite.com/blog/your-post | grep "a distinctive sentence" ``` If the sentence is not in the output, no AI crawler can see it. Use view source rather than the element inspector in your browser: the inspector shows the page after JavaScript has run, which is a different document from the one your server sent. Four things break this regardless of framework: fetching content in the browser after load, mounting content on interaction, infinite scroll, and text that exists only inside an image. AgentBlog prerenders every post as complete static HTML at build time, and the two components that hydrate render their full content on the server first. That is the single most important thing it does. ## Nothing turns red when this fails [#nothing-turns-red-when-this-fails] The reason blogs stay invisible for months is that every failure here is silent. A missing signal is not an error. Some examples, all of which the install handles: | What goes wrong | What you see | | --------------------------------------------------------------- | ----------------------------------- | | The article renders only in the browser | A perfect page in your browser | | Page metadata streams into the body instead of the head | A perfect page in your browser | | The sitemap is a cached route and never learns about a new post | A perfect page, and a missing post | | The author is a plain string rather than a linked entity | A perfect page, with no attribution | | Your CDN turns crawlers away before your server ever sees them | A perfect page, and no citations | None of those produce a build failure, a console error, or a failing test. They produce nothing. You find out months later when nothing ranks and nothing cites you. That is why the CLI exists. `agentblog doctor` reads your config and reports what is missing, and `agentblog doctor --url` fetches your live site as five different crawlers and tells you what each one actually received. ## Structured data is the summary machines read [#structured-data-is-the-summary-machines-read] Structured data is a block of JSON in your page that says, in a vocabulary search engines agree on, what the page is: an article, published on this date, written by this person, who works for this organisation, which also has these profiles elsewhere. Two rules matter more than the rest, and both are about honesty: **Every marked-up fact has to be visible on the page.** Marking up content a reader cannot see is the most enforced structured data policy there is, and a violation can cost you rich results entirely. AgentBlog emits FAQ markup only when the FAQ section actually renders. **The author has to be an entity, not a string.** A name is a string. A name linked to a page that lists what that person knows about, with profile links a third party can verify, is an entity. Entity resolution is what lets an assistant say "according to X" and mean something by it. ## Being read is not the same as being quoted [#being-read-is-not-the-same-as-being-quoted] Once the text reaches a crawler, a second question starts: when a retrieval system pulls a chunk out of your page to answer a question, is your chunk any good on its own? That is what the writing format is for. A direct 40 to 60 word answer under each heading, headings phrased as questions, sections that stand alone, entity names instead of pronouns, real tables instead of prose comparisons. Each of those makes an extracted fragment survive being extracted. The evidence for each technique, and how strong that evidence is, is in [the GEO playbook](/concepts/geo-playbook). The short version is that quotations from named sources and statistics with numbers measure highest, and keyword stuffing measures worse than doing nothing at all. ## What this means for your blog [#what-this-means-for-your-blog] Three things, in order: 1. **Serve the article in the first response.** Everything else is downstream of this. AgentBlog does it and CI proves it on every commit against a real build. 2. **Make the machine-readable layer correct and honest.** Also handled, and `doctor` checks it stayed that way. 3. **Write posts a retrieval system can lift a good answer out of.** That is the part where the tools help and you decide. <Cards> <Card title="The GEO playbook" href="/concepts/geo-playbook" description="Every writing technique, with the strength of its evidence." /> <Card title="Install it" href="/quickstart" description="The five minute path." /> </Cards> --- # Why the blog does not ship llms.txt Source: https://docs.agentblog.dev/concepts/why-no-llms-txt Summary: The evidence against llms.txt for content sites, the one case where it genuinely helps, and why these docs serve one while your blog will not. The blog ships no `llms.txt`. Not as an option, not behind a flag. This documentation site does serve one, at [/llms.txt](/llms.txt), and the difference between those two situations is the whole point. ## The evidence [#the-evidence] * **Gary Illyes, Google Search Central Deep Dive, July 2025:** Google does not support `llms.txt` and is not planning to. * **John Mueller** compared it on the record to the discredited keywords meta tag. * **Google's own AI optimization guide:** "You don't need to create new machine readable files, AI text files, markup, or Markdown to appear in Google Search (including its generative AI capabilities), as Google Search itself doesn't use them." The same page adds that maintaining one for other services will neither harm nor help visibility, because Google Search ignores it. * **No major model provider consumes it in production.** * **An SE Ranking study** found that removing `llms.txt` as a variable improved their citation-prediction model, meaning it was adding noise. * **OtterlyAI instrumented one site for 90 days** and logged 84 requests to `/llms.txt` out of 62,100 AI bot visits. An ordinary page on the same site averaged about 265 AI bot visits in the same window. That last measurement is the one worth sitting with. The file that exists to be read by AI crawlers was fetched roughly a third as often as a random page that was not written for them at all. A larger figure circulates, 408 requests out of more than 500 million AI bot visits. It is not used here: it comes from a vendor with no published methodology, no data window, and no dataset, and a number that convenient deserves the same scrutiny as the claim it argues against. ## The cost is not the file [#the-cost-is-not-the-file] `llms.txt` neither helps nor hurts your search performance. The cost of shipping one is the implication. A blog that ships an `llms.txt` is telling its owner that the file does something, and that owner will spend an hour maintaining it that belongs somewhere else. ## The one case where it earns its place [#the-one-case-where-it-earns-its-place] Developer documentation with programmatic readers, where the token saving is real. The mechanism is different from the citation claim. Nobody is arguing that a crawler discovers your docs through `llms.txt`. The argument is that when an agent has already been pointed at a documentation site and has to read it, a Markdown index and Markdown page variants cost a fraction of the tokens that rendered HTML with navigation chrome costs. That is a measurable saving on a real workflow rather than a ranking hypothesis. Next.js does exactly this for its own documentation, and so does this site: * [/llms.txt](/llms.txt), an index of every page with its description * [/llms-full.txt](/llms-full.txt), every page concatenated * A Markdown variant of every page, at [/installation.md](/installation.md) and so on for the rest Your blog is not that. Its readers are people, plus crawlers that want HTML with structured data in it. Serving those crawlers a second parallel copy of the same content adds a surface to keep in step and buys nothing measurable. ## What to do with the hour instead [#what-to-do-with-the-hour-instead] 1. **Server-rendered HTML.** The text in the first response, with no JavaScript required. This is the whole game, and most sites fail it. 2. **Structured data.** A connected graph that resolves your author and your organisation to real entities. 3. **Third-party mentions.** Being discussed somewhere you do not control is closer to the mechanism than anything you can put in your own root directory. AgentBlog does the first two for you and tells you how to work on the third. `llms.txt` is not on the list. ## If you want one anyway [#if-you-want-one-anyway] It is your repository. A route handler at `app/llms.txt/route.ts` returning a plain-text index is about fifteen lines, and nothing in the blog will fight you. It is not shipped by default because shipping it implies it works. *** *This page is licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).* --- # Make it look like your site Source: https://docs.agentblog.dev/guides/match-your-design Summary: The blog inherits your colours, fonts, and components automatically. What to edit when you want it to look different, and the rules that keep the inheritance working. `/blog` should look like the rest of your product on the day you install it, and it does. AgentBlog ships no colours, no theme, and no font. It composes the shadcn components you already have and reads the tokens you already defined, so changing your theme changes the blog. ## What to edit [#what-to-edit] Everything here is a file in your repository. An update never overwrites any of it unless you explicitly pass `--overwrite`. | You want to change | Edit | | ------------------ | --------------------------------------------------------------- | | Colours and radii | Your existing tokens in `globals.css`. Nothing blog-specific | | Reading width | `--agentblog-measure` in `styles/agentblog.css`, default `68ch` | | Article typography | The `@utility prose` block in the same file | | The post card | `components/blog/post-card.tsx` | | The article layout | `app/blog/[slug]/page.tsx`, an ordinary Server Component | | Icons | `components/blog/icons.tsx`, which re-exports every icon used | If you have already customised your `Card`, the blog uses yours. `shadcn add` does not overwrite an existing component, and AgentBlog's post card composes on top of whatever `Card` your project has. ## The rules that make that work [#the-rules-that-make-that-work] Five decisions keep the inheritance intact. They are worth knowing before you edit a component, because breaking one of them is how a blog starts looking like a different website. ### Primitives are requested by bare name [#primitives-are-requested-by-bare-name] ```json { "registryDependencies": ["card", "badge", "separator", "avatar", "button"] } ``` A bare name resolves against your configuration: your component base, your aliases, your base colour. That is the whole inheritance mechanism, and it is free as long as nothing fights it. ### Only semantic tokens, never a palette utility [#only-semantic-tokens-never-a-palette-utility] Allowed everywhere in the blog: `bg-background`, `text-foreground`, `text-muted-foreground`, `bg-card`, `bg-muted`, `bg-primary`, `bg-secondary`, `bg-accent`, `text-destructive`, `border-border`, `ring-ring`, and the `rounded-*` scale derived from your `--radius`. Not allowed: any palette utility such as `text-zinc-500`, any colour literal, and any dark-mode colour variant. <Callout title="The dark-mode variant is the one that catches people"> Your tokens already flip under `.dark`. Writing a dark-mode colour override re-hardcodes exactly what the token was abstracting, and it breaks the moment your dark theme is not near-black. A component that seems to need one has picked the wrong token. </Callout> A lint runs over the shipped components on every commit and fails the build on any of the above, which is what keeps this true in month six. ### Installing the blog installs no base and no theme [#installing-the-blog-installs-no-base-and-no-theme] A shadcn `registry:base` item carries a style, an icon library, a base colour, and CSS variables. Applying one to an app that already has a visual identity is a request to replace that identity, so `@agentblog/blog` does not depend on `@agentblog/theme` at all and never will. If you want AgentBlog's own reading theme, on a project that has nothing to lose, ask for it by name: ```bash npx shadcn@latest add @agentblog/theme ``` ### The blog's own tokens are namespaced and derived [#the-blogs-own-tokens-are-namespaced-and-derived] Long-form reading needs a measure and a prose scale that shadcn does not define. Those ship prefixed with `--agentblog-`, and each one is defined in terms of a token you already have. ```css title="styles/agentblog.css" @theme inline { --agentblog-measure: 68ch; --color-agentblog-prose-body: var(--foreground); --color-agentblog-prose-muted: var(--muted-foreground); --color-agentblog-prose-rule: var(--border); } ``` No new colours enter your project. Change `--foreground` and the article prose follows. ### The typography plugin is bridged rather than fought [#the-typography-plugin-is-bridged-rather-than-fought] `@tailwindcss/typography` ships its own greys, which is the thing rule two forbids. In Tailwind v4 the plugin is customised through `--tw-prose-*` variables, so `styles/agentblog.css` is the one place long-form styling binds to your theme. ```css title="styles/agentblog.css" @plugin '@tailwindcss/typography'; @utility prose { --tw-prose-body: var(--foreground); --tw-prose-headings: var(--foreground); --tw-prose-links: var(--primary); --tw-prose-bullets: var(--border); --tw-prose-hr: var(--border); --tw-prose-quote-borders: var(--border); --tw-prose-captions: var(--muted-foreground); --tw-prose-pre-bg: var(--muted); --tw-prose-th-borders: var(--border); --tw-prose-td-borders: var(--border); } ``` <Callout type="warn" title="A version trap worth knowing"> Tailwind v3 era shadcn stored colours as bare HSL channel triplets, so the idiom everyone learned was `hsl(var(--foreground))`. Tailwind v4 shadcn stores complete `oklch()` values, so the correct form is `var(--foreground)` with no wrapper. Most answers online still show the old form. It fails silently: the colour is invalid, the property is dropped, and the element inherits whatever was above it. </Callout> ## If the article prose looks unstyled [#if-the-article-prose-looks-unstyled] That is almost always the stylesheet import, which is the one install step with no warning attached to it. ```css title="app/globals.css" @import 'tailwindcss'; @import '../styles/agentblog.css'; ``` See [installation](/installation#the-one-line-nothing-tells-you-about). --- # Measure traffic from AI assistants Source: https://docs.agentblog.dev/guides/measure-ai-traffic Summary: Separating visits that came from ChatGPT, Perplexity, or Claude out of your ordinary referral report, in three lines against the analytics you already run. `lib/ai-referrers.ts` answers one question: did this visit come from an AI assistant, and which one. It is a pure function with no dependencies. It sends nothing anywhere and sets no cookie, so wiring it to your analytics is your decision and stays your decision. ## Why this needs its own dimension [#why-this-needs-its-own-dimension] Assistant referrals do not group with search traffic in any default report. `chatgpt.com` arrives as an ordinary referral, sitting in the same list as a forum link and a newsletter, and `perplexity.ai` sits three rows below it. The one number that tells you whether writing for retrieval is working is scattered across a dozen rows nobody totals. Classifying at the source collapses those rows into one dimension you can chart over time. That chart is the only feedback loop available here, because AI answer engines send no query data, no impression counts, and no rank. ## Wire it up [#wire-it-up] Three lines against whatever you already run. Put them in a client component that renders on the post route, or on the server against the `Referer` header. ```ts title="Google Analytics 4" const ai = classifyReferrer(document.referrer) if (ai) gtag('event', 'ai_referral', { ai_source: ai.source, ai_host: ai.host }) ``` ```ts title="Vercel Analytics" const ai = classifyReferrer(document.referrer) if (ai) track('ai_referral', { source: ai.source, host: ai.host }) ``` ```ts title="PostHog" const ai = classifyReferrer(document.referrer) if (ai) posthog.capture('ai_referral', { source: ai.source, host: ai.host }) ``` Chart `ai_source` as the dimension and `ai_host` as the drill-down. The host is kept alongside the source deliberately: it is how you notice a new subdomain sending traffic before it has a name of its own. ## The API [#the-api] ```ts import { classifyReferrer, isAiReferrer } from '@/lib/ai-referrers' classifyReferrer('https://chatgpt.com/c/abc123') // { source: 'chatgpt', host: 'chatgpt.com' } classifyReferrer('https://news.ycombinator.com/item?id=1') // null isAiReferrer(document.referrer) // boolean ``` | Export | Signature | | ------------------ | ------------------------------------------------------------------------------------------------- | | `classifyReferrer` | `(referrer: string \| null \| undefined) => AiReferrer \| null` | | `isAiReferrer` | `(referrer: string \| null \| undefined) => boolean` | | `AiReferrer` | `{ source: AiReferrerSource; host: string }` | | `AiReferrerSource` | `'chatgpt' \| 'perplexity' \| 'gemini' \| 'copilot' \| 'claude' \| 'grok' \| 'you' \| 'other-ai'` | `null` covers empty referrers, direct traffic, unparseable strings, and every ordinary referral, so you can branch on truthiness and be done. The input may be a full URL or a bare hostname. `document.referrer` gives you a full URL and a `Referer` header usually does too, but log pipelines and tag managers hand over bare hosts often enough that it is handled here rather than at every call site. ## It is safe in a client component [#it-is-safe-in-a-client-component] `lib/ai-referrers.ts` has no `server-only` import, no Node built-ins, no config import, and no I/O. That is deliberate, so it can be imported from a `'use client'` component and read `document.referrer` on first paint. It is the only file in `lib/` with that property. Everything else that touches config imports `server-only`, and a build-time check fails if that ever changes. ## What it recognises [#what-it-recognises] Four matching strategies, in order. | Strategy | Example | Why it is separate | | ---------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Exact host | `chatgpt.com`, `chat.openai.com` | Old hostnames stay alive in old links for years | | Path-scoped host | `bing.com/chat` | `bing.com` on its own is a search referral. Counting all of it as AI overstates it | | Apex suffix | `*.perplexity.ai`, `*.claude.ai`, `*.x.ai` | These products add and rename subdomains | | Known other | `poe.com`, `phind.com`, `meta.ai`, `chat.mistral.ai`, `chat.deepseek.com` | Classified as `other-ai`, so newer assistants are never counted as ordinary links | Adding a host to the `other-ai` list is the low-risk edit. Make it freely. ## Why the integration is not shipped [#why-the-integration-is-not-shipped] Shipping a Google Analytics integration would mean choosing Google Analytics for you, adding a dependency to a block whose pitch is that it adds none, and owning a consent surface nobody here can see. The classifier is the part that is genuinely hard to get right, and it is the part where a mistake is silent: a subdomain that stops matching does not throw, it quietly stops appearing in your numbers. ## Also worth watching [#also-worth-watching] ```bash npx agentblog@latest audit --crawlers /path/to/access.log ``` That parses a server or CDN access log and reports crawler hits per bot per week, verifying each hit against the operators' published IP ranges. User agent strings are trivially spoofed, so any count that trusts the string alone is reporting noise. --- # Install in a monorepo Source: https://docs.agentblog.dev/guides/monorepo Summary: What works in a workspace today, where each file lands, and the one Turborepo setting worth adding. Monorepos work to the extent shadcn gives it for free, which is a real level of support and worth stating precisely rather than generously. ## What works [#what-works] shadcn already handles workspace layouts. It routes base components to your UI package and blocks to the app you ran the command in, and it rewrites imports accordingly. Two requirements: each workspace has its own `components.json`, and the workspaces agree on `style`, `iconLibrary`, and `baseColor`. AgentBlog asks for `card`, `badge`, `separator`, `avatar`, and `button` by bare name, so they resolve through your configuration and inherit all of that with no work from you. ## Where the files land [#where-the-files-land] | File | Destination | | ----------------------------------- | --------------------------------------------------------------------- | | `app/**`, `components/**`, `lib/**` | The app you ran the command in | | `components/ui/*` | Wherever your `components.json` aliases point, often a shared package | | `agentblog.config.ts` | The project root, as shadcn resolves it | | `AGENTS.md` | The same root | | `.claude/skills/**` | The same root | In a workspace, which root those last three land in is the genuine unknown, and getting it wrong scatters the agent layer somewhere nobody looks. It fails quietly, because the files do get written. So `agentblog doctor` detects a workspace, reports where each of the three landed, and never blocks: ```text AgentBlog: pnpm workspace detected. agentblog.config.ts apps/web/agentblog.config.ts AGENTS.md AGENTS.md (repo root) .claude/skills/ .claude/skills/ (repo root) Verify these are where your tooling expects them. ``` If the split is wrong for your setup, move the files and update the single import in `lib/config.ts`. That is the only place `@/agentblog.config` is read, which is exactly why there is only one importer. ## Turborepo [#turborepo] Nothing special is required. Add the blog to whichever app owns it and the usual `build`, `lint`, and `typecheck` tasks pick it up. One setting is worth adding: give the content directory an input on the build task, so editing a post invalidates the cache. ```json title="turbo.json" { "tasks": { "build": { "inputs": ["$TURBO_DEFAULT$", "content/**", "agentblog.config.ts"] } } } ``` Without it, Turborepo can serve a cached build that predates your new post. The symptom is a post that exists in the repository and not on the site. ## What has not been tested [#what-has-not-been-tested] A monorepo fixture is not in CI yet. It joins when the first database-backed content source ships, because that is when workspace users become likely. Until then the mechanism is sound and the disclosure above is accurate. If something lands in the wrong place, please open an issue with your workspace layout. That is more useful than a passing test written to fit the assumption. --- # Decide what to write about Source: https://docs.agentblog.dev/guides/plan-your-content Summary: A working method for choosing topics, grouping them into clusters, and keeping the ones that earn traffic current, using the tools the install gives you. The hardest part of running a blog is not writing the posts. It is knowing which posts to write. This page is a method you can hand to your agent, not a theory of content marketing. ## Start from questions your customers already ask [#start-from-questions-your-customers-already-ask] The posts that earn search traffic and get quoted by assistants answer a specific question completely. So the unit of planning is a question, not a topic. You already have a supply of them: * Support conversations, sales calls, and onboarding sessions. Any question you have answered twice is a post. * The queries that already bring people to your site. Google Search Console, Performance, Queries. * What people ask about your category in public. Reddit threads, competitor comparison pages, and the "People also ask" box. * Your own product decisions. The reasoning behind a choice you made is often the most useful thing you can publish, and nobody else can write it. Write the question down in the words a person would actually type. "Do AI crawlers run JavaScript?" is a post. "AI crawler considerations" is not. ## Group them into clusters [#group-them-into-clusters] A cluster is one broad page plus several narrow ones, all linking to each other. Search engines and retrieval systems both use those links to work out what your site is about, and a group of connected posts on one subject reads as expertise in a way that ten unconnected posts does not. In AgentBlog a cluster maps onto a category. Each entry in `content/categories.json` gets an indexable hub page at `/blog/category/<slug>`, so the category description is real copy that ranks, not a label. ```json title="content/categories.json" [ { "slug": "ai-search", "name": "AI search", "description": "How ChatGPT, Claude, and Perplexity find, read, and cite web pages, and what that means for how you publish." } ] ``` A workable first shape is two or three categories, each with one broad post and three to five narrow ones. More categories than that on a young blog splits your internal links across pages that have none to spare. Tags are for cross-cutting themes rather than structure. Tag pages are `noindex` below a configurable post count, because a tag page with two posts on it is a thin page competing with your real ones. ## Ask your agent to plan, not just to write [#ask-your-agent-to-plan-not-just-to-write] Your agent has your existing posts, your categories, and your config in context, which makes it a better planner than a blank page. ```text Read content/blog and content/categories.json. Propose eight posts for the ai-search cluster that we have not written yet. For each one give the exact question it answers, who it is for, which existing posts should link to it, and which existing post it should link to. Rank them by how likely they are to be searched by someone who could buy our product. ``` That last clause matters more than volume. A question with 200 searches a month from people evaluating a purchase is worth more than one with 20,000 searches from people who will never buy. ## Publish in an order that compounds [#publish-in-an-order-that-compounds] Write the broad post in a cluster first, then the narrow ones, then go back and link the broad one to each of them. Every new post should get at least one inbound link from an existing post in the same commit that adds it. A post nothing links to is discovered only through your sitemap and inherits none of the context around it. Orphan posts are the most common structural failure on a blog that otherwise does everything right. ## Refresh what already works [#refresh-what-already-works] New posts are not the only lever, and often not the best one. A post that already ranks and is now slightly wrong is the cheapest win available. ```bash npx agentblog@latest audit --stale ``` That lists posts by how overdue a refresh is, ranked by how many internal links point at them, so the top of the list is where a refresh pays back most. Then: ```text Refresh content/blog/<slug>.mdx using the refresh-blog-post skill. ``` The agent re-fetches every source, corrects what actually changed, and leaves `dateModified` alone if nothing did. See [writing with your agent](/guides/write-with-your-agent#refreshing-a-post-is-not-rewriting-it) for why that discipline matters. A reasonable cadence for a small team is one new post and one refresh per week. Two posts a week that nobody links to is worse than one post a week inside a cluster. ## Build the entity, not only the pages [#build-the-entity-not-only-the-pages] An assistant that quotes you has to resolve "who published this" to something real. Three things help, and none of them is a blog post: * `brand.sameAs` in your config, pointing at profiles a third party can verify: LinkedIn, GitHub, Crunchbase, YouTube, Wikidata. * A real author with a real bio and their own `sameAs` links, in `content/authors.json`. One named author with a filled-in profile beats three empty ones. * Being discussed somewhere you do not control. Off-site mentions correlate more strongly with AI visibility than raw backlink counts do. The measurements behind that claim, and their limits, are in [the GEO playbook](/concepts/geo-playbook#entity-building). ## Know what "working" looks like [#know-what-working-looks-like] Two numbers, checked monthly. **Impressions per cluster** in Search Console, filtered by URL path. A cluster that is working shows impressions climbing before clicks do. **Assistant referrals**, which do not group with search traffic in any default report. `lib/ai-referrers.ts` classifies them for you. See [measuring AI traffic](/guides/measure-ai-traffic). Give a new cluster three months before you judge it. Give an individual post six weeks before you decide it failed, then refresh it rather than deleting it. <Cards> <Card title="Write the post" href="/first-post" description="From an empty file to something ready to publish." /> <Card title="The format, and the evidence for it" href="/concepts/geo-playbook" description="Why answer capsules, question headings, and tables." /> </Cards> --- # Check a post before publishing Source: https://docs.agentblog.dev/guides/pre-publish-checklist Summary: The pre-publish gate agentblog audit runs, split into what the tooling checks for you and the four things only you can judge. ```bash npx agentblog@latest audit <slug> ``` That runs the mechanical half of this page and reports each item pass or fail with the value it found. It never reports success on a failure. The rest of this page is the same checklist in a form you can read, plus the part no tool can check. ## Once, before your first post [#once-before-your-first-post] These are install-time. `npx agentblog@latest doctor` checks all of them. * [ ] `htmlLimitedBots` in `next.config.ts` includes the Next.js default list as well as the AI crawlers * [ ] `metadataBase` and `title.template` are set in the root layout * [ ] `app/robots.ts` has the deployment guard, so preview URLs stay out of the index * [ ] `sitemap.xml` and `feed.xml` both resolve and list real posts * [ ] The sitemap is submitted to Google Search Console and Bing Webmaster Tools * [ ] Verification tokens are in `agentblog.config.ts` * [ ] The IndexNow key file is served from your domain root and matches `INDEXNOW_KEY` * [ ] `brand.sameAs` points at two or more profiles a third party can verify * [ ] `/editorial-policy` says something specific about how you correct mistakes * [ ] `npx agentblog@latest doctor --url <a live post URL>` passes, including the Googlebot fetch That last one is the check most people skip and the one most likely to fail. See [when your CDN blocks crawlers](/troubleshooting/cdn-blocking-crawlers). ## The post itself [#the-post-itself] * [ ] One `<h1>`, and it matches what the reader was searching for * [ ] `title` is 60 characters or fewer and reads as a title rather than a keyword string * [ ] `description` is 150 to 160 characters and describes the post rather than teasing it * [ ] An answer capsule under the H1: 40 to 60 words, direct, no links inside it * [ ] An answer capsule under each H2, same rules * [ ] H2s are phrased as questions where a question is what a reader would ask * [ ] Sections are 150 to 300 words and each one stands alone * [ ] Entity names are repeated rather than replaced by pronouns * [ ] At least one real statistic, cited * [ ] At least one quotation from a named source, cited * [ ] Comparison data is in a table, not in prose * [ ] Five to fifteen contextual internal links * [ ] At least one existing post now links to this one * [ ] No keyword stuffing. It measures worse than writing normally * [ ] No em dashes, and none of the other copy tells ## Frontmatter [#frontmatter] * [ ] `title`, `description`, `datePublished`, `dateModified`, `author`, `category` are all present * [ ] Dates are ISO 8601 with a UTC offset * [ ] `dateModified` changed only because the content changed * [ ] `tags` are terms a reader would use, and there are not thirty of them * [ ] Every entry in `citations` has a source and a kind * [ ] Every `faq` entry matches a question the body actually answers ## Structured data [#structured-data] The install generates all of this. The audit checks it, and it is worth knowing what is being checked. * [ ] Exactly one `ld+json` block, containing a connected graph * [ ] Every marked-up fact is visible on the page * [ ] An `FAQPage` block only when the FAQs render in the HTML * [ ] `author` is a linked `Person` node with an `@id` and a `url`, never a bare string * [ ] `author.name` carries no job title, honorific, or company name * [ ] Every `sameAs` entry is an absolute URL rather than a handle * [ ] `Organization.logo` carries `width` and `height` * [ ] Validated against the raw HTML rather than the rendered page Google's Rich Results Test and the Schema Markup Validator answer different questions, eligibility and vocabulary, so check both when something looks wrong. ## Images [#images] * [ ] Descriptive file names rather than `IMG_1234.png` * [ ] `alt` describes what the image shows, in plain language * [ ] Every chart is paired with its numbers in a table nearby * [ ] No critical text that exists only inside an image * [ ] `preload` on at most one image per page, and only when it is definitely the largest element ## After it is live [#after-it-is-live] Do this every time, immediately after deploying: ```bash curl -s -A "GPTBot" "$URL" | grep -q "a distinctive sentence from the article" curl -s -A "GPTBot" "$URL" | head -c 4000 | grep -q "<title>" ``` The first asserts that the article text reaches a crawler that runs no JavaScript. The second asserts that `<title>` landed inside `<head>` rather than being appended to `<body>`, which is what happens when page metadata streams. Then the version that also catches a CDN turning crawlers away: ```bash npx agentblog@latest doctor --url "$URL" ``` And the publishing half: * [ ] The publish step revalidated `sitemap.xml` and `feed.xml` as well as the post * [ ] IndexNow returned 200 or 202, not 403 or 422 * [ ] The post appears in `sitemap.xml` and in `feed.xml` * [ ] The canonical URL on the page matches the sitemap entry exactly ## What no tool can check [#what-no-tool-can-check] Four things, and the audit will pass a post that fails all of them: * Whether the post answers the question a reader actually has. * Whether the statistic is true. * Whether the quotation is real and correctly attributed. * Whether you would link to this post from another site. That is the part of the job that stays yours. --- # Take an update Source: https://docs.agentblog.dev/guides/take-an-update Summary: How to pull a fix into an install you have already edited, using shadcn add --diff and --overwrite, without losing your changes. The files are yours. That is the whole pitch, and it has one cost: there is no `npm update` that carries a fix from us into your repository, because there is no package of ours in your dependency tree. Updating is something you do deliberately, in three commands. ```bash npx shadcn@latest add @agentblog/blog --diff # see what changed npx shadcn@latest add @agentblog/blog-schema --overwrite # take one part of it npx agentblog@latest doctor # confirm nothing broke ``` ## Read the diff first [#read-the-diff-first] ```bash npx shadcn@latest add @agentblog/blog --diff ``` That fetches the current version of every file in the item and prints the difference against what is on your disk, without writing anything. Pass a path to narrow it to one file, or `--view <path>` to print the upstream file on its own. There are three cases, and only one of them needs thought: | What the diff shows | What to do | | ----------------------------------------------------- | ------------------------------------------------- | | A file you never touched, changed upstream | Take it with `--overwrite` | | A file you edited, changed upstream somewhere else | Copy the upstream change in by hand | | A file you edited, changed upstream in the same place | Read both, decide, and leave a comment saying why | ## Take the change [#take-the-change] ```bash npx shadcn@latest add @agentblog/blog --overwrite # everything npx shadcn@latest add @agentblog/blog-schema --overwrite # one item ``` `--overwrite` replaces every file the item declares. There is no per-file flag, so narrow by item instead. The registry is split into `blog-schema`, `blog-ui`, `blog-routes`, `seo-routes`, `mdx-components`, `publish-webhook`, `eeat-pages`, `source-mdx`, and `agent-kit` exactly so you can take part of an update. Commit before you run this. `shadcn add --overwrite` backs nothing up, so your version control is what makes it reversible. `--dry-run` prints what it would write if you want one more look. ## Four files are always safe to overwrite [#four-files-are-always-safe-to-overwrite] `lib/schemas.ts`, `lib/types.ts`, `lib/define-config.ts`, and `lib/preflight-checks.ts` carry a banner saying they are generated. Nobody should have edited them, and they are the files most likely to carry a real fix: a schema correction, a new configuration check, a new crawler in the bot list. If a diff on one of those shows local changes, that is the finding. Something edited a generated file, and the change will be lost anyway. ## The config half comes from the CLI [#the-config-half-comes-from-the-cli] `shadcn add` only writes files. Your `next.config.ts` and your root layout live outside the block, so they update through the CLI: ```bash npx agentblog@latest doctor --fix ``` This is how a new entry in the bot list reaches you. When Next.js adds a bot to its default list, the fix is a wider pattern in `next.config.ts` rather than a changed component, so no amount of `--overwrite` delivers it. `doctor` reports `html-limited-bots-incomplete` and `--fix` adds the missing names. Every file `doctor --fix` touches is copied to `.agentblog/backup/<timestamp>/` first, `--dry-run` prints the diff, and `agentblog revert` restores the last backup. ## Then verify [#then-verify] ```bash npx agentblog@latest doctor npx agentblog@latest audit npx agentblog@latest doctor --url https://yoursite.com/blog/your-post ``` Run the third one after you deploy, from your own machine or from CI. See [verify the install](/installation#verify-the-install) for why the location of the request matters. ## About pinning [#about-pinning] You can pin the GitHub install path to a ref, which gives you a reproducible fetch: ```bash npx shadcn@latest add goldk3y/agentblog/blog#v1.2.0 ``` It does not give you an upgrade path, because nothing records which ref you installed. If you want to know what you have, note the ref in a comment in `agentblog.config.ts` when you install. --- # Write posts with your coding agent Source: https://docs.agentblog.dev/guides/write-with-your-agent Summary: The four skills AgentBlog installs, what to ask for, what the agent will refuse to do, and where the skills deliberately stop. Posts are MDX files in your repository, so writing one is editing a file, which is the thing coding agents are already good at. AgentBlog installs four skills that tell your agent what a good post looks like here, so you do not have to describe the format every time. ## The four skills [#the-four-skills] They land in `.claude/skills/` during the install. | Skill | What it does | | ------------------- | --------------------------------------------------------------------------------- | | `write-blog-post` | Writes a new post in the format AI search engines can quote | | `refresh-blog-post` | Re-checks an existing post's sources and updates only what actually changed | | `agentblog-setup` | Finishes install wiring, if you took the registry path and skipped `doctor --fix` | | `agentblog-audit` | Runs the pre-publish gate, including a raw crawler fetch | Claude Code loads a skill when what you asked for matches its description, so most of the time you write a normal sentence and the right skill appears. You can also call one directly with `/write-blog-post`. ## What to actually say [#what-to-actually-say] Good prompts name the file and the goal. The skill supplies the format. ```text Write content/blog/do-ai-crawlers-run-javascript.mdx. The reader is a developer deciding whether their marketing site needs server rendering. Match the voice of the two existing posts. ``` ```text Refresh content/blog/how-ai-search-engines-read-your-blog.mdx. Check every citation still says what we claim it says. ``` ```text Audit content/blog/do-ai-crawlers-run-javascript.mdx before I publish it. ``` You do not need to explain answer capsules, heading format, or schema fields. That is what the skill is. ## What the agent does when it writes [#what-the-agent-does-when-it-writes] 1. Reads `agentblog.config.ts` and your existing posts, to learn your voice, your topic clusters, and which posts should link to the new one. 2. Works out the question the post answers, and the sub-questions under it. 3. Outlines the H2 headings as questions. 4. Drafts answer-first: 40 to 60 words directly under the H1, and again under each H2, with no links inside those paragraphs. 5. Keeps sections to 150 to 300 words, each one able to stand alone. 6. Includes at least one real statistic and one named-source quotation, both cited in the frontmatter. 7. Puts comparison data in a real table rather than in prose. 8. Adds five to fifteen internal links, and at least one link from an existing post to the new one, so it is not an orphan. 9. Fills in the frontmatter completely. 10. Runs the [pre-publish checklist](/guides/pre-publish-checklist) and reports each item pass or fail. ## What it will not do [#what-it-will-not-do] Four rules sit in the skill's always-loaded text rather than in a reference file it might not open, because these are the ones where skipping is expensive. * **Never invent a statistic, a quotation, or a source.** This is the rule that keeps the whole idea from being a liability. If a number cannot be verified at a source the agent actually fetched, the claim goes qualitative instead. * No keyword stuffing. It measures worse than writing normally, not neutral. * No padding to reach a word count. * No em dashes, and no stock phrasing. See [why the copy style matters](#the-copy-style-rules). ## Refreshing a post is not rewriting it [#refreshing-a-post-is-not-rewriting-it] `refresh-blog-post` exists because a refresh that reflows correct prose produces a large diff with no information in it and buries the actual corrections. The agent fetches every source in `citations[]` and answers three questions about each: does it still resolve, does it still say what your post says it says, and has it been superseded. Documentation gets rewritten and vendors quietly restate figures, so the second question is the one that matters and the one everybody skips. Then it applies the smallest correct change, and decides the date deliberately: | What changed | `dateModified` | | -------------------------------------------------------------------- | -------------- | | A number, a dead citation, a new section, a corrected claim | Moves | | A typo, a reflowed paragraph, a reformatted table with the same rows | Stays | | Nothing, because every fact still checks out | Stays | Freshness correlates with being cited, which is a reason to genuinely update posts and not a reason to restamp them. A date that does not match a real change teaches everything downstream to ignore that field, and the engines that reward freshness are the same ones holding a copy of what your page said last week. "Nothing needed changing" is a successful outcome. The skill is instructed to say so and stop. It also refuses to refresh, and says why, when more than about half the post's claims are now wrong or the central premise has been invalidated. Refreshing the numbers around a dead premise produces a well-cited wrong article. To find what needs a refresh: ```bash npx agentblog@latest audit --stale ``` That ranks posts by how overdue they are, weighted by how many internal links point at them. ## Installing the skills without installing the blog [#installing-the-skills-without-installing-the-blog] The same four skills are also a Claude Code plugin, which is useful if you want them in a repository that is not your Next.js app. ```text /plugin marketplace add goldk3y/agentblog /plugin install agentblog@agentblog ``` The install id is `<plugin>@<marketplace>`. Both halves are `agentblog` here, which reads like a typo and is not. Or through the registry, into `.claude/skills/`: ```bash npx shadcn@latest add @agentblog/agent-kit ``` ## AGENTS.md [#agentsmd] The install also appends a block to your `AGENTS.md`, which most agent tools read as project instructions. It states the ten rules that must not be broken, so an agent that never loads a skill still knows the important ones: do not fabricate, do not narrow `htmlLimitedBots`, do not add `'use client'` to the article render path, do not bump `dateModified` without a reason. On Next.js 16.3 and later, `next dev` also manages a block in that file between its own markers. AgentBlog writes strictly after the closing marker and never inside it, and never writes `CLAUDE.md` at all. ## The copy style rules [#the-copy-style-rules] One rule sits above the rest: no em dashes. Not in posts, not in the docs, not in CLI output. The reason is commercial rather than aesthetic. The em dash has become the most recognizable tell of machine-written prose, and readers discount text that leans on it. For a product whose deliverable is AI-assisted writing that people actually read, shipping copy that reads as machine-written undermines the thing being sold. Bundled with it, since they share a cause: no "in today's fast-paced world" openers, no "it is not just X, it is Y" construction, no delve, leverage, robust, seamless, landscape, or tapestry, no rhetorical question followed by its own answer, and no three-item list where two would do. `agentblog audit` fails a post that contains any of them. ## What the skills deliberately do not teach [#what-the-skills-deliberately-do-not-teach] Two things already own their surface, and duplicating them would make the skills worse. Next.js ships version-matched documentation inside `node_modules`, so an agent in your repository already has correct API docs with no network call. shadcn ships an official skill covering its CLI and registry system. AgentBlog's skills assume both exist and cover what is genuinely ours: the writing playbook, the schema shapes, and the install wiring. <Cards> <Card title="What to write about" href="/guides/plan-your-content" description="Picking topics, clustering them, and knowing when to refresh." /> <Card title="The playbook itself" href="/concepts/geo-playbook" description="Why the format is the format, with the evidence attached." /> </Cards> --- # Licensing Source: https://docs.agentblog.dev/project/licensing Summary: Three licenses scoped by directory, and the reasoning behind each one. | Scope | License | File | | ----------------------------------------------- | --------- | ----------------- | | Code | MIT | `LICENSE` | | The example posts | CC0 1.0 | `LICENSE-SEED` | | Documentation prose, including the GEO playbook | CC BY 4.0 | `LICENSE-CONTENT` | ## Code: MIT [#code-mit] A restrictive license on a template whose entire distribution mechanism is copying files into your repository would be incoherent. MIT matches shadcn, matches Next.js, and matches every expectation the audience has. ## The example posts: CC0 [#the-example-posts-cc0] The two posts in `content/blog/` are copied into your repository and become your published content. Any attribution requirement would mean every AgentBlog user technically owed credit on their own blog, which is unenforceable and hostile. CC0 says so explicitly. Delete them, rewrite them, or publish them as they are. They are yours the moment they land. ## Documentation: CC BY 4.0 [#documentation-cc-by-40] This reverses the obvious instinct, which was to stop a competitor reprinting the playbook. The realistic threat is scraping, and no license prevents scraping. The realistic upside is being quoted, and CC BY's attribution requirement is exactly the citation this product exists to generate. Licensing the best top-of-funnel asset here so that reproducing it requires a credit link is the most on-thesis choice available. So: reproduce these pages, translate them, quote them at length, put them in your internal wiki, feed them to a model. Credit `agentblog.dev` with a link. ## What each one covers [#what-each-one-covers] * **MIT:** everything under `packages/`, the code in `apps/web/registry/blog/{app,components,lib,hooks,styles}/`, `apps/web/app/`, `apps/web/components/`, `apps/web/lib/`, `apps/docs/{app,lib,components}/`, `scripts/`, and `plugins/`. * **CC0:** `apps/web/registry/blog/content/**`. * **CC BY 4.0:** `apps/docs/content/docs/**`. SPDX headers mark the boundary in ambiguous files. ## Third-party licenses [#third-party-licenses] AgentBlog composes shadcn/ui components (MIT) and depends on Next.js, React, Zod, Tailwind CSS, and the unified ecosystem, all MIT. Nothing in the dependency tree carries a copyleft obligation. The typography plugin is MIT. `schema-dts` is Apache 2.0, and it is a development dependency in the blog: it types the structured data builders and does not ship to the browser. ## Contributions [#contributions] By opening a pull request you agree that your contribution is licensed under the license covering the directory you changed. There is no contributor license agreement. --- # Roadmap and non-goals Source: https://docs.agentblog.dev/project/roadmap Summary: What is shipping, in what order, and the four things v1 deliberately does not do. ## What v1 does not do [#what-v1-does-not-do] Each of these is a decision with a reason, stated so you can tell whether it matters to you before you install. ### Multi-locale routing [#multi-locale-routing] Full internationalisation means locale-prefixed routes, alternate language links in both the metadata and the sitemap, per-locale feeds, translation linking, and locale-filtered prerendering. That is a large surface, and it multiplies the number of pages the blog builds. What was taken is the cheap half. Retrofitting locale into the content source interface later would be a breaking change to the one interface that exists to stay stable, so four optional locale fields are reserved on the schema and the contract now, unused. Adding locale later is additive rather than breaking. ### Tailwind v3 [#tailwind-v3] Supporting it would mean a second colour format, a second prose bridge, and a second config shape. Decisively, v3 users have to pin an old shadcn CLI that predates namespaced registries, so half the install story does not function for them. Supporting v3 means shipping a second product against a CLI that cannot install it. `agentblog doctor` reports this as an error rather than a warning. ### An `llms.txt` in the blog [#an-llmstxt-in-the-blog] See [why the blog does not ship one](/concepts/why-no-llms-txt). Short version: Google is on record that Search does not use it, no major provider consumes it in production, and the best-instrumented measurement anyone here could retrieve logged 84 requests to `/llms.txt` out of 62,100 AI bot visits over 90 days. This documentation site serves one, because developer documentation with programmatic readers is the one place the token saving is real. ### Certified monorepo support [#certified-monorepo-support] See [installing in a monorepo](/guides/monorepo), which states precisely what works, what is untested, and where each file lands. The mechanism works because shadcn already handles it. A fixture is not in CI yet. ## Also not in v1 [#also-not-in-v1] * **A hosted CMS.** The content source interface is the seam. There is nothing behind it yet. * **An analytics integration.** The referrer classifier ships as a pure function with no dependency, and [measuring AI traffic](/guides/measure-ai-traffic) shows the wiring for three tools. Shipping an integration would mean picking a vendor for you. * **Comments.** Not a blog problem worth solving twice. * **A theme gallery.** The blog has no theme. That is the point. * **Search.** Your site probably already has one, and if it does not, that is a site decision rather than a blog decision. ## Order of work [#order-of-work] **Now.** The registry, the blog, the CLI, and the agent layer. The MDX source is the only content source. **Next.** Supabase and Convex sources, in that order, each passing the same eight contract assertions. A monorepo fixture joins CI alongside the first of them. **After that.** Measurement. `agentblog audit --crawlers` reads a log you already have and reports verified hits per bot per week. A hosted version reads Search Console over OAuth. Both sit on one interface, so the free tier and the paid one are the same shape. **Later.** A hosted content source, and a dashboard on top of the measurement layer. ## Maintenance, as a standing job [#maintenance-as-a-standing-job] This product depends on third-party surfaces that move. Naming the cadence is more useful than promising to keep up. | Cadence | What gets checked | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Every CI run | The vendored bot list still matches Next.js. The registry still validates. Generated files have not drifted | | Monthly | Re-fetch the crawler IP range files and diff them. Look for new user agents | | Quarterly | Re-verify the Next.js API surface against the installed minor. Re-read the shadcn changelog. Re-check the CDN policies | | Every Next.js major | Full re-verification against the upgrade guide. The CI fixture moves to the new major first | The fixture in CI tracks the latest stable Next.js, which means upstream breakage surfaces in the AgentBlog repository before it surfaces in yours. ## Version compatibility [#version-compatibility] | AgentBlog | Next.js | React | Tailwind | shadcn CLI | | --------- | ------- | ----- | -------- | ---------- | | 0.x | 16.x | 19.x | 4.x | 4.x | Next.js 17 will invalidate parts of this, the same way 16 invalidated the 15-era guidance. When it lands, the fixture moves first and the blog follows. --- # CLI Source: https://docs.agentblog.dev/reference/cli Summary: Every agentblog command, its flags, what doctor checks, and what --fix will and will not repair. ```bash npx agentblog@latest <command> ``` Published to npm as `agentblog`. It bundles its own copy of TypeScript rather than resolving one from your `node_modules`, because it edits config files in projects whose TypeScript version it does not control. ## Commands [#commands] | Command | Does | | ---------------- | ---------------------------------------------------------------------------------- | | `init` | Check the stack, prompt, install, patch the configs, generate keys, run `doctor` | | `create <name>` | Scaffold a new Next.js project and install the blog into it | | `doctor [--fix]` | Verify the install. Exits non-zero on an error, so it works in CI | | `audit [slug]` | The pre-publish checklist, for one post or all of them | | `new <title>` | Scaffold a post file with correct frontmatter | | `ping <slug>` | Revalidate and submit to IndexNow by hand | | `revert` | Restore the last patch set from `.agentblog/backup/` | | `uninstall` | Reverse every config patch, remove the `AGENTS.md` block, list the files to delete | | `telemetry` | Anonymous usage data: `on`, `off`, or `status` | ## Flags [#flags] Four flags are global, accepted before the command name and by every command. Everything else belongs to one command or a few, and `agentblog <command> --help` is the authority. | Global flag | Effect | | ----------------- | -------------------------------------------------------------------------- | | `--cwd <path>` | Run as if `agentblog` had started in this directory | | `--no-telemetry` | Disable anonymous usage data for this run. `DO_NOT_TRACK` is also honoured | | `-v`, `--version` | Print the version | | `-h`, `--help` | Print help for the command | There is no global `--dry-run`, `--yes`, `--force`, or `--json`. Passing one to a command that does not declare it is an error rather than a no-op, which is worth knowing before you script against this. | Flag | Accepted by | Effect | | -------------- | ------------------------------------------------------- | ------------------------------------------------------------ | | `--dry-run` | `init`, `create`, `doctor --fix`, `revert`, `uninstall` | Print the unified diff and write nothing | | `-y`, `--yes` | `init`, `create` | Skip the prompts. Read the contract below first | | `--force` | `init` | Proceed even when files already exist under `app/blog` | | `--json` | `doctor`, `audit` | Print the whole report as one JSON document and nothing else | | `--verbose` | `doctor`, `audit` | List passing checks as well as failures | | `--offline` | `doctor` | Skip every check that needs the network | | `--dir <path>` | `audit`, `new` | Content directory. Defaults to `content/blog` | ### What `--yes` actually means [#what---yes-actually-means] `--yes` does not accept prompt defaults, because three of the prompts have no defensible default. `init --yes` on its own refuses and tells you so. The non-interactive form is all four flags together: ```bash npx agentblog@latest init --yes \ --site-url https://yoursite.com \ --brand "Your Brand" \ --author your-slug ``` Each value is validated against the same rule its prompt uses. A flag that accepted what the prompt rejects would be the same bug arriving through a different door. ## init [#init] ```bash npx agentblog@latest init [--source mdx] [--site-url https://…] [--brand "Name"] [--author slug] [--yes] [--dry-run] [--force] [--skip-install] [--reinstall] [--registry <url>] ``` Refuses, before writing anything, when Next.js is older than 16.3 or not on the App Router, React is not 19, Tailwind is not v4, or `components.json` is absent. The last one prints the `shadcn init` command rather than running it. It also refuses when `app/blog/**` already exists and AgentBlog did not write it, and prints the conflicting paths. `--force` proceeds. `--skip-install` runs only the config patches, which is what you want when the files are already on disk. `--reinstall` forces the file copy on a project that already has an install manifest. `--registry` points the `@agentblog` namespace somewhere other than `agentblog.dev`, for a local server or a private mirror. Your content is never overwritten. `content/authors.json`, `content/categories.json`, and `content/blog/**` are snapshotted before the install runs and restored afterwards, so seed content only ever lands where there was nothing. ### What a second run does [#what-a-second-run-does] Nothing. Each patch site has stated semantics rather than "write the value": | Patch site | Behaviour when a value already exists | | -------------------------------- | -------------------------------------------------------------------------- | | `htmlLimitedBots` | Union, never replace. Replacing is a live SEO regression | | `images.qualities` | Union with your array, deduped and sorted | | `images.remotePatterns` | Appends when no structurally equal entry exists | | `metadataBase`, `title.template` | Written only when absent. Reported, never overwritten | | The `AGENTS.md` block | Replaced between its own markers. Appended when absent | | The IndexNow key | Generated only when both the key file and the environment value are absent | Anything the CLI declines to overwrite becomes a `doctor` warning, so you are told rather than left half-configured. ### AGENTS.md, and the file Next.js also owns [#agentsmd-and-the-file-nextjs-also-owns] On Next.js 16.3 and later, `next dev` generates `AGENTS.md` and `CLAUDE.md` at your project root and rewrites a managed block inside its own markers on every run. Content outside those markers is preserved. So `init` writes strictly after the closing marker, never inside or across it, and never writes `CLAUDE.md` at all. Next.js generates that file already importing `AGENTS.md`, so adding our block there would put the same text into context twice. A hand-written `CLAUDE.md` is left alone. ## doctor [#doctor] ```bash npx agentblog@latest doctor [--fix] [--dry-run] [--url https://…] [--offline] [--verbose] [--json] ``` Exits non-zero on any error-severity finding. Every finding carries a stable id, printed with the message and included under `--json`. Search for the id rather than the wording: the wording changes and the id does not. ### What it checks [#what-it-checks] | Area | Checks | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Crawler config | `htmlLimitedBots` exists and is a superset of the Next.js default list plus the AI crawlers | | Metadata | `metadataBase`, `title.template`, and the RSS entry in the root layout | | Prerendering | `generateStaticParams` exists in the post route and is not sliced. An AST check, not a text search | | Robots | The environment guard in `app/robots.ts` | | Sitemap | No fabricated `lastModified` from `new Date()` | | Render path | No `'use client'` anywhere in the article render path | | Revalidation | Every `revalidateTag` call passes a profile, and the publish path uses `{ expire: 0 }` | | Versions | Next.js on a patched release, React 19, Tailwind v4, Node 20.9 or newer | | Images | `images.qualities` covers every quality the block uses, and `preload` is on at most one image per route | | Secrets | The IndexNow key file exists and matches, and no AgentBlog secret is in a git-tracked file | | Routes | No collision with an existing `app/blog/**`, and every route file landed | | Structured output | Post metadata still spreads the shared defaults, asserted against built HTML rather than source | | Agent files | The `AGENTS.md` block is outside the Next.js markers and no `CLAUDE.md` was written by us | | Your config | `defineConfig` is used, `siteUrl` is real, `brand.sameAs` is not empty, the author roster has been edited, one config importer | | Theme | No palette utilities, colour literals, or dark-mode colour variants in the installed components | | Copy style | No em dashes in the seed posts, the registry text, or the `AGENTS.md` block | | Client bundle | Zod is absent from every client chunk | | Monorepo | Reports where each root-level file landed. Never blocks | With `--url` it adds the live checks: it fetches the URL as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Googlebot, and asserts each gets a 200 with body text present and `<title>` inside `<head>`. A 403, a challenge page, or an interstitial is a blocking failure that names the CDN it detected. <Callout type="warn" title="Run the live checks from outside the deployment"> It is an ordinary fetch from wherever the CLI runs. A request originating inside your own network can bypass the exact CDN rule the check exists to find, and then the most valuable check in the product passes on a site no crawler can reach. </Callout> ### What `--fix` repairs [#what---fix-repairs] A fixed list, and nothing else. It never edits a route beyond the robots guard, and it never touches your content. | It writes | It declines when | | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `htmlLimitedBots`, as the union of your value, the Next.js list, and the AI crawlers | The key appears twice, a spread could set it, or the current value is not a literal it can read | | `metadataBase`, when absent | `siteUrl` is missing or still the placeholder, or the layout has no metadata object | | `title.template`, when there is no title or the title is already an object | **The title is a plain string.** Converting it changes how every page title on your site is composed, so that is yours | | The RSS entry under `alternates.types` | `alternates` is not an object literal, or `types` is already set | | The robots environment guard | The default export is not a function body it can edit | | `images.qualities`, unioned and sorted | `images` appears twice or is not a literal | | `INDEXNOW_KEY`, `AGENTBLOG_REVALIDATE_SECRET`, and the key file in `public/` | A `public/*.txt` only counts as a key file when it contains exactly its own name, so `security.txt` is never touched | | The `AGENTS.md` block, after the Next.js managed region | The markers are malformed | Everything else is reported with a remedy and never written, for three reasons: it is your code (route files and render paths), it is your decision (tsconfig strictness, config values, components you may have restyled deliberately), or it cannot be written (version floors, missing packages, live HTTP responses). <Callout title="If the fixable count never goes down"> The summary counts findings that are marked fixable, which is a property of the finding rather than a prediction about your file. A project whose root layout has `title: 'My Site'` as a plain string is told forever that one thing can be repaired, while `--fix` correctly declines to convert it every time. Read the declined list, or run `doctor --fix --json` and read `fix.declined`. </Callout> ## audit [#audit] ```bash npx agentblog@latest audit [slug] [--dir <path>] [--stale] [--days <n>] [--crawlers <logfile>] [--verbose] [--json] ``` Runs the [pre-publish checklist](/guides/pre-publish-checklist) against one post or all of them, reporting each item pass or fail. It never claims success on a failure. Every check runs on every post. There is no flag to run a subset, because the checks a writer would switch off are the ones that catch the expensive mistakes. Narrow the input instead: pass a slug, or pass `--dir`. `--stale` lists posts by how overdue a refresh is, ranked by inbound internal links, with `--days` setting the threshold (90 by default). `--crawlers` parses a server or CDN access log and reports hits per bot per week, verifying each hit against the operators' published IP ranges. User agent strings are trivially spoofed, so a count that trusts the string alone is reporting noise. ## new [#new] ```bash npx agentblog@latest new "Do AI crawlers run JavaScript?" [--slug <slug>] [--dir <path>] [--author <slug>] [--category <slug>] ``` Writes `content/blog/do-ai-crawlers-run-javascript.mdx` with complete frontmatter, today's date with an offset, and `draft: true`. It does not write the post. <Callout type="warn" title="Pass --author and --category"> `new` does not read `agentblog.config.ts`, so without them it writes placeholder values that name no real record. `draft: true` does not save you: drafts are validated like any other post, so the next build fails with `unknown author slug`. </Callout> ## ping [#ping] ```bash npx agentblog@latest ping do-ai-crawlers-run-javascript [--skip-revalidate] [--skip-indexnow] [--site-url <url>] [--key <key>] [--secret <secret>] ``` Calls your publish webhook and submits the URL to IndexNow, printing the response code with its meaning attached: 200 submitted, 202 accepted with key validation pending, 400 bad format, 403 key invalid or missing, 422 URL and host mismatch, 429 rate limited. The 403 and 422 cases look identical to success from the caller's side, which is why they are printed. Exit code 0 means every step you asked for happened. A missing credential is an error rather than a warning, because a publish step that silently submits nothing and reports success is the failure this command exists to make loud. ## revert and uninstall [#revert-and-uninstall] ```bash npx agentblog@latest revert [--all] [--dry-run] npx agentblog@latest uninstall [--keep-env] [--keep-backups] [--dry-run] ``` `revert` restores the most recent backup only, so a second `doctor --fix` can be undone without also unwinding your `init`. `revert --all` replays every backup newest to oldest, returning the patched files to their state before the first run. `uninstall` removes the `AGENTS.md` block, removes the two environment variables AgentBlog added, restores every backup, removes `.agentblog/`, and then lists the files the registry wrote without deleting any of them. You have probably edited some, and deleting edited components is not ours to do. ## Telemetry [#telemetry] Anonymous and opt-out: install count, framework version, chosen content source, and doctor pass rate. Nothing about your content, your URLs, or your configuration values is collected. In this release the events are written to a local file and never sent anywhere. `--no-telemetry` disables it for a run, `agentblog telemetry off` disables it permanently, and `DO_NOT_TRACK` is honoured. --- # Configuration Source: https://docs.agentblog.dev/reference/configuration Summary: Every field in agentblog.config.ts, its type, its default, and what breaks when it is wrong. `agentblog.config.ts` sits at your project root, next to `package.json` and `next.config.ts`, including in a `src/` layout. Exactly one module reads it: `lib/config.ts`. That is enforced by `agentblog doctor`, because `@/` maps to the root in a flat layout and to `src/` in a `src/` layout, so one import specifier cannot be right in both. With a single importer, fixing a `src/` project is a one-line change rather than a search across forty files. ## The shape [#the-shape] ```ts title="agentblog.config.ts" import { defineConfig } from '@/lib/define-config' import { mdxSource } from '@/lib/sources/mdx' export default defineConfig({ siteUrl: 'https://yourdomain.com', brand: { name: 'Your Brand', logo: { url: '/logo.png', width: 512, height: 512 }, sameAs: ['https://github.com/yourbrand'], }, source: mdxSource({ dir: 'content/blog' }), }) ``` `siteUrl`, `brand`, and `source` are required. Everything else has a default. ## Every field [#every-field] | Field | Type | Default | Notes | | ------------------ | -------------------------------------- | -------------------- | ---------------------------------------------------------------------------------- | | `siteUrl` | `string` | required | https, no trailing slash, no path. Trailing slashes are stripped before validation | | `locale` | `string` | `'en_US'` | Emitted as `og:locale` and as `inLanguage` in the structured data | | `brand.name` | `string` | required | Becomes `og:site_name` and `Organization.name` | | `brand.logo` | `{ url, width, height }` | required | `width` and `height` are required, not optional | | `brand.sameAs` | `string[]` | `[]` | Absolute URLs only. A bare handle is rejected at build time | | `source` | `ContentSource` | required | See [content sources](/reference/content-sources) | | `deployHook` | `string` | none | Required, at compile time, when the source needs a rebuild to publish | | `revalidate` | `number` | `3600` | Revalidation window in seconds for the blog routes | | `postsPerPage` | `number` | `12` | Index pagination | | `noindexTagsBelow` | `number` | `5` | Tag pages below this post count are marked `noindex` | | `indexnow` | `{ enabled, key? }` | `{ enabled: false }` | The key is 8 to 128 characters | | `verification` | `{ google?, yandex?, yahoo?, other? }` | `{}` | Maps straight onto the Next.js metadata verification fields | | `aiAccess` | `{ train?, search?, agent? }` | all `true` | Emitted into `robots.txt` | | `preflight` | `boolean` | `true` | Build-time config linting | | `trailingSlash` | `boolean` | `false` | Must match `next.config.ts` | | `defaultAuthor` | `string` | none | Author slug used when a post omits one | Unknown keys are ignored rather than rejected, so an invented field is silently dead. Change values, not structure. ## The fields worth understanding [#the-fields-worth-understanding] ### `siteUrl` [#siteurl] Every canonical link, sitemap entry, RSS link, and structured data identifier is composed from this string, so a wrong value is wrong in about forty places at once. Point it at the domain readers actually visit, never at a preview deployment. A trailing slash would produce `https://site.dev//blog/post` in every canonical, so it is stripped and the value is revalidated before use. Never build a URL by concatenating onto `config.siteUrl`. Use `absoluteUrl`, `postUrl`, `categoryUrl`, `tagUrl`, and `authorUrl` from `lib/config.ts`. They are the only code that knows your trailing-slash policy and the rule that a path ending in a file extension never takes one. ### `brand.sameAs` [#brandsameas] The single most valuable property in the file. It becomes `Organization.sameAs`, which is how a search engine or an assistant resolves "the company that published this" to a real entity rather than to a string that looks like a company name. Entity resolution is what lets a model cite you by name and attribute the claim correctly. List profiles a third party can verify: LinkedIn, Crunchbase, GitHub, YouTube, Wikidata. A bare handle is rejected, because it disambiguates nothing. ### `brand.logo` [#brandlogo] `width` and `height` are required because Google's Organization markup needs both, and a logo without dimensions is one of the most common structured data errors on the web. A relative `url` resolves against `siteUrl`. ### `deployHook` [#deployhook] Required when your content source declares that publishing needs a rebuild. Set it to a rebuild trigger URL so the publish webhook can rebuild before pinging IndexNow. In the other order you are telling a crawler to fetch a URL that was never built. You will not forget this one: the file stops type checking until it is set. ### `noindexTagsBelow` [#noindextagsbelow] Thin tag pages are the classic way a blog generates hundreds of near-empty indexable URLs and spends its own crawl budget on them. Categories are always indexable. Tags have to earn it. Set it to `0` to index every tag. ### `indexnow` [#indexnow] ```ts indexnow: { enabled: true, key: process.env.INDEXNOW_KEY } ``` The key is read from the config first and falls back to `INDEXNOW_KEY` in the environment, so the secret never has to live in this committed file. The matching `<key>.txt` file must be served from your domain root, UTF-8, containing the key and nothing else, or every submission comes back 403. A key hosted at a subpath only authorises URLs under that subpath. `agentblog doctor --fix` writes the file for you. The limit is 10,000 URLs per request, not per day. ### `aiAccess` [#aiaccess] ```ts aiAccess: { train: true, search: true, agent: true } ``` A forward-looking seam. Three standards are converging on machine-readable AI usage preferences, and all of them attach to `robots.txt` or to HTTP headers. None is implemented yet. When one lands, this same object emits it with no change to your config file. Setting `train: false` is a real tradeoff rather than a free one. Several crawlers are multi-purpose, so opting out of training can also opt you out of the search index that would have cited you. ### `preflight` [#preflight] On by default. It reads your `next.config` off disk once per process during dev and build and warns when a required setting is missing or narrowed. It never throws and it does nothing at request time. Set it to `false` only once you know your config is correct and you want the warning gone. That is the supported way to switch it off. Deleting the preflight import from `app/blog/layout.tsx` is not, because then nothing tells you when a later edit breaks the config again. ### `trailingSlash` [#trailingslash] Must match `trailingSlash` in `next.config.ts`. The URL helpers compose canonicals from this value, so two different answers means every canonical points at a URL that immediately redirects to its other form. Search engines treat that as a redirect chain and AI crawlers frequently do not follow it at all. ## Why `defineConfig` rather than `satisfies` [#why-defineconfig-rather-than-satisfies] `defineConfig` is an identity function that exists for its type signature. It gives contextual inference, which is what lets the `deployHook` requirement depend on which content source you passed. `satisfies` checks an object against a fixed type and cannot do that. Concretely: swap `mdxSource` for a source that needs a rebuild to publish, and the file stops type checking until you supply a `deployHook`. A runtime surprise becomes a compile error, which is the highest-value thing the type layer does here. ## Validation [#validation] Your config is validated once, when `lib/config.ts` is first imported. A bad value fails the build with a message naming the key, rather than producing `undefined` inside a structured data graph three files later. --- # Content sources Source: https://docs.agentblog.dev/reference/content-sources Summary: Where posts come from, the MDX source that ships with the blog, and what it takes to write your own. A content source is the boundary between the blog and wherever your posts live. Every route reads posts through `lib/posts.ts`, which is a thin layer over whatever you set as `source`, so changing where posts come from is one line in `agentblog.config.ts` and nothing else in the blog changes. ```ts source: mdxSource({ dir: 'content/blog' }) ``` If you are happy writing posts as files in your repository, that line is all you need and the rest of this page is background. ## The MDX source [#the-mdx-source] ```ts mdxSource({ dir: 'content/blog', authorsFile: 'content/authors.json', onInvalid: 'warn', }) ``` | Option | Default | Notes | | ---------------- | ------------------------- | ------------------------------------------------------------------------ | | `dir` | required | Directory of `.mdx` files, relative to the project root | | `authorsFile` | `content/authors.json` | Author records, keyed by slug | | `categoriesFile` | `content/categories.json` | Category records, keyed by slug | | `defaultAuthor` | none | Applied when a post omits `author`. Must exist in the roster | | `onInvalid` | `'throw'` | `'warn'` skips a malformed post and logs it instead of failing the build | Frontmatter is validated as the site builds, and a post with a bad date or a missing description names the file and the field. A post whose `datePublished` is in the future, or whose `draft` flag is set, is excluded from the index, the sitemap, the feed, and the prerendered routes. It is still readable by slug while you are developing. ### Where the slug comes from [#where-the-slug-comes-from] The file name, with `.mdx` removed. Only `.mdx` files are read: a `.md` file in the directory is skipped with a warning in the build log, because every body is compiled as MDX and the Markdown that MDX does not share fails the compile for the whole site. A `slug` in frontmatter overrides the file name, silently. That is occasionally what you want, when a published URL has to outlive a file rename. Otherwise it is a way to end up at a URL that names no file anyone is editing. Keep them identical unless you have a reason not to. ### The two files posts point into [#the-two-files-posts-point-into] `author` and `category` are slug references, resolved from `content/authors.json` and `content/categories.json` during the same read. A slug with no record is a build failure naming the post, the field, and the file to add the record to. An inline object works too, for the one-off post whose author is not in the roster. This is the coupling that catches most new installs, because the example posts name the example records. See [the build fails after I edited content](/troubleshooting#the-build-fails-after-i-edited-content). ## The contract [#the-contract] Any source implements this interface: ```ts interface ContentSource<Strategy extends PrerenderStrategy = PrerenderStrategy> { readonly name: string readonly prerenderStrategy: Strategy getAllPosts(opts?: PostQuery): Promise<PublishedPost[]> getPost(slug: string, opts?: PostQuery): Promise<Post | null> getAllCategories(): Promise<Category[]> getAllAuthors(): Promise<Author[]> getPostsByCategory(slug: string): Promise<PublishedPost[]> getPostsByAuthor(slug: string): Promise<PublishedPost[]> getRelatedPosts(post: Post, limit: number): Promise<PublishedPost[]> } ``` Every domain type is inferred from the validation schema rather than written by hand, so the shape you validate and the shape you type cannot drift apart. ### `prerenderStrategy` prevents the worst failure in the system [#prerenderstrategy-prevents-the-worst-failure-in-the-system] | Value | Meaning | Consequence | | --------------- | --------------------------------------------------------- | ----------------------------------------------- | | `'build'` | Every post is known at build time | Nothing extra required. The MDX source is this | | `'deploy-hook'` | Publishing needs a rebuild before the post exists as HTML | `deployHook` becomes a compile-time requirement | | `'on-demand'` | New posts render on first request | Measure that render before you rely on it | The failure it prevents: you publish a post to a database, the webhook pings IndexNow, a crawler arrives within seconds, and the route was never prerendered because the build has not run since. What the crawler receives then depends on your dynamic route settings and on whether the first render finishes inside its timeout. Both are worse than a static file. With `'deploy-hook'`, your config does not type check until you supply a rebuild trigger, and the publish webhook fires that rebuild before it pings anything. ## Writing your own [#writing-your-own] Three things to get right, in order of how expensive they are to get wrong. **Hydrate in bulk, not per post.** `getAllPosts` is called by the sitemap, the feed, and the prerender step. An implementation that fetches each post's author with its own query turns a 400-post blog into 400 extra round trips per build, and build time grows with your content instead of staying flat. The contract suite watches the call count and fails when it scales with post count, because this is the mistake every database adapter makes first. **Return dates with an offset.** Not decorative: a date without one shifts by hours depending on who reads it. **Declare your strategy honestly.** If publishing does not rebuild the site, say `'deploy-hook'`. The compile error that follows is the point. ### Testing it [#testing-it] ```ts import { runSourceContractTests } from '@agentblog/schema/contract' runSourceContractTests(async () => myAdapter({/* ... */}), fixtures) ``` Eight assertions, run against a fixture you describe to the suite: | Assertion | The mistake it catches | | ------------------------------------------------------- | ------------------------------------------------------------ | | Every returned post satisfies the post schema | A shape that validates in your tests and not in the blog's | | Drafts are excluded by default and included on request | A draft in the sitemap, or a draft you cannot preview | | Author and category are hydrated in a single round trip | The query explosion that makes build time scale with content | | Every method is callable with no request context | An adapter that reads cookies and breaks static generation | | An unknown slug returns null rather than throwing | A 500 where a 404 belongs | | Related posts return editorial picks first, in order | Editorial intent silently reordered by a similarity score | | `prerenderStrategy` is declared | The staleness bug the field exists to make a compile error | | Ordering is stable across calls | A sitemap and an index page that disagree about post order | Passing the suite is the definition of a working source. `agentblog doctor` does not re-check any of it, because a bad source fails at build. ## What is coming [#what-is-coming] The MDX source ships today. Supabase and Convex follow, in that order. Because each one implements the same interface and passes the same suite, moving between them is a config line rather than a rewrite, which is the whole reason this layer exists. See [the roadmap](/project/roadmap). --- # What gets installed Source: https://docs.agentblog.dev/reference/files Summary: A file-by-file tour of the 71 files AgentBlog writes into your repository, and the rules each piece obeys. Everything below lands in your repository as source you own and can edit. The paths assume a flat layout. In a `src/` layout, shadcn resolves them against your `components.json` aliases. ## Routes [#routes] | File | What it does | | ------------------------------------- | ----------------------------------------------------------- | | `app/blog/layout.tsx` | Imports the preflight check, adds the editorial policy link | | `app/blog/page.tsx` | Paginated index with `Blog` structured data | | `app/blog/[slug]/page.tsx` | The post route | | `app/blog/[slug]/opengraph-image.tsx` | The social card for each post | | `app/blog/category/[slug]/page.tsx` | Category hub, always indexable | | `app/blog/tag/[slug]/page.tsx` | Tag listing, `noindex` below your configured post count | | `app/authors/[slug]/page.tsx` | Author page emitting `Person` structured data | | `app/editorial-policy/page.tsx` | Optional, ships as `@agentblog/eeat-pages` | Five rules are baked into these files, and each one closes a failure that has no visible symptom. **`generateStaticParams` returns every slug.** Never sliced, never paginated. Every post becomes complete static HTML at build time, which is the safest possible shape for a crawler that fetches once and runs no JavaScript. **No `'use client'` in the article render path.** Two components are allowed to be client components, the table of contents for its scroll tracking and the share buttons, and both render their full content on the server first. The FAQ and the table of contents use `<details>` and CSS rather than mounting on interaction, so their text is in the HTML whether or not anything hydrates. **One `<h1>` per page**, a `<time>` element on every date, a stable `id` on every H2 and H3, and `rel="author"` on the byline link. **Every route that sets `openGraph` or `robots` spreads the shared defaults** from `lib/metadata.ts`. See [the shallow merge trap](#the-shallow-merge-trap). **Pagination renders real links.** A paginator that only works after hydration hides everything past page one from a crawler. ## SEO routes [#seo-routes] | File | Notes | | -------------------------- | ------------------------------------------------------------------------------- | | `app/sitemap.ts` | `lastModified` comes from each post's `dateModified`, never from the build time | | `app/robots.ts` | Guarded on the deployment environment, and emits your `aiAccess` rules | | `app/feed.xml/route.ts` | RSS 2.0 | | `app/opengraph-image.tsx` | The site-level social card | | `app/not-found.tsx` | A 404 that links back into the content rather than being a dead end | | `app/api/publish/route.ts` | Revalidation and IndexNow, in that order | `sitemap.xml`, `robots.txt`, `feed.xml`, and the social images are all cached route handlers. That is why the publish webhook revalidates the sitemap and the feed explicitly rather than only the post paths: a new post that never reaches either is a failure with no symptom. ## Components [#components] `components/blog/` holds the reading experience: the answer capsule, the author bio, breadcrumbs, the byline, category pills, icons, the structured data serialiser, pagination, the post card, the post list, the prose wrapper, related posts, share buttons, and the table of contents. `components/mdx/` holds the component map plus the callout, code block, FAQ, figure, key takeaways, quote, stat, and table. See [components you can use in a post](/reference/mdx-components). `components/blog/icons.tsx` re-exports every icon used anywhere in the block, so swapping icon libraries costs one file rather than a search across twelve components. `components/blog/type-scale.ts` is the design system, and it is the file to open first if you want the blog to look like the rest of your product. It holds the heading, lede, section, eyebrow, and meta roles as class strings, plus the two gaps every page composes with. Editing one constant there restyles every route at once, which is the reason the routes import from it instead of spelling out their own headings. The layout widths it composes against live in `styles/agentblog.css` as `--agentblog-measure`, `--agentblog-aside`, `--agentblog-article`, and `--agentblog-rail`. One rule in that file is worth repeating here, because breaking it is how the spacing goes wrong: **no component sets its own outer margin.** Pages stack their children in a flex column with a `gap`, so a component that is missing from the layout is a component that is visibly missing, rather than one that silently renders flush against its neighbour. ## Library [#library] | File | Responsibility | | ----------------------------------- | ----------------------------------------------------------------------------- | | `lib/config.ts` | The resolved config and the URL helpers. The only file that reads your config | | `lib/posts.ts` | The content source facade. Every route reads posts through this file | | `lib/schema.ts` | The structured data builders, typed rather than hand-written JSON | | `lib/metadata.ts` | The shared metadata defaults every route spreads | | `lib/render-mdx.tsx` | The only place MDX is compiled | | `lib/mdx-plugins/` | Table of contents extraction and answer capsule handling | | `lib/toc.ts`, `lib/reading-time.ts` | Heading extraction, word count and minutes | | `lib/indexnow.ts` | Submission, with the response codes surfaced rather than swallowed | | `lib/ai-referrers.ts` | Classifies assistant referrals. See [measuring](/guides/measure-ai-traffic) | | `lib/sources/mdx.ts` | The MDX content source | Four files are generated and carry a banner saying so: `lib/schemas.ts`, `lib/types.ts`, `lib/define-config.ts`, and `lib/preflight-checks.ts`. They are generated rather than imported because they ship into your repository, where there is no package of ours to import from. Do not edit them: they are the files most likely to carry a real fix in an update, and they are always safe to overwrite. ## The shallow merge trap [#the-shallow-merge-trap] Worth stating on its own, because it is invisible to the type checker and it is the single most common way a correct-looking blog loses metadata. Next.js merges metadata across segments shallowly. If your root layout sets: ```ts openGraph: { siteName: 'Your Brand', locale: 'en_US', type: 'website' } ``` and a post page sets: ```ts openGraph: { type: 'article', title: post.title, description: post.description, url: canonical, images: [ogImage], } ``` then because the page defined `openGraph` at all, the layout's entire object is discarded. Every post ships a social card with no site name and no locale. Nothing errors, no validator complains, and the types are correct. The only way to notice is to view source on a built page. `lib/metadata.ts` exists so that no route file can make this mistake by accident, and `agentblog doctor` asserts it against built HTML rather than against the source. ## Styles [#styles] `styles/agentblog.css` carries two things: the bridge from the typography plugin to your theme tokens, and the `--agentblog-` prefixed variables for the reading measure and the prose scale. See [make it look like your site](/guides/match-your-design). Nothing imports this file for you. That is the one install step with no warning attached. ## Content [#content] `content/blog/*.mdx`, `content/authors.json`, and `content/categories.json`. Two example posts ship with the block, licensed CC0, because they become your published content and an attribution requirement would mean every AgentBlog user owed credit on their own blog. They are also the format specification. They are what the `write-blog-post` skill patterns from, so they follow every rule in [the GEO playbook](/concepts/geo-playbook), including the copy style rules. Read one before you delete them. --- # Components you can use in a post Source: https://docs.agentblog.dev/reference/mdx-components Summary: The six components a post can use beyond ordinary Markdown, their props, and when each one earns its place. A post is Markdown first. Headings, lists, links, tables, and code fences all work, and each one is already styled and given the right markup. These six components exist for the cases where plain Markdown loses information a retrieval system can use. They live in `components/mdx/` in your project, so all of them are yours to edit. ## Callout [#callout] An aside for something that costs money to get wrong. ```mdx <Callout variant="warning" title="Do not skip this"> The CSS import has no warning attached to it. </Callout> ``` | Prop | Type | Default | | ---------- | ---------------------------------------- | ---------------- | | `variant` | `note`, `tip`, `important`, or `warning` | `note` | | `title` | string | The variant name | | `children` | content | required | There is no amber or green variant, because there is no amber or green in a shadcn token set. Inventing one would make the blog stop looking like the product it is attached to. ## Stat [#stat] One number, presented as a number. ```mdx <Stat value="2.5x" label="more citations for pages that answer the question in the first paragraph" source="Ahrefs" href="https://ahrefs.com/blog/example" /> ``` | Prop | Type | Notes | | -------- | ------ | ---------------------------------------------------- | | `value` | string | The number as it should read, units included | | `label` | string | What the number measures. A full clause reads better | | `source` | string | Who reported it | | `href` | string | Where it was reported. Turns `source` into a link | Statistics with numbers are among the highest-effect elements measured for AI citation. They are also one of the two things a language model will happily invent, so every one needs a source you actually read. ## Quote [#quote] A quotation with attribution. ```mdx <Quote source="Gary Illyes" context="Google Search Central" cite="https://example.com/talk"> We do not support llms.txt and we are not planning to. </Quote> ``` | Prop | Type | Notes | | --------- | ------ | -------------------------------------------------------------- | | `source` | string | Who said it. This is the part that carries the measured effect | | `context` | string | Their role or publication, shown after the name | | `cite` | string | URL of the document being quoted. Links the attribution | A Markdown blockquote maps to this component too, without attribution, which is why every prop is optional. A quotation from a named source measures far better than the same sentence unattributed. ## KeyTakeaways [#keytakeaways] What the reader will know by the end. ```mdx <KeyTakeaways items={[ 'AI crawlers fetch your HTML once and never run JavaScript.', 'Anything rendered in the browser is invisible to them.', ]} /> ``` | Prop | Type | Default | | --------- | --------------- | --------------- | | `items` | list of strings | required | | `heading` | string | `Key takeaways` | One complete sentence per item, three to six items. This is not a replacement for the answer capsule: the capsule answers the post's question in prose, this lists what the reader leaves with. ## Figure [#figure] An image with a caption, sized and lazy-loaded correctly. ```mdx <Figure src="/blog/crawler-response.png" alt="A terminal showing an empty HTML shell returned to GPTBot" caption="The same page, fetched as GPTBot." /> ``` | Prop | Type | Notes | | --------- | ------- | ---------------------------------------------------------- | | `src` | string | required | | `alt` | string | required. Describe what the image shows, in plain language | | `caption` | content | Rendered in a `figcaption` | | `width` | number | Defaults to 1600 | | `height` | number | Defaults to 900 | | `preload` | boolean | At most one per page, and only for the largest image | Plain Markdown images work too and are routed through the same image handling. Use `Figure` when the image needs a caption. ## Table [#table] You almost never write this one directly. A Markdown table compiles to it automatically, complete with a scrollable container and correct header markup. ```md | Crawler | Runs JavaScript | | --------- | --------------- | | GPTBot | No | | Googlebot | Yes | ``` The `label` prop exists for a table rendered from your own TSX, to give the scrollable region an accessible name when a post has several tables and none of them carry a caption. Put comparison and specification data in a table rather than in prose. A table is structurally unambiguous about which value belongs to which row, which is exactly what a retrieval system needs and exactly what prose obscures. ## The FAQ section is not a component [#the-faq-section-is-not-a-component] It renders from the `faq:` block in your frontmatter, and only when there are entries, so the structured data can never describe questions the page does not show. See [post frontmatter](/reference/post-frontmatter#faq-entries). ## What is deliberately not a component [#what-is-deliberately-not-a-component] Paragraphs, lists, bold, and italic are left to the typography layer, which is bound to your theme tokens in `styles/agentblog.css`. Adding pass-through components for them would put long-form styling in two places that have to agree, and the second one always loses. If body copy looks wrong, fix the binding in that stylesheet rather than adding a component. See [make it look like your site](/guides/match-your-design#the-typography-plugin-is-bridged-rather-than-fought). --- # Post frontmatter Source: https://docs.agentblog.dev/reference/post-frontmatter Summary: Every field at the top of a post file, what it does, what it rejects, and the two files that author and category point into. A post is `content/blog/<slug>.mdx`. The block at the top of the file is frontmatter, and it is validated when the site builds, so a mistake is a build error with the field name in it rather than a missing value three files away. The file name is the slug. `do-ai-crawlers-run-javascript.mdx` is served at `/blog/do-ai-crawlers-run-javascript`. Rename the file to change the URL. ## Every field [#every-field] | Field | Type | Required | Notes | | --------------- | ------------------- | --------- | ------------------------------------------------------------------ | | `title` | string | yes | 70 characters maximum, 60 is the target | | `description` | string | yes | 50 to 160 characters. This is the meta description | | `answerCapsule` | string | no | The 40 to 60 word direct answer under the H1 | | `datePublished` | date and time | yes | ISO 8601 with a UTC offset | | `dateModified` | date and time | yes | ISO 8601 with a UTC offset, and not before `datePublished` | | `author` | author slug | yes | Must name a record in `content/authors.json` | | `category` | category slug | yes | Must name a record in `content/categories.json` | | `tags` | string list | no | Free text. Tag pages are `noindex` below a configurable post count | | `heroImage` | path or URL | no | Root-relative path or absolute http(s) URL | | `heroAlt` | string | with hero | Required whenever `heroImage` is set | | `relatedPosts` | slug list | no | Editorial ordering, shown before automatic suggestions | | `citations` | citation list | no | See below. Renders as a source list and feeds the structured data | | `faq` | question and answer | no | Renders visibly, and only then as FAQ structured data | | `draft` | boolean | no | Defaults to false. A draft is validated but not published | | `locale` | string | no | Defaults to the site locale | ## The rules behind the fields [#the-rules-behind-the-fields] ### Slugs cannot start with a date [#slugs-cannot-start-with-a-date] Lowercase, hyphen separated, no slashes, 120 characters maximum, and not beginning with a date. `2024-03-post-title` advertises the post's age in every search result and makes an evergreen refresh look stale, which is the opposite of what you want. ### Dates carry an offset, always [#dates-carry-an-offset-always] ```yaml datePublished: 2026-08-06T09:30:00Z dateModified: 2026-08-06T09:30:00-04:00 ``` The offset is not optional and the type refuses a timestamp without one. Google falls back to Googlebot's own timezone when a date has no offset, which shifts every published date by hours and can move a post across a day boundary. `dateModified` must not be earlier than `datePublished`, and it should move only when the content actually changed. See [why that matters](/guides/write-with-your-agent#refreshing-a-post-is-not-rewriting-it). ### `answerCapsule` is the field that does the most work [#answercapsule-is-the-field-that-does-the-most-work] 40 to 60 words, directly answering the title, with no links inside it. It renders under the H1 and it is the paragraph a retrieval system lifts when it quotes your page. Too short reads as a fragment, too long stops being liftable, and a link inside it fragments the chunk. `agentblog audit` reports the word count rather than failing the build, because a capsule eight words over is worth telling you about and not worth refusing. ### `author` and `category` are references [#author-and-category-are-references] Both must name a record that exists. A typo is a build failure, which is the right outcome: a post attributed to nobody carries no credibility signal, and a category page with no record has nothing to describe itself with. ### `heroImage` accepts two shapes and rejects a third [#heroimage-accepts-two-shapes-and-rejects-a-third] A root-relative path beginning with a single slash, or an absolute http(s) URL. A protocol-relative `//host/path` is rejected, because it reads as a path and loads from another origin, and this value ends up in your sitemap, your social card, and your structured data. `heroAlt` is required whenever `heroImage` is set. An unlabelled hero image is an accessibility failure and the schema will not let you ship one. ### Citations [#citations] ```yaml citations: - name: The rise of the AI crawler url: https://vercel.com/blog/the-rise-of-the-ai-crawler author: Vercel and MERJ datePublished: 2024-12-17 kind: industry ``` `name` and `url` are required. `kind` is one of `peer-reviewed`, `official-docs`, `industry`, `news`, or `other`, and defaults to `other`. It is used for auditing rather than emitted, so you can tell at a glance whether a post rests on primary sources or on vendor blog posts. ### FAQ entries [#faq-entries] ```yaml faq: - question: Do AI crawlers execute JavaScript? answer: >- No. Two or three sentences that make sense on their own. ``` These render visibly on the page, and the FAQ structured data is emitted only when they do. Marking up content a reader cannot see is the most enforced structured data policy there is, and FAQ markup is the most common way blogs trip it. Write answers that stand alone. An answer that refers to "the table above" is useless in the place it will be quoted. ## The two files a post points into [#the-two-files-a-post-points-into] ### `content/authors.json` [#contentauthorsjson] ```json [ { "slug": "editorial", "name": "Ada Lovelace", "bio": "Two sentences on why this person is credible on this subject.", "jobTitle": "Head of Engineering", "avatar": "https://yoursite.com/team/ada.jpg", "knowsAbout": ["Search", "Next.js"], "sameAs": ["https://www.linkedin.com/in/ada", "https://github.com/ada"] } ] ``` `slug`, `name`, and `bio` are required. `bio` is required because the author page is where a search engine or an assistant decides whether the person writing this knows the subject. `name` is the person's name and nothing else. Google's guidance is explicit that it must exclude job titles, honorifics, and the publisher name, which is why `jobTitle` is a separate field: the correct output is the only one you can construct. `sameAs` takes absolute profile URLs. It is what turns a name into an entity. ### `content/categories.json` [#contentcategoriesjson] ```json [ { "slug": "ai-search", "name": "AI search", "description": "How ChatGPT, Claude, and Perplexity find, read, and cite web pages." } ] ``` All three fields are required. The description is required because the category page is indexable, and a hub page with nothing but a list of links is a crawl liability rather than an asset. ## What is validated when [#what-is-validated-when] | When | What | | ----------------- | --------------------------------------------------------------------------- | | Build | Every field above, on every post, including drafts | | `agentblog audit` | Word counts, link counts, copy style, citations, and the pre-publish gate | | Never | Whether the statistic is true, or the quotation real. That part stays yours | --- # When your CDN blocks crawlers Source: https://docs.agentblog.dev/troubleshooting/cdn-blocking-crawlers Summary: A perfectly installed blog can be invisible because the layer above it turns crawlers away, and blocking AI training can block Googlebot with it. This is the one failure AgentBlog can neither cause nor fix in code, and it lands squarely on the people most likely to install it. ## The symptom [#the-symptom] Your install is correct. `agentblog doctor` passes. No warnings during the build. `curl` from your own machine returns the full article. Search Console looks fine. And you get no citations, ever. ## What is happening [#what-is-happening] Since September 2026, Cloudflare blocks Training and Agent crawlers by default on ad-displaying pages for new domains, new sites on existing accounts, and any free-tier account that has not changed the setting. Search crawlers stay allowed. That alone would be a problem for AI visibility. The part that makes it an SEO emergency is in Cloudflare's own announcement: > Multi-purpose crawlers such as Googlebot, Applebot, and BingBot will be > blocked by customers who have selected to block Training. So a developer who registers a new domain, puts it behind Cloudflare's free tier, and installs a blog gets a perfectly prerendered, perfectly marked-up site that a whole category of crawlers cannot reach. And if they later tick "block AI training" because it sounds prudent, they lose Googlebot with it. This is the single most common cause of "my robots.txt is open and I get no citations". A `robots.txt` allowlist is a lock on a door that has been bricked over. ## Diagnosing it [#diagnosing-it] The check has to run from outside your deployment's network, because a request originating inside it often bypasses the edge rules entirely. ```bash npx agentblog@latest doctor --url https://yoursite.com/blog/your-post ``` It fetches the URL as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Googlebot, and asserts a 200 with body text present. A 403, a challenge page, or an interstitial is reported as a blocking failure, and when the response headers name a CDN it prints the specific setting rather than a generic "you appear to be blocked". By hand, from a machine that is not your deploy environment: ```bash for ua in GPTBot ClaudeBot PerplexityBot OAI-SearchBot Googlebot; do printf '%-18s %s\n' "$ua" \ "$(curl -s -o /dev/null -w '%{http_code}' -A "$ua" https://yoursite.com/blog/your-post)" done ``` Five 200s is what you want. Anything else is your answer. Note what a challenge page looks like from a script: status 200, an HTML body, no article text. Check for a distinctive sentence rather than only the status code. ## Fixing it on Cloudflare [#fixing-it-on-cloudflare] Zone settings, then the AI crawler controls. Allow the crawlers you want to be cited by. The Training and Search categories are separate in the dashboard and not cleanly separated on the crawler side: several bots serve both purposes, so blocking Training removes them from Search too. If your dashboard offers a per-bot list rather than categories, allow at least: `GPTBot`, `OAI-SearchBot`, `ChatGPT-User`, `ClaudeBot`, `Claude-SearchBot`, `Claude-User`, `PerplexityBot`, `Perplexity-User`, `Googlebot`, `Bingbot`, `Applebot`. `CCBot` and `Bytespider` are training-only. Blocking those two is a defensible choice with no search cost. ## Other layers with the same shape [#other-layers-with-the-same-shape] Cloudflare is the common case rather than the only one. * **Firewall or WAF rules** that rate-limit by user agent. Crawlers arrive in bursts and look like bursts. * **A bot management product** in front of the origin, added by someone else on the team. * **A `robots.txt` you did not write.** Check what is actually served, not what is in your repository. A CDN can synthesize one. * **Geographic blocking.** Crawler traffic originates from a small number of ranges, and some of them sit in regions people block by default. * **Challenges for unusual user agents.** A 200 with a JavaScript challenge in the body is indistinguishable from success in a status-code check. ## Verifying that a crawler hit is real [#verifying-that-a-crawler-hit-is-real] If you are counting crawler traffic in your logs, verify by IP range rather than by user agent. Vendor user agent strings are trivially spoofed, and AgentBlog's own audit skill spoofs GPTBot deliberately. | Operator | Published ranges | | ---------- | --------------------------------------------------------------------------------- | | OpenAI | `openai.com/gptbot.json`, `/searchbot.json`, `/chatgpt-user.json`, `/adsbot.json` | | Anthropic | `claude.com/crawling/bots.json` | | Perplexity | `perplexity.com/perplexitybot.json`, `/perplexity-user.json` | ```bash npx agentblog@latest audit --crawlers /path/to/access.log ``` That parses the log, verifies each hit against those endpoints, and reports hits per bot per week. ## Two crawler facts worth knowing [#two-crawler-facts-worth-knowing] `OAI-AdsBot` validates ad landing pages and does not respect `robots.txt`. It is frequently missing from crawler tables. Anthropic honours the non-standard `Crawl-delay` directive, and the `robots.ts` that ships with the blog emits it for Anthropic's bots specifically. --- # Troubleshooting Source: https://docs.agentblog.dev/troubleshooting Summary: The failures that actually happen, what each one looks like from the outside, and the fix for each. Almost every failure here is silent. That is the shape of the problem rather than an accident of implementation: metadata in the wrong element still renders, a narrowed pattern still matches, a stale cache still returns 200. So the symptom is usually "nothing is wrong and nothing is working". Start here. If the answer turns out to be "a CDN is blocking the crawler", that has [its own page](/troubleshooting/cdn-blocking-crawlers), because it is the most common cause and the least visible one. ## The build fails after I edited content [#the-build-fails-after-i-edited-content] This is the first thing most installs hit, and `doctor` sends you into it: it warns that the example author roster is unedited, you edit it, `doctor` goes green, and the next build fails. `doctor` reads your config, not your posts, so the example posts are outside what it checks. ```text [agentblog] content/blog/how-ai-search-engines-read-your-blog.mdx is invalid: author: unknown author slug "editorial". Add a record with that slug to content/authors.json, or inline the author object in this post's frontmatter. ``` Both example posts carry `author: editorial`, and `editorial` is the slug of the first record in `content/authors.json`. Replacing the roster with your own record under a different slug deletes a slug two files still name. The category version of this error is identical in shape. Three ways out, in the order they are usually right: 1. **Keep the `editorial` slug and replace everything else in the record.** The slug is an internal key. It appears in one URL, `/authors/editorial`, and nowhere else. Edit the name, bio, job title, and profile links. 2. **Change the slug and the posts in the same commit.** Edit the `author:` line in every file under `content/blog`, and `defaultAuthor` in `agentblog.config.ts`, alongside the roster. 3. **Delete the example posts.** They are the format specification, so read one first, but nothing depends on them existing. The same trap has a quieter version. `defaultAuthor` in `agentblog.config.ts` ships as a placeholder that matches no record. It only bites the first post you write without an `author` in its frontmatter, which may be weeks later. Set it to a real slug while you are in the file. ## The article text is not in the raw HTML [#the-article-text-is-not-in-the-raw-html] ```bash curl -s -A "GPTBot" https://yoursite.com/blog/your-post | grep "a distinctive sentence" ``` No match. Causes, in the order they occur: **Something in the render path is a client component.** Look for `'use client'` anywhere the article body is rendered. Only the table of contents and the share buttons are allowed to be client components, and both render meaningful content on the server first. **Content is mounted on interaction.** An accordion or a tab set that renders its children only after a click. Use `<details>` instead: it is in the HTML whether or not it is open. **The route was not prerendered.** Check that `generateStaticParams` returns every slug and is not sliced. `agentblog doctor` checks this by reading the syntax tree rather than searching the text. **You are looking at the element inspector.** It shows the page after JavaScript has run. Use view source or `curl`. ## The title is in the body instead of the head [#the-title-is-in-the-body-instead-of-the-head] ```bash curl -s -A "GPTBot" "$URL" | head -c 4000 | grep -q "<title>" ``` Fails. The page rendered dynamically and Next.js streamed the metadata, appending it to `<body>` instead of `<head>`. Two fixes, and you want both. Prerender the post, so the metadata is in the initial HTML for everyone. Then set `htmlLimitedBots`, so the AI crawlers get a blocking render regardless: ```bash npx agentblog@latest doctor --fix ``` Read the value it writes carefully. It repeats the entire Next.js default list after the AI crawlers, and that is not redundancy: setting this config **replaces** the default rather than extending it. Dropping the tail removes Googlebot, Bingbot, Applebot, Twitterbot, LinkedInBot, Slackbot, Discordbot, and Facebook's crawler from the same treatment, which trades an AI win for a live SEO and social preview regression. ## Article prose has no typography at all [#article-prose-has-no-typography-at-all] Not slightly off. One font size, no visible heading hierarchy, no list markers, while the rest of the site looks right. Cards and layout are fine, because those are utilities in the components. Only the article body is affected. You did not import the stylesheet. ```css title="app/globals.css" @import 'tailwindcss'; @import '../styles/agentblog.css'; ``` From `src/app/globals.css` the path is `../../styles/agentblog.css`, because the file lands at the project root rather than next to `app/`. Nothing catches this. It is not a `doctor` finding, the preflight check does not look at your stylesheet, the build succeeds, and every crawler check passes, because the HTML and the structured data are correct and only the presentation is missing. ## A new post is not in the sitemap or the feed [#a-new-post-is-not-in-the-sitemap-or-the-feed] `sitemap.xml`, `robots.txt`, and the social image routes are cached route handlers. Publishing a post and revalidating only its own path leaves all of them stale. The publish webhook that ships with the blog revalidates the sitemap and the feed explicitly. If you wrote your own publish path, add them. `agentblog doctor` asserts it. ## A published post 404s or renders a fallback [#a-published-post-404s-or-renders-a-fallback] Your content source publishes without a rebuild, and the prerender step has not run since the last deploy, so the route was never built. Check `prerenderStrategy` on your source. If publishing needs a rebuild it should be `'deploy-hook'`, and your config will then refuse to compile without a `deployHook`. The webhook fires the rebuild before it pings IndexNow, which is the correct order. Pinging first tells a crawler to fetch a URL that does not exist yet. ## The crawler gets stale HTML right after publishing [#the-crawler-gets-stale-html-right-after-publishing] `revalidateTag` takes a required second argument in Next.js 16, and `'max'` on the publish path means you invalidated nothing useful. Use `{ expire: 0 }`. Also worth knowing: `updateTag(tag)` takes a single argument and works only in Server Actions. It throws in a route handler. ## Social cards have no site name [#social-cards-have-no-site-name] View source on a built post and look for `og:site_name`. Missing means a segment defined its own social metadata without spreading the shared defaults. Next.js merges metadata shallowly, so defining the object at all in a child segment discards the parent's entire object. Nothing errors, no validator complains, and the types are correct. Spread the defaults from `lib/metadata.ts` in every segment that sets them. The same applies to the robots directives, where the loss costs you `max-snippet` and `max-image-preview`, which is what permits full quotations in AI answers. ## The blog does not match the rest of the site [#the-blog-does-not-match-the-rest-of-the-site] Most likely a dark-mode colour variant or a palette utility crept into a component you edited. The tokens already flip under `.dark`, so a dark-mode colour override re-hardcodes the thing the token abstracts. ```bash npx agentblog@latest doctor ``` It reports palette utilities, colour literals, and dark-mode colour variants in the installed components. A warning rather than an error, because you may have done it deliberately. If the prose specifically looks wrong, check `styles/agentblog.css` for `hsl(var(--foreground))`. Tailwind v4 stores complete `oklch()` values, so the correct form is `var(--foreground)` with no wrapper. The old form fails silently by producing an invalid colour that inherits. ## Turbopack warns about dynamic filesystem access [#turbopack-warns-about-dynamic-filesystem-access] Every build prints six of these, three from `lib/preflight.ts` and three from `lib/sources/mdx.ts`. ```text Warning: Dynamic filesystem access causes tracing of the whole project ``` **These come from AgentBlog rather than from your code, and the build succeeds.** Both files read the filesystem on purpose. The preflight check reads your `next.config` off disk to lint it, which is the entire reason it exists, and the path is your project root rather than a subfolder Turbopack can scope. The MDX source resolves the content directory from your config, so the path cannot be a literal it can fold. The consequence is real but it is a size and deploy-time cost rather than a correctness one: more files get copied next to your server bundle. On a blog that is usually a slower deploy and a larger function. It matters if you are near a platform size limit. If it is a real problem, trim the trace in your own `next.config.ts` rather than editing the installed files: ```ts outputFileTracingExcludes: { '/**': ['./public/**/*'], } ``` Exclude only what you are certain no route reads. Everything `agentblog init` put in `outputFileTracingIncludes` has to stay: the MDX source really does read `content/blog/**`, `content/authors.json`, and `content/categories.json` at runtime, on `/blog` and whenever the publish webhook regenerates the sitemap and the feed. Dropping one of them is the section below. ## `/blog` returns a 500 and the post pages do not [#blog-returns-a-500-and-the-post-pages-do-not] The symptom is specific. Every post, category, and author page serves normally, `/blog` returns a server error, and the deployment logs carry: ```text Error [AgentBlogSourceError]: [agentblog:mdx] getAllPosts failed [cause]: Error: ENOENT: no such file or directory, open '/var/task/content/authors.json' ``` The post pages are prerendered at build time, when the whole repository is on disk. `/blog` reads `searchParams` for its `?page=N`, so it renders on demand, inside a function that only carries the files the build traced. The file named in the error was not one of them. Fix it by declaring every path your content source reads, not just the posts directory: ```ts title="next.config.ts" outputFileTracingIncludes: { '/**': [ './content/blog/**/*', './content/authors.json', './content/categories.json', ], } ``` `npx agentblog doctor --fix` writes that for you, reading the paths out of your `agentblog.config.ts`. Two things about it are easy to get wrong by hand. The roster files sit beside the posts directory rather than inside it, so `./content/blog/**/*` alone ships the posts and leaves the authors behind. And the key is `/**` rather than `/blog/**`, because `sitemap.xml`, `feed.xml`, `/authors/[slug]`, and `/api/publish` read content too. ## The image optimizer returns 400 [#the-image-optimizer-returns-400] Next.js 16 rejects any image quality not listed in your config, and the default list contains only 75. The blog uses 75 for cards and 90 for hero images. ```ts images: { qualities: [75, 90] } ``` `agentblog doctor --fix` writes this. ## The build fails on a social image [#the-build-fails-on-a-social-image] The site social image has an 8MB limit and the Twitter variant has 5MB. Exceeding either fails the build rather than warning. Read fonts and logos once at module scope rather than per request, or every generated image pays for the read. ## The preflight warning will not go away [#the-preflight-warning-will-not-go-away] It is telling you something. Read the line: it names the missing setting. If your config is genuinely correct and the check is wrong, set `preflight: false` in `agentblog.config.ts`. That is the supported way to silence it. Deleting the import from `app/blog/layout.tsx` is not, because then nothing tells you when a later edit breaks the config again. If you think the check is wrong, please open an issue: a false positive here is a bug that gets the import deleted in a lot of repositories. ## IndexNow returns 403 or 422 [#indexnow-returns-403-or-422] * **403:** the key is invalid or the key file is missing. `public/<key>.txt` has to be served from your domain root, UTF-8, containing the key and nothing else. A key hosted at a subpath only authorises URLs under that subpath. * **422:** the submitted URL does not match the host that owns the key. Both look identical to success from a caller that only checks whether the request completed, which is why `agentblog ping` prints the code with its meaning attached. ## agentblog init refuses to run [#agentblog-init-refuses-to-run] | Message | Meaning | | --------------------------- | -------------------------------------------------------------------------------------------------- | | `components.json` not found | Run `npx shadcn@latest init` first. AgentBlog will not, because that command picks a design system | | Tailwind v3 detected | Not supported. See [the roadmap](/project/roadmap) | | `app/blog` already exists | You have a blog. `--force` overwrites it, and the conflicting paths are printed | | Next.js 15 detected | The product is built on APIs specific to Next.js 16 | ## Undoing an install [#undoing-an-install] ```bash npx agentblog@latest revert # restore the last patch set npx agentblog@latest revert --all # replay every backup, back to the start npx agentblog@latest uninstall # reverse every patch, remove the AGENTS.md block npx agentblog@latest uninstall --keep-env # the same, but leave the two secrets in place ``` `revert` on its own restores only the most recent backup, so a second `doctor --fix` can be undone without also unwinding your `init`. `uninstall` lists the files the registry wrote rather than deleting them, because by then some of them are yours.