# SuperDuperSEO docs SuperDuperSEO writes and publishes SEO pages for your site, and is the headless CMS your site reads them from. Agents connect through MCP; your site's code reads the Content API (or the SDK). - **[Put your blog on SuperDuperSEO](https://superduperseo.com/docs/quickstart)**: with an AI agent, or by hand. - **[Write and publish with an agent](https://superduperseo.com/docs/writing)**: drafts, publishing, history, insight. - **[MCP server](https://superduperseo.com/docs/mcp)**: connect, sign in, every tool. - **[Content API](https://superduperseo.com/docs/content-api)**: files, fields, rules for serving pages. - **[SDK](https://superduperseo.com/docs/sdk)**: `@superduperseo/content`. - **[Webhooks](https://superduperseo.com/docs/webhooks)**: events and signatures. - **[Site check](https://superduperseo.com/docs/site-check)**: what "Verified" checks, and every fix. - **Starters:** [Next.js](https://superduperseo.com/docs/starter-nextjs), [Cloudflare](https://superduperseo.com/docs/starter-cloudflare). - **[REST API (v1)](https://app.superduperseo.com/docs/agent-spec)**: the older API for stats, keywords and generation. For agents: https://superduperseo.com/docs/llms.txt (index) and https://superduperseo.com/docs/llms-full.txt (everything in one file). Every page is also markdown: add `.md` to its URL. --- # Put your blog on SuperDuperSEO SuperDuperSEO writes and publishes SEO pages, and serves them to your site as its CMS. Your site keeps its design and domain and renders the pages on its own server. Every page arrives with the SEO done: title, description, canonical, structured data, sitemap, feed, `llms.txt`, redirects and IndexNow. A new site is on SuperDuperSEO from the moment you add it. Its pages wait there until your site shows the first one; daily writing starts then. In the app this is the **Connect your site** step, with three ways to do it. ## With an AI agent (recommended) 1. Add the MCP server to your agent (Claude Code shown; any MCP client works): ```bash claude mcp add --transport http --scope user superduperseo https://app.superduperseo.com/mcp ``` Then `/mcp`, choose superduperseo, and sign in once. 2. Open your site's repository and say: > Put the blog of example.com on SuperDuperSEO. Call get_site_setup for example.com and follow it: keep every URL the site has today, render posts on the server, deploy and run check_site. Then call get_setup and fill in anything still missing (pages a day, author, topics), asking me where you need to. 3. The agent works through the setup: - it reads `get_site_setup`, creates a site key and stores it in your server environment; - it changes your site to render posts from SuperDuperSEO (the [SDK](https://superduperseo.com/docs/sdk) or a [starter](https://superduperseo.com/docs/starter-nextjs)); - it deploys, adds the webhook, and runs `check_site` until your first page passes. 4. You add one DNS record so Search Console can verify the site (the agent tells you which). Moving from another CMS? The agent shows you the switch before it happens, and your old CMS stays switched off for 30 days in case you want to go back. Every existing URL stays the same. ## Send it to your developer In **Connect your site**, choose **My developer** and send the link. It has the steps, a prompt for their coding agent, the site key (shown once) and a check of the live site. They need no account; the link works for 7 days. ## By hand 1. In **Connect your site**, choose **I'll do it myself** and create the site key (it is shown once). 2. Build the blog with the [SDK](https://superduperseo.com/docs/sdk) following the [Content API rules](https://superduperseo.com/docs/content-api), or copy a starter: [Next.js](https://superduperseo.com/docs/starter-nextjs), [Cloudflare](https://superduperseo.com/docs/starter-cloudflare). 3. Deploy, then **Check my site**. Add the [webhook](https://superduperseo.com/docs/webhooks) on the site's Content API page so new pages appear in seconds. --- # Write and publish with an agent Once a site is on SuperDuperSEO, an agent can manage its content with the [MCP tools](https://superduperseo.com/docs/mcp). ## Drafts `create_draft` adds a draft in one of two ways: - **A page you wrote:** give `title` and `content` (markdown by default). It is free. - **A page we write:** give only a `keyword`. SuperDuperSEO writes it with its own research, the site's voice and internal links, for the site's page price from the balance. The first call shows the price; `confirm: true` writes it. Every draft gets a quality score (0-100) and **advice**, such as "Link to 2 or 3 more related pages on this site". `get_post` returns the body as stored, ready for `update_post`. ## Publish - `publish_post` publishes now. `schedule_post` publishes at a time (15 minutes to 30 days ahead), or in the site's queue. - Only hard problems block a publish: thin content, or invalid structured data. The score is advice. - `unpublish_post` takes a page down. Its URL then answers 410, or redirects (301) with `redirect_to`. Pages with search traffic stay live. - `add_redirect` and `remove_redirect` manage manual redirects. ## History Every change to a published page is a version: the last 20 are kept, with who made the change. - `get_post_history` lists them. - `get_post { version }` reads one. - `rollback_post` restores one as a new version. The URL never changes. ## Insight - `get_seo_report`: clicks, impressions, top pages, pages nearly on page one, pages losing clicks, and the live-site check. - `suggest_topics`: what to write next. - `get_balance`: the balance, what a page costs on each site, and whether each site writes today. ## Pace - `set_pace`: pages a day for a site (raising the spend shows the cost first). - `pause_site` and `resume_site`: stop and start a site's daily writing. --- # MCP server SuperDuperSEO is a remote MCP server (Streamable HTTP): **`https://app.superduperseo.com/mcp`**. An agent with it can connect a site, write and publish pages, and see how the site does in search. ## Connect - **Claude Code:** `claude mcp add --transport http --scope user superduperseo https://app.superduperseo.com/mcp`, then `/mcp`, choose superduperseo, Authenticate. - **Claude, ChatGPT, Cursor and other clients:** add a custom connector or MCP server with the URL `https://app.superduperseo.com/mcp` and sign in when asked. - **Agents that run on their own:** send an account API key (https://app.superduperseo.com/settings/api) as `Authorization: Bearer sf_...`. Sign-in is OAuth 2.1, and your client handles it. A call without credentials answers 401 with `WWW-Authenticate: Bearer resource_metadata="https://app.superduperseo.com/.well-known/oauth-protected-resource/mcp"`. Clients register themselves (RFC 7591), use PKCE (S256), and the user approves once on a consent screen. Connected apps can be removed in Settings, API. ## How to use it - If the user skipped setup in the app, `get_setup` says what's missing (pages a day, author, topics, connection, balance) and which tool fills it. Until it's done, every answer ends with the same note. - Start with `get_site_setup` for a site. Run `check_site` after every deploy and fix what it lists. - Errors say what to do next (`Next: ...`). - Tools that cost money, or change or remove something, show a preview first and act on `confirm: true`. Show the user the preview. - Pages with search traffic cannot be removed (unpublish_post refuses them). - At most 120 calls a minute per account. Resources: `sds://docs/setup` (the setup guide) and `sds://{site}/posts/{slug}` (each published post as markdown). Prompt: `set_up_blog` (site, framework). ## Tools ## Set up a site ### `list_sites`: List sites The sites in this SuperDuperSEO account: how each is served, whether it shows its pages yet (connection: waiting = the site has not read from us, reading = it has but no page is live, live, verified), its SEO check, page counts and any setup still missing (get_setup). Read only. No parameters. ### `create_site`: Add a site Add a website to the account. SuperDuperSEO researches it in the background (topics, keywords, strategy). It gets 3 pages a day unless pages_per_day says otherwise; daily writing (and spend) starts once the site shows its first page and the account has balance. set_pace changes it later. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `hostname` | string | yes | The site, e.g. example.com | | `language` | string | | Content language, default en | | `country` | string | | Target country, default US | | `pages_per_day` | integer | | Pages a day, default 3; 0 for none | ### `get_site_setup`: How to connect a site Everything needed to make a site read its pages, SEO tags and sitemap from SuperDuperSEO: the content API, the key variable, the starter for the framework, the route map, webhook and the current state. Start here. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `framework` | "nextjs" \\| "cloudflare" \\| "other" | | nextjs (App Router), cloudflare (Worker or Pages, plain HTML/Vite sites), other. Default: from what the site is built with, when we detected it | ### `create_site_key`: Create a site key A secret key the site's server code uses to read its content (shown once; store it as SUPERDUPERSEO_CONTENT_KEY in the server environment, never in code). With rotate, the old key keeps working 7 days. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `rotate` | boolean | | Replace the current key (it keeps working 7 days) | | `confirm` | boolean | | Required with rotate | ### `revoke_site_key`: Revoke a site key Stop a site key at once (the site stops getting content with it). Needs confirm:true. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `key_prefix` | string | yes | The key prefix, e.g. sds_live_ab12 | | `confirm` | boolean | | | ### `add_webhook`: Add a webhook We POST to this URL (signed) when a page is published, updated or removed, so the site can refresh it. Returns the signing secret once: store it as SUPERDUPERSEO_WEBHOOK_SECRET. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `url` | string | yes | https URL on the site, e.g. https://example.com/api/revalidate | ### `test_webhook`: Test a webhook Send a signed test call to a webhook and report whether the site answered 2xx. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `webhook_id` | string | yes | | ### `check_site`: Check the live site Fetch the live site like a search engine (no JavaScript) and compare it with what SuperDuperSEO published: canonical, title, description, structured data, one h1, server rendering, robots.txt, sitemap, 404s, Search Console. Returns verified or each issue with its fix. Run after every deploy. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `get_site_status`: Site status Whether a site shows its pages yet (connection), the last SEO check result, Search Console state and CMS, without running a new check. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `update_site`: Change site settings The site's organisation details for search engines: its name (WebSite and Organization name, og:site_name, the feed and llms.txt), a short description, its logo, and its profiles elsewhere (X, LinkedIn, Facebook; used as sameAs). Use the name the site calls itself on its homepage. "" clears a field. Pages served by SuperDuperSEO are rebuilt at once; pages on another CMS pick it up on their next update. undo_activity puts the old details back. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `name` | string | | The brand name, e.g. Kickmyass (not a page title) | | `description` | string | | One or two sentences about the site | | `logo_url` | string | | https:// address of a square logo, at least 512px | | `twitter_url` | string | | | | `linkedin_url` | string | | | | `facebook_url` | string | | | ### `set_routes`: Change where pages live Change the paths a site serves its posts, author pages and topic pages at (e.g. /blog/{slug} to /articles/{slug}). Every page that moves keeps a 301 from its old URL. Only for sites served by SuperDuperSEO. The first call shows what moves and the search traffic at stake; confirm:true applies it. Deploy the frontend for the new paths first: the SDK follows site.routes, hand-written routes need a change. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `post_path` | string | | e.g. /blog/{slug}, /articles/{slug}, /{slug} | | `author_path` | string | | e.g. /blog/author/{slug} | | `topic_path` | string | | e.g. /blog/topic/{slug} | | `confirm` | boolean | | | ### `connect_search_console`: Connect Search Console Start Search Console verification for a site: returns the DNS TXT record to add at the domain's DNS provider. Then check_search_console. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `reissue` | boolean | | Ask Google for a new token | ### `check_search_console`: Check Search Console verification Ask Google whether the DNS TXT record is in place; on success the site is added to Search Console and its sitemap submitted. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `switch_site_to_cms`: Serve the site from SuperDuperSEO Move a site onto SuperDuperSEO as its CMS (its old CMS is kept, switched off, for rollback). Dry run first by default; the real switch needs confirm:true and a verified check_site. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `dry_run` | boolean | | | | `confirm` | boolean | | | ### `delete_site`: Delete a site Delete a site and everything it owns: its pages stop being served at once, and its plan, voice, rules, keys, redirects and history are deleted. The balance stays. Cannot be undone. Without confirm it shows what goes; the user must say to delete it, then call with confirm:true and hostname set to the site's address exactly. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `confirm` | boolean | | | | `hostname` | string | | The site's address, typed as confirmation | ## Finish setup ### `get_setup`: What setup is missing For each site (or one): whether its setup is done, and for anything missing (pages a day, author, topics, connection, balance) the tool that fills it. SuperDuperSEO writes nothing for a site until all five are done. Start here when the user skipped setup in the app. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | | The site: its hostname (example.com) or id | ### `set_author`: Set who signs the pages Make a person the main author of a site: their name signs new pages (byline and Person structured data). Creates the author, or links one the account already has (author_id). Ask the user who it should be; usually them. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `name` | string | | Full name, e.g. Simon Sovič. Not needed with author_id | | `bio` | string | | One or two sentences: who they are and why they know the topic | | `job_title` | string | | | | `url` | string | | Their personal site or profile | | `linkedin_url` | string | | | | `twitter_url` | string | | | | `image_url` | string | | | | `author_id` | string | | An author the account already has (from another site) | ### `set_voice`: Set how the site sounds How a site's pages sound, used from the next page written: tone (plain, friendly, expert), depth (short, standard, in depth), who reads it, and the owner's own words about the voice (up to 500 characters, e.g. "Write like a practice manager who has tried every tool. Short sentences, real prices, no hype."). Send only what changes; ask the user first. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `tone` | "plain" \\| "friendly" \\| "expert" | | | | `depth` | "short" \\| "standard" \\| "in depth" | | | | `audience` | string | | Who reads it, e.g. "owners and office managers of small dental clinics" | | `words` | string | | How it should sound, in the owner's words | ### `add_topics`: Add topics to write about Add topics (search keywords, e.g. "how to price a saas product") to a site's queue; daily writing picks from it, best first. Topics already queued or written are skipped. suggest_topics gives ideas; agree on them with the user first. Free. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `topics` | string[] | yes | | | `first` | boolean | | Write these before the rest of the queue | ### `remove_topic`: Remove a queued topic Take a topic out of a site's queue so it is never written. Pages already written stay (unpublish_post removes those). Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `topic` | string | yes | | ### `pin_topic`: Write a topic next Move a queued topic to the front of a site's queue so daily writing takes it next (pinned:false puts it back in its place). add_topics with first:true adds new topics at the front. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `topic` | string | yes | | | `pinned` | boolean | | Default true | ## How pages are written ### `get_plan`: How a site's pages are written A site's voice (tone, depth, who reads it, the owner's words), its rules (always / never, with ids for update_rule), the owner's notes the writer works into pages (with ids for update_note), and its competitors. suggest_topics shows the topics queued. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `add_rule`: Add a writing rule Something the writer must always or never do on a site, used for every page written and every refresh: either a rule in plain words (rule: "Never name a competitor in a page title") or a word swap (say: "clinic", instead_of: "practice"). fix_existing_pages:true also changes the swap on the site's pages that already exist, live ones included (undo_activity puts them back). Ask the user first. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `rule` | string | | | | `say` | string | | The words to use | | `instead_of` | string | | The words to replace | | `fix_existing_pages` | boolean | | | ### `update_rule`: Turn a rule on, off, or remove it on:false stops the writer following a rule from the next page (on:true brings it back); remove:true deletes it. Pages already written keep their words. Rule ids are in get_plan. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `rule_id` | string | yes | | | `on` | boolean | | | | `remove` | boolean | | | ### `add_note`: Add the owner's experience A story, number or opinion from the owner, in their words (up to 2000 characters, e.g. "Most no-shows were people who booked more than three weeks out."). The writer works it into the site's pages where the topic fits, and never invents details beyond it. draft (default true) also writes a draft page from it, which waits in the site's pages and costs nothing unless published. Only add what the user tells you. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `text` | string | yes | | | `draft` | boolean | | | ### `update_note`: Change or remove a note Change a note's words (text), give a note without a site to this one (assign:true), or stop using it (remove:true; pages that use it keep their words). Note ids are in get_plan. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `note_id` | string | yes | | | `text` | string | | | | `assign` | boolean | | | | `remove` | boolean | | | ### `set_competitors`: Set who a site competes with The sites a site competes with (up to 3 addresses, e.g. ["weave.com"]); replaces the list, [] clears it. We read their sitemap and homepage for what they write about. comparison_topics:true also queues comparison topics against each ("acme vs weave", "acme vs weave pricing"); free until a page goes live. Ask the user first. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `competitors` | string[] | yes | | | `comparison_topics` | boolean | | | ## Write and publish ### `list_posts`: List posts A site's pages: published, drafts (with any scheduled time), failed, or all; newest first. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `status` | "published" \\| "draft" \\| "failed" \\| "all" | | | | `limit` | integer | | | | `offset` | integer | | | ### `get_post`: Get a post One page of a site by slug. With version, an earlier version (see get_post_history). By default the body as stored, ready to edit with update_post (the byline, CTAs and footer are added when it is published); as_published:true returns the page exactly as served, with its SEO fields. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | | `format` | "markdown" \\| "html" | | Format of content (default markdown) | | `as_published` | boolean | | | | `version` | integer | | Read an earlier version (get_post_history lists them) | ### `create_draft`: Create a draft Add a draft page to a site. Either give the page yourself (title + content, markdown by default; free), or give only a keyword and SuperDuperSEO writes it with its own research, the site's voice and internal links (costs the site's page price from the balance; the first call shows the price, confirm:true writes it). Drafts are never published by this tool. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `title` | string | | Title (H1 and SEO title) of a page you wrote | | `content` | string | | The body of a page you wrote (no title heading needed) | | `format` | "markdown" \\| "html" | | Format of content (default markdown) | | `keyword` | string | | The search query the page targets. Alone: we write the page | | `slug` | string | | URL slug (derived from the title when left out) | | `description` | string | | Meta description (derived from the body when left out) | | `template` | "ARTICLE" \\| "LISTICLE" \\| "HOWTO" \\| "COMPARISON" \\| "FAQ" \\| "CASE_STUDY" \\| "COST" \\| "CHECKLIST" \\| "ROUNDUP" \\| "REVIEW" \\| "ALTERNATIVE" \\| "STATISTICS" \\| "GLOSSARY" \\| "LOCATION" \\| "SERVICE_PAGE" \\| "SAAS_PAGE" \\| "PRODUCT_PAGE" | | | | `confirm` | boolean | | Confirms the page price when we write the page | ### `update_post`: Edit a post Change a page's title, meta description, body, slug, author or CTAs. A published page is updated on the live site at once. Changing the URL of a published page needs confirm:true (on SuperDuperSEO the old URL redirects; on another CMS it stops working, so pages with search traffic keep their URL). Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | | `title` | string | | | | `description` | string | | | | `content` | string | | The full new body (get_post returns the current one) | | `format` | "markdown" \\| "html" | | Format of content (default markdown) | | `new_slug` | string | | | | `author` | string | | Author id or name, one of the site's authors | | `suppress_ctas` | boolean | | true: no calls to action on this page | | `confirm` | boolean | | | ### `publish_post`: Publish a post Publish a draft on its site now (a page that was live before returns to its old URL). Only hard problems block it (thin content, invalid structured data); the quality score comes back as advice. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | ### `get_post_history`: Post history The versions of a page served by SuperDuperSEO (the last 20): when, by whom (editor, an agent, automation...) and what changed. Read one with get_post {version}; restore one with rollback_post. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | ### `rollback_post`: Restore an earlier version Make an earlier version of a page current again (its text, title, description, feature image and author). It is published as a new version, the URL never changes, and calls to action follow the current rotation. The first call shows what changes; confirm:true restores. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | | `version` | integer | yes | | | `confirm` | boolean | | | ### `schedule_post`: Schedule a post Publish a draft later: at a given time (15 minutes to 30 days ahead), or without a time in the site's publishing queue, which spaces pages out. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | | `at` | string | | ISO 8601 time; leave out for the queue | ### `unpublish_post`: Unpublish a post Take a page off its site (it becomes a draft). Its URL then answers 410, or 301 to redirect_to (merging it into another page). Pages with search traffic stay live. The first call shows what happens; confirm:true does it. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `slug` | string | yes | The post's slug (or its URL) | | `redirect_to` | string | | Path on the site or https URL the old URL should redirect to | | `confirm` | boolean | | | | `confirm_unmeasured` | boolean | | The user accepts removing it although its search traffic cannot be measured | ### `add_redirect`: Add a redirect On a site served by SuperDuperSEO: make a path redirect (301) to another path or https URL, or answer 410 (gone:true). Chains are collapsed. Not over a live post (use unpublish_post with redirect_to). Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `from` | string | yes | Path on the site, e.g. /old-page | | `to` | string | | Path on the site or https URL | | `gone` | boolean | | Answer 410 instead of redirecting | ### `remove_redirect`: Remove a redirect Remove a redirect added with add_redirect. Redirects made automatically (slug changes, unpublished pages) protect old links and stay. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `from` | string | yes | | ## Pace, publishing and balance ### `set_pace`: Set pages a day How many pages a day SuperDuperSEO writes and publishes for a site (0 for none), paid from the prepaid balance at the site's page price. A site writes once it shows its first page and the account has balance. Raising the daily spend shows the new cost first; confirm:true applies it. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `pages_per_day` | integer | yes | | | `confirm` | boolean | | | ### `pause_site`: Pause a site Stop daily writing for one site. Its published pages stay live; nothing is charged while paused. resume_site starts it again. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `resume_site`: Resume a site Start daily writing again for a site paused with pause_site (or in the app). Says whether it writes today and, if not, what it waits for. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `set_publishing`: How a site publishes How a site's pages go live: review_before_publishing (every page waits for the user), quality_floor (40-90; pages scoring under it are held, default 65), publishing hours (hours_from / hours_to, 0-23 in the account's time zone), gifs (a fitting GIF in pages), auto_refill_topics (add researched topics when the queue runs low). Send only what changes; with none it shows the current settings. Ask the user first. undo_activity reverses a change. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `review_before_publishing` | boolean | | | | `quality_floor` | integer | | | | `hours_from` | integer | | | | `hours_to` | integer | | | | `gifs` | boolean | | | | `auto_refill_topics` | boolean | | | ### `set_adult_mode`: Adult content mode For sites about adult products: pages go through a separate writer and checks at a different price per page, from the next page. Pages a day stay the same. Without confirm it shows both prices; tell the user, then confirm:true switches it. undo_activity switches it back. Changes something. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `on` | boolean | yes | | | `confirm` | boolean | | | ### `get_balance`: Balance and prices The account balance, what a page costs on each site, each site's pages a day and spend, whether it writes today (and if not, why), and how long the balance lasts. Prepaid only: every page SuperDuperSEO writes is paid from the balance, charged when it goes live (pages waiting to go live are reserved). Read only: balance is added by the user in the app; set_pace changes a site's pace. Read only. No parameters. ## Inbox and activity ### `get_inbox`: What needs the user The user's inbox, the same list as the app's Home: pages waiting for approval, failed publishes, a site not connected, site check issues, missing setup, low balance, few topics left, pages held for quality, and a weekly suggestion. Each item says the tool that fixes it, or that only the user can. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | | The site: its hostname (example.com) or id | ### `get_activity`: What happened What happened on the user's sites, newest first: pages published, held, refreshed, edited, linked, redirects and settings changed, with who did it (SuperDuperSEO, the user, or an agent), why, and what it cost. Events marked undoable can be reversed with undo_activity. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | | The site: its hostname (example.com) or id | | `who` | "everyone" \\| "you" \\| "agent" \\| "system" | | "you" is the user; "system" is SuperDuperSEO | | `kind` | "published" \\| "held" \\| "refreshed" \\| "linked" \\| "redirected" \\| "settings" | | | | `before` | string | | An event id from a previous answer: events older than it | | `limit` | integer | | | ### `undo_activity`: Undo something Reverse an event from get_activity that is marked undoable: take a published page down, put it back, restore the version before an edit or refresh, set a pace back, pause or resume, move page addresses back, remove a redirect. The first call says what it will do; confirm:true does it. Pages with search traffic stay live. Changes or removes something: shows a preview first, `confirm: true` does it. | Parameter | Type | Required | Meaning | |---|---|---|---| | `event_id` | string | yes | | | `confirm` | boolean | | | | `remove_unmeasured` | boolean | | Take a page down even though its search traffic can't be measured (Search Console not connected) | ## Insight ### `get_seo_report`: SEO report How a site does in search: clicks, impressions, position and top pages from Search Console (last 30 days), pages nearly on page one, pages losing clicks, indexing, content health and the last live-site check. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | ### `suggest_topics`: Suggest topics What to write next for a site: the topics already queued, the topics our research found for it (not queued yet), and with fresh:true new AI suggestions that fill gaps (each flagged when it would compete with an existing page). add_topics queues the ones the user wants. Free. Read only. | Parameter | Type | Required | Meaning | |---|---|---|---| | `site` | string | yes | The site: its hostname (example.com) or id | | `fresh` | boolean | | Also ask for new suggestions (up to 10 times an hour) | | `count` | integer | | | --- # Content API Your site's server code reads its pages, SEO tags and sitemap from SuperDuperSEO, then renders them on your own domain. The [SDK](https://superduperseo.com/docs/sdk) wraps all of this. - **Base URL:** `https://content.superduperseo.com/v1/` - **Auth:** `Authorization: Bearer ` (`sds_live_...`). The key selects the site. Create it with the MCP tool `create_site_key`, or in the app on the site's Content API page. - **Server side only.** Keep the key in your server environment (e.g. `SUPERDUPERSEO_CONTENT_KEY`), never in code or the browser. There is no CORS, so browsers cannot call the API. - **Never public, never indexed.** Without a valid key every request answers 401, and every response carries `X-Robots-Tag: noindex, nofollow, noarchive`. Your domain is the only public copy. - **Caching:** responses are `Cache-Control: private, max-age=60` with an `ETag`; send `If-None-Match` to get `304`. Cache on your side and refresh on our [webhook](https://superduperseo.com/docs/webhooks). - **Limits:** 20 requests a second per key (bursts of 200), then `429` with `Retry-After`. - **Errors:** `401` wrong or revoked key; `404` no such file (a post that does not exist); `429` slow down. ## Serving a request For every path under your blog, in this order: 1. **Redirects first.** If the path is in `redirects.json`, answer `301` to `to`, or `410` when `to` is null. 2. **The page.** If `posts/{slug}.json` exists and its `path` is the request path, render it. 3. **Otherwise a real `404`,** never a `200` page that says "not found". Render on the server: - `` from `seo.title`; - `head_html` in the `<head>`, removing your own description, canonical, Open Graph and Article JSON-LD so nothing is duplicated; - the title as the only `<h1>`; - then `html`. ## Files ### `site.json` (json) The site: name, routes, feed, structured data. | Field | Type | Meaning | |---|---|---| | `name` | `string` | The site's name (as in og:site_name and structured data) | | `url` | `string` | The site's home URL | | `language` | `string` | Default content language | | `description` | `string \| null` | What the site is about | | `logo` | `string \| null` | Logo URL | | `routes` | `{ index, post, author, topic }` | Where each kind of page lives, e.g. post: "/blog/{slug}" | | `feed` | `string` | The RSS feed URL to serve | | `json_ld` | `object` | Organization and WebSite structured data, for your home page | ### `posts/index.json` (json) Published pages, newest first, 100 per file: { page, pages, total, next, posts: [summary] }. Follow `next` (posts/index-2.json...). | Field | Type | Meaning | |---|---|---| | `id` | `string` | Stable id of the page | | `collection` | `"blog" \| "pages"` | Blog post, or a service/SaaS/product page | | `slug` | `string` | The public slug | | `path` | `string` | The exact path to serve it at, e.g. /blog/my-post (no trailing slash) | | `url` | `string` | The canonical URL on your domain | | `title` | `string` | Title, rendered as the page's only <h1> | | `summary` | `string \| null` | One or two sentences: the meta description and the answer first | | `feature_image` | `{ url, alt, width, height, srcset, credit: { name, url, source } } \| null` | Feature image, with dimensions and srcset; show the credit when present | | `author` | `{ slug, name, url } \| null` | The byline's author | | `topics` | `Array<{ slug, name, url }>` | Topic (cluster) the post belongs to | | `reading_time` | `number` | Minutes | | `published_at` | `ISO 8601` | First publish date (datePublished) | | `updated_at` | `ISO 8601` | Last change to the content (dateModified, sitemap lastmod) | ### `posts/{slug}.json` (json) One page with everything to render it. | Field | Type | Meaning | |---|---|---| | `id` | `string` | Stable id of the page | | `collection` | `"blog" \| "pages"` | Blog post, or a service/SaaS/product page | | `slug` | `string` | The public slug | | `path` | `string` | The exact path to serve it at, e.g. /blog/my-post (no trailing slash) | | `url` | `string` | The canonical URL on your domain | | `title` | `string` | Title, rendered as the page's only <h1> | | `summary` | `string \| null` | One or two sentences: the meta description and the answer first | | `feature_image` | `{ url, alt, width, height, srcset, credit: { name, url, source } } \| null` | Feature image, with dimensions and srcset; show the credit when present | | `author` | `{ slug, name, url } \| null` | The byline's author | | `topics` | `Array<{ slug, name, url }>` | Topic (cluster) the post belongs to | | `reading_time` | `number` | Minutes | | `published_at` | `ISO 8601` | First publish date (datePublished) | | `updated_at` | `ISO 8601` | Last change to the content (dateModified, sitemap lastmod) | | `html` | `string` | The body: no <h1>, headings with ids, byline, calls to action and footer included. Render it as is, on the server | | `markdown` | `string` | The same body as markdown (for /blog/{slug}.md or other renderers) | | `toc` | `Array<{ id, text, level }>` | Table of contents from the h2/h3 headings | | `word_count` | `number` | Words in the body | | `language` | `string` | Content language, for <html lang> | | `version` | `number` | Increases with every content change | | `content_hash` | `string` | Changes when the content changes | | `seo` | `{ title, description, canonical, robots, og, twitter }` | Everything for the <head>, as fields | | `json_ld` | `object` | Structured data (@graph: Organization, WebSite, WebPage, BlogPosting, Person, BreadcrumbList, FAQPage...) | | `head_html` | `string` | Ready-made <head> tags: description, canonical, robots, Open Graph, Twitter and the JSON-LD. Not the <title>: set it from seo.title | | `breadcrumbs` | `Array<{ name, url }>` | Home > Blog > Post | | `related` | `Array<summary>` | Related posts (same topic first) | | `alternates` | `Array<{ hreflang, url }>` | Other languages of the page (empty until translations exist) | ### `authors/index.json` (json) Authors: { authors: [{ slug, name, url, job_title, posts }] }. ### `authors/{slug}.json` (json) An author page: name, job_title, bio, image, profiles, posts, seo, json_ld (ProfilePage), head_html. ### `topics/index.json` (json) Topics: { topics: [{ slug, name, url, posts, indexable }] }. ### `topics/{slug}.json` (json) A topic page: name, posts, seo (noindex under 3 posts), json_ld (CollectionPage), head_html. ### `redirects.json` (json) { redirects: [{ from, to, status }] }: answer 301 to `to`, or 410 when `to` is null, before rendering anything. ### `sitemap.xml` (xml) Indexable URLs only, honest lastmod, image entries. Serve at /sitemap.xml or merge into yours. ### `feed.xml` (xml) RSS 2.0, newest 50 posts. Serve at site.feed. ### `llms.txt` (text) For AI search engines. Serve at /llms.txt. ### `search.json` (json) For on-site search: { entries: [{ title, summary, url, topics }] }. ### `changes.json` (json) What changed, newest first: { cursor, changes: [{ id, event, path, url, version, at }] }. For incremental builds and missed webhooks. ### `indexnow.txt` (text) The site's IndexNow key: serve it as text at /{key}.txt. --- # SDK: @superduperseo/content A typed client for the [Content API](https://superduperseo.com/docs/content-api). No dependencies; works in Node 18+, edge runtimes and Cloudflare Workers. ```bash npm install @superduperseo/content ``` Set the site key in the **server** environment: `SUPERDUPERSEO_CONTENT_KEY=sds_live_...`. ```ts import { createClient } from '@superduperseo/content' const cms = createClient() // reads SUPERDUPERSEO_CONTENT_KEY; or createClient({ key }) const hit = await cms.resolve('/blog/my-post') if (hit?.type === 'redirect') return Response.redirect(hit.to, 301) if (hit?.type === 'gone') return new Response(null, { status: 410 }) if (hit?.type === 'post') render(hit.post) // seo.title, head_html, title as <h1>, html // null: not a blog page: render your own page or a real 404 ``` | Method | Returns | |---|---| | `resolve(path)` | `{ type: 'post', post }`, `{ type: 'redirect', to, status: 301 }`, `{ type: 'gone', status: 410 }` or `null` | | `site()` | Site name, routes, feed URL, structured data | | `posts(page)`, `allPosts()` | Post summaries, newest first | | `post(slug)` | A full post | | `author(slug)`, `authors()`, `topic(slug)`, `topics()` | Author and topic pages | | `redirects()` | All 301s and 410s | | `sitemapXml()`, `feedXml()`, `llmsTxt()` | Text to serve at /sitemap.xml, the feed URL, /llms.txt | | `indexNowKey()` | Serve it as text at `/{key}.txt` | | `search()`, `changes()` | Search entries; what changed (by cursor) | | `clearCache(file?)` | Drop cached copies (call it from your webhook) | Responses are cached in memory for 60 seconds and revalidated with ETags. If SuperDuperSEO cannot be reached, the last good copy is served. ## Next.js helpers ```ts import { metadataFor, jsonLd, revalidateRoute } from '@superduperseo/content/next' export async function generateMetadata({ params }) { return metadataFor(await cms.post((await params).slug)) } // <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(post)} /> export const POST = revalidateRoute({ secret: process.env.SUPERDUPERSEO_WEBHOOK_SECRET!, revalidate: revalidatePath }) ``` ## Webhooks `verifyWebhook(secret, rawBody, signatureHeader)` returns the event, or `null` when the signature is wrong or older than 5 minutes. See [Webhooks](https://superduperseo.com/docs/webhooks). Complete examples: [Next.js starter](https://superduperseo.com/docs/starter-nextjs), [Cloudflare starter](https://superduperseo.com/docs/starter-cloudflare). --- # Webhooks SuperDuperSEO calls your site when a page changes, so it can drop cached copies. Add one with the MCP tool `add_webhook` (or on the site's Content API page). It returns the signing secret once: store it as `SUPERDUPERSEO_WEBHOOK_SECRET`. - **Request:** `POST` with a JSON body `{ id, event, site, path, url, version, at }`. - **Events:** `page.published`, `page.updated`, `page.unpublished`, `site.updated` (redirects, settings, many pages), `ping` (`test_webhook`). - **Signature:** header `SuperDuperSEO-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. Compare in constant time and reject when `t` is more than 5 minutes off. The SDK's `verifyWebhook` does all of this. - **Delivery:** answer `2xx`. We try 3 times: at once, after 2 seconds, after 10 seconds. Every delivery is logged and can be replayed from the app. - **Missed one?** `changes.json` lists recent changes by cursor. ```ts import { verifyWebhook } from '@superduperseo/content' export async function POST(req: Request) { const raw = await req.text() const event = await verifyWebhook(process.env.SUPERDUPERSEO_WEBHOOK_SECRET!, raw, req.headers.get('SuperDuperSEO-Signature')) if (!event) return new Response('bad signature', { status: 401 }) // drop cached copies of event.path, the blog index, the sitemap and the feed return new Response('ok') } ``` --- # Site check and "Verified" SuperDuperSEO fetches your live site the way a search engine does: no JavaScript, first response only. It compares what it gets with what was published. It runs every hour, and on demand with the MCP tool `check_site` (run it after every deploy). A site is **Verified** when: - its pages match what was published; - the site-level checks pass; - Search Console is connected (`connect_search_console`). Only verified sites get IndexNow pings, and a site moves onto SuperDuperSEO as its CMS only once it is verified. | Issue | What it means | Fix | |---|---|---| | `http_error` | The page does not load | Serve every post at its exact path (post.path) with status 200. | | `redirected` | The page redirects elsewhere | Serve the post at post.path itself; only our redirects (redirects.json) should redirect. | | `noindex` | The page tells search engines not to index it | Remove noindex from the page's robots meta tag or X-Robots-Tag header. | | `canonical_mismatch` | Wrong canonical URL | Use post.seo.canonical (or post.head_html) for <link rel="canonical">. | | `title_mismatch` | Wrong or missing <title> | Use post.seo.title as the page title (a " - Site name" suffix is fine). | | `description_mismatch` | Wrong or missing meta description | Use post.seo.description (or post.head_html). | | `missing_article_schema` | No article structured data | Add post.json_ld in a <script type="application/ld+json"> (or inject post.head_html). | | `duplicate_article_schema` | Two article schemas on the page | Remove your own Article/BlogPosting JSON-LD on blog pages: ours is the one. | | `wrong_author` | Article schema names another author | Use post.json_ld as is; it names the site's author. | | `h1_count` | Not exactly one <h1> | Render the post title as the only <h1>; post.html never contains one. | | `missing_lang` | No language on the page | Set <html lang="..."> (site.language). | | `client_rendered` | The article is loaded in the browser | Render post.html on the server: search engines read the first HTML. See the Cloudflare or Next.js starter. | | `robots_blocks_blog` | robots.txt blocks the blog | Allow crawling of the blog path in robots.txt. | | `robots_no_sitemap` | robots.txt does not name the sitemap | Add "Sitemap: https://your-site/sitemap.xml" to robots.txt. | | `sitemap_unreachable` | No sitemap at /sitemap.xml | Serve cms.sitemapXml() at /sitemap.xml (or merge it into yours). | | `indexnow_key_unreachable` | IndexNow key file missing | Serve the key from cms.indexNowKey() at /{key}.txt. | | `soft_404` | Unknown blog URLs answer 200 | Return a real 404 when cms.resolve(path) is null. | | `search_console_missing` | Search Console not connected | Verify the site in Search Console (Domains, your site, Search Console) so we can watch its rankings. | | `not_fetching` | Your site is not reading from SuperDuperSEO yet | Set SUPERDUPERSEO_CONTENT_KEY in your site's server environment and deploy. | --- # Starter: Next.js (App Router) A complete, SEO-correct blog on your domain from SuperDuperSEO, rendered on the server. Copy the files you need into your site, keep your design, and keep every URL. Every file is below; this page as markdown is one fetch: https://superduperseo.com/docs/starter-nextjs.md. A blog that reads its pages, SEO tags and sitemap from SuperDuperSEO, SEO-correct by default: server-rendered pages, one `<h1>`, our canonical, robots, Open Graph and structured data, 301/410 from our redirects, sitemap, RSS, `llms.txt` and the IndexNow key file. Pages are cached for a day and refreshed by our webhook the moment they change. ## Set up 1. `npm install` 2. In SuperDuperSEO, open Domains, your site, Content API: create a site key. Put it in the **server** environment as `SUPERDUPERSEO_CONTENT_KEY` (Vercel/Netlify/Cloudflare project settings, or `.env.local` for development). Never in code or the browser. 3. Deploy. Then add the webhook `https://YOUR-SITE/api/revalidate` on the same page and put its secret in `SUPERDUPERSEO_WEBHOOK_SECRET`. 4. SuperDuperSEO checks your live pages (canonical, title, description, structured data, server rendering) and marks the site Verified. ## Adding it to an existing Next.js site Copy `lib/cms.ts`, `proxy.ts` (`middleware.ts` on Next 15 or older; or merge its redirect check into yours), `app/blog/**`, `app/sitemap.xml`, `app/llms.txt`, `app/api/**` and the rewrite in `next.config.ts`. Remove your own Article/BlogPosting JSON-LD on these pages (ours is the one), and keep each post at exactly `post.path`. ## Files ### `package.json` ````json { "name": "superduperseo-nextjs-starter", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "@superduperseo/content": "^0.1.0", "next": "^16.4.0", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@types/node": "^20", "@types/react": "^19", "typescript": "^5" } } ```` ### `next.config.ts` ````ts import type { NextConfig } from 'next' const config: NextConfig = { // Blog URLs have no trailing slash (/blog/my-post); /blog/my-post/ 301s to it. trailingSlash: false, async rewrites() { // The IndexNow key file (/{key}.txt) is answered by app/api/indexnow/[key]. return [{ source: '/:key([A-Za-z0-9-]{8,128}).txt', destination: '/api/indexnow/:key' }] }, } export default config ```` ### `proxy.ts` ````ts import { NextResponse, type NextRequest } from 'next/server' import { cms } from './lib/cms' /** * Our redirects: 301 for moved pages, 410 for removed ones, before any page * renders. (Next.js 16 "proxy"; on Next 15 or older, name this file * middleware.ts and the function middleware.) */ export async function proxy(req: NextRequest) { try { const path = req.nextUrl.pathname.replace(/\/+$/, '') || '/' const hit = (await cms.redirects()).find(r => r.from === path) if (hit?.to) return NextResponse.redirect(hit.to, 301) if (hit) return new NextResponse('Gone', { status: 410, headers: { 'X-Robots-Tag': 'noindex' } }) } catch { // SuperDuperSEO unreachable and nothing cached yet: render normally. } return NextResponse.next() } export const config = { matcher: ['/blog/:path*'] } ```` ### `lib/cms.ts` ````ts import { createClient } from '@superduperseo/content' /** Server-side only: reads SUPERDUPERSEO_CONTENT_KEY. */ export const cms = createClient() ```` ### `app/layout.tsx` ````tsx import type { ReactNode } from 'react' export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body style={{ maxWidth: 760, margin: '0 auto', padding: '24px 16px', fontFamily: 'system-ui, sans-serif', lineHeight: 1.6 }}> {children} </body> </html> ) } ```` ### `app/page.tsx` ````tsx import Link from 'next/link' export default function Home() { return ( <main> <h1>Welcome</h1> <p><Link href="/blog">Read the blog</Link></p> </main> ) } ```` ### `app/blog/page.tsx` ````tsx import type { Metadata } from 'next' import Link from 'next/link' import { cms } from '../../lib/cms' export const revalidate = 86400 export async function generateMetadata(): Promise<Metadata> { const site = await cms.site() return { title: `Blog - ${site?.name ?? ''}`, description: site?.description ?? undefined, alternates: { canonical: `${site?.url.replace(/\/$/, '')}${site?.routes.index ?? '/blog'}`, ...(site?.feed ? { types: { 'application/rss+xml': site.feed } } : {}), }, } } export default async function BlogIndex({ searchParams }: { searchParams: Promise<{ page?: string }> }) { const page = Math.max(1, Number((await searchParams).page) || 1) const index = await cms.posts(page) return ( <main> <h1>Blog</h1> {(index?.posts ?? []).map(p => ( <article key={p.id} style={{ marginBottom: 32 }}> <h2><Link href={p.path}>{p.title}</Link></h2> {p.summary && <p>{p.summary}</p>} <small>{new Date(p.published_at).toLocaleDateString('en', { dateStyle: 'long' })}{p.author ? ` · ${p.author.name}` : ''} · {p.reading_time} min read</small> </article> ))} <nav> {page > 1 && <Link href={page === 2 ? '/blog' : `/blog?page=${page - 1}`}>Newer</Link>}{' '} {index?.next && <Link href={`/blog?page=${page + 1}`}>Older</Link>} </nav> </main> ) } ```` ### `app/blog/[slug]/page.tsx` ````tsx import type { Metadata } from 'next' import Link from 'next/link' import { notFound, permanentRedirect } from 'next/navigation' import { metadataFor, jsonLd } from '@superduperseo/content/next' import { cms } from '../../../lib/cms' // Cached for a day; our webhook (app/api/revalidate) refreshes a page the moment it changes. export const revalidate = 86400 export async function generateStaticParams() { try { return (await cms.allPosts()).map(p => ({ slug: p.slug })) } catch { return [] } } async function load(slug: string) { const hit = await cms.resolve(`/blog/${slug}`) if (hit?.type === 'redirect') permanentRedirect(hit.to) if (hit?.type !== 'post') notFound() return hit.post } export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { return metadataFor(await load((await params).slug)) as Metadata } export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { const post = await load((await params).slug) const image = post.feature_image return ( <main> {/* One structured-data block per page: ours (remove any other Article schema). */} <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(post)} /> <nav aria-label="Breadcrumb"> {post.breadcrumbs.map((b, i) => ( <span key={b.url}>{i > 0 && ' / '}{i < post.breadcrumbs.length - 1 ? <Link href={new URL(b.url).pathname}>{b.name}</Link> : b.name}</span> ))} </nav> <article> <h1>{post.title}</h1> {/* The byline ("By ...") is part of post.html, signed by the site's author. */} <p><small> <time dateTime={post.published_at}>{new Date(post.published_at).toLocaleDateString('en', { dateStyle: 'long' })}</time> {post.updated_at !== post.published_at && <> · Updated <time dateTime={post.updated_at}>{new Date(post.updated_at).toLocaleDateString('en', { dateStyle: 'long' })}</time></>} {' · '}{post.reading_time} min read </small></p> {image && ( // The hero is the largest element: load it first (LCP), with its size (no layout shift). // eslint-disable-next-line @next/next/no-img-element <img src={image.url} srcSet={image.srcset ?? undefined} sizes="(max-width: 760px) 100vw, 760px" alt={image.alt ?? ''} width={image.width ?? undefined} height={image.height ?? undefined} fetchPriority="high" style={{ width: '100%', height: 'auto' }} /> )} {image?.credit && <small>Photo: {image.credit.url ? <a href={image.credit.url} rel="nofollow">{image.credit.name}</a> : image.credit.name}</small>} {post.toc.length > 2 && ( <nav aria-label="Contents"> <ul>{post.toc.filter(t => t.level === 2).map(t => <li key={t.id}><a href={`#${t.id}`}>{t.text}</a></li>)}</ul> </nav> )} <div dangerouslySetInnerHTML={{ __html: post.html }} /> </article> {post.related.length > 0 && ( <aside> <h2>Related</h2> <ul>{post.related.map(r => <li key={r.slug}><Link href={new URL(r.url).pathname}>{r.title}</Link></li>)}</ul> </aside> )} </main> ) } ```` ### `app/blog/author/[slug]/page.tsx` ````tsx import type { Metadata } from 'next' import Link from 'next/link' import { notFound } from 'next/navigation' import { metadataFor, jsonLd } from '@superduperseo/content/next' import { cms } from '../../../../lib/cms' export const revalidate = 86400 export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const author = await cms.author((await params).slug) return author ? (metadataFor(author) as Metadata) : {} } export default async function AuthorPage({ params }: { params: Promise<{ slug: string }> }) { const author = await cms.author((await params).slug) if (!author) notFound() return ( <main> <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(author)} /> <h1>{author.name}</h1> {author.job_title && <p>{author.job_title}</p>} {author.bio && <p>{author.bio}</p>} <ul>{author.posts.map(p => <li key={p.id}><Link href={p.path}>{p.title}</Link></li>)}</ul> </main> ) } ```` ### `app/blog/topic/[slug]/page.tsx` ````tsx import type { Metadata } from 'next' import Link from 'next/link' import { notFound } from 'next/navigation' import { metadataFor, jsonLd } from '@superduperseo/content/next' import { cms } from '../../../../lib/cms' export const revalidate = 86400 export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const topic = await cms.topic((await params).slug) // Topics with fewer than 3 posts come back noindex (no thin pages). return topic ? (metadataFor(topic) as Metadata) : {} } export default async function TopicPage({ params }: { params: Promise<{ slug: string }> }) { const topic = await cms.topic((await params).slug) if (!topic) notFound() return ( <main> <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(topic)} /> <h1>{topic.name}</h1> <ul>{topic.posts.map(p => <li key={p.id}><Link href={p.path}>{p.title}</Link></li>)}</ul> </main> ) } ```` ### `app/blog/rss.xml/route.ts` ````ts import { cms } from '../../../lib/cms' export const revalidate = 3600 export async function GET() { return new Response((await cms.feedXml()) ?? '', { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } }) } ```` ### `app/sitemap.xml/route.ts` ````ts import { cms } from '../../lib/cms' export const revalidate = 3600 /** Our sitemap (honest lastmod, images). Merge your own pages in if you have a sitemap already. */ export async function GET() { const xml = await cms.sitemapXml() return new Response(xml ?? '', { headers: { 'Content-Type': 'application/xml; charset=utf-8' } }) } ```` ### `app/llms.txt/route.ts` ````ts import { cms } from '../../lib/cms' export const revalidate = 3600 /** For AI search engines (llmstxt.org). */ export async function GET() { return new Response((await cms.llmsTxt()) ?? '', { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) } ```` ### `app/robots.ts` ````ts import type { MetadataRoute } from 'next' import { cms } from '../lib/cms' export default async function robots(): Promise<MetadataRoute.Robots> { const site = await cms.site().catch(() => null) return { rules: [{ userAgent: '*', allow: '/' }], sitemap: site ? `${site.url.replace(/\/$/, '')}/sitemap.xml` : undefined, } } ```` ### `app/api/revalidate/route.ts` ````ts import { revalidatePath } from 'next/cache' import { revalidateRoute } from '@superduperseo/content/next' /** Our webhook: refreshes a page the moment it changes. Add https://YOUR-SITE/api/revalidate in SuperDuperSEO. */ export const POST = revalidateRoute({ secret: process.env.SUPERDUPERSEO_WEBHOOK_SECRET ?? '', revalidate: revalidatePath, alsoRevalidate: ['/blog', '/sitemap.xml', '/blog/rss.xml', '/llms.txt'], }) ```` ### `app/api/indexnow/[key]/route.ts` ````ts import { cms } from '../../../../lib/cms' /** Answers /{key}.txt (see next.config.ts) so search engines accept our IndexNow pings. */ export async function GET(_req: Request, { params }: { params: Promise<{ key: string }> }) { const key = await cms.indexNowKey() if (!key || key !== (await params).key) return new Response('Not found', { status: 404 }) return new Response(key, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) } ```` ### `.env.example` ````bash # From SuperDuperSEO -> Domains -> your site -> Content API. Server-side only. SUPERDUPERSEO_CONTENT_KEY= # Shown when you add the webhook https://YOUR-SITE/api/revalidate in SuperDuperSEO. SUPERDUPERSEO_WEBHOOK_SECRET= ```` --- # Starter: Cloudflare (Worker or Pages) A complete, SEO-correct blog on your domain from SuperDuperSEO, rendered on the server. Copy the files you need into your site, keep your design, and keep every URL. Every file is below; this page as markdown is one fetch: https://superduperseo.com/docs/starter-cloudflare.md. Your blog, rendered on the server at Cloudflare's edge from SuperDuperSEO, inside any static site (plain HTML, Vite/React, AI-built sites). Search engines and AI crawlers get the full article on their first request: title, description, canonical, robots, Open Graph, one JSON-LD block, the text. No "Loading…" pages. `src/blog.ts` does the work. Make `src/layout.ts` look like your site (header, footer, CSS). ## Option A: Worker with static assets 1. `npm install` 2. Create a site key in SuperDuperSEO (Domains, your site, Content API), then `npx wrangler secret put SUPERDUPERSEO_CONTENT_KEY`. Never put it in code or in the browser. 3. Put your static site in `public/`, set `name` and a route in `wrangler.jsonc`, then `npm run deploy`. 4. Add the webhook `https://YOUR-SITE/api/superduperseo/webhook` in SuperDuperSEO and `npx wrangler secret put SUPERDUPERSEO_WEBHOOK_SECRET`. **Already have a worker?** Copy `src/` into your project and add this at the top of your `fetch()`: ```ts const blog = await handleBlog(request, env, ctx) if (blog) return blog ``` Then add `/blog`, `/blog/*`, `/sitemap.xml`, `/llms.txt`, `/*.txt` and `/api/superduperseo/*` to `run_worker_first` (or let the worker see every request). Remove any code that loads posts in the browser. ## Option B: Cloudflare Pages Copy `src/` and `functions/` into your Pages project, then set `SUPERDUPERSEO_CONTENT_KEY` (and `SUPERDUPERSEO_WEBHOOK_SECRET`) as **encrypted** environment variables in the Pages project settings. `functions/blog/[[path]].ts` serves the blog; `functions/[file].ts` serves the sitemap, `llms.txt` and the IndexNow key file; `functions/api/superduperseo/webhook.ts` takes the webhook. If you already have a `functions/blog/[[path]].js`, replace it. ## What you get - `/blog` (paginated), `/blog/{slug}`, `/blog/author/{slug}`, `/blog/topic/{slug}` - Our 301s (moved pages) and 410s (removed pages), a 301 from a trailing slash, real 404s - `/sitemap.xml`, `/blog/rss.xml`, `/llms.txt`, the IndexNow key file - Pages cached at the edge for 5 minutes. Our webhook drops the cached copy as soon as a page changes. If SuperDuperSEO is unreachable, the last good copy is served. SuperDuperSEO then checks your live pages and marks the site Verified. ## Files ### `package.json` ````json { "name": "superduperseo-cloudflare-starter", "private": true, "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy" }, "dependencies": { "@superduperseo/content": "^0.1.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250214.0", "typescript": "^5.7.0", "wrangler": "^4.0.0" } } ```` ### `wrangler.jsonc` ````jsonc { // Cloudflare Worker + static assets. For Cloudflare Pages, deploy the // public/ folder with the functions/ folder instead (see README). "name": "my-site", "main": "src/worker.ts", "compatibility_date": "2025-09-01", "assets": { "directory": "./public", "binding": "ASSETS", // The worker sees blog, sitemap, llms.txt, key file and webhook requests first. "run_worker_first": ["/blog", "/blog/*", "/sitemap.xml", "/llms.txt", "/*.txt", "/api/superduperseo/*"] } // Secrets (not here): npx wrangler secret put SUPERDUPERSEO_CONTENT_KEY // npx wrangler secret put SUPERDUPERSEO_WEBHOOK_SECRET } ```` ### `src/worker.ts` ````ts /** * Cloudflare Worker with static assets (wrangler.jsonc): the blog is rendered * here, everything else is your static site. If you already have a worker * (e.g. simonsovic.com's worker.js), add the two handleBlog lines to its * fetch() before it serves assets. */ import { handleBlog, type Ctx, type Env } from './blog' interface WorkerEnv extends Env { ASSETS: { fetch(request: Request): Promise<Response> } } export default { async fetch(request: Request, env: WorkerEnv, ctx: Ctx): Promise<Response> { const blog = await handleBlog(request, env, ctx) if (blog) return blog return env.ASSETS.fetch(request) }, } ```` ### `src/blog.ts` ````ts /** * The blog, rendered on the server at Cloudflare's edge from SuperDuperSEO. * Every response is full HTML (title, description, canonical, robots, Open * Graph, one JSON-LD block, the article): what Google and AI crawlers read * on their first fetch. No browser-side loading. * * /blog index (?page=2...) * /blog/{slug} post (our 301s and 410s answered first) * /blog/author/{slug} author page * /blog/topic/{slug} topic page * /blog/rss.xml RSS feed * /sitemap.xml sitemap (merge with yours if you have one) * /llms.txt for AI search engines * /{indexnow-key}.txt IndexNow key file * POST /api/superduperseo/webhook our webhook: drops cached copies * * handleBlog() returns null for anything else, so the rest of your site keeps * working. Rendered pages are cached at the edge for 5 minutes. */ import { createClient, verifyWebhook, type AuthorPage, type Post, type PostSummary, type Site, type SuperDuperSeoClient } from '@superduperseo/content' import { layout } from './layout' import { esc } from './html' export interface Env { /** From SuperDuperSEO -> Domains -> your site -> Content API (a secret, never in code). */ SUPERDUPERSEO_CONTENT_KEY: string /** The webhook's signing secret, if you added the webhook. */ SUPERDUPERSEO_WEBHOOK_SECRET?: string /** Only for staging or local development. */ SUPERDUPERSEO_CONTENT_URL?: string } export interface Ctx { waitUntil(promise: Promise<unknown>): void } interface EdgeCache { match(request: Request): Promise<Response | undefined> put(request: Request, response: Response): Promise<void> delete(request: Request | string): Promise<boolean> } const EDGE_TTL = 300 const BLOG = '/blog' let client: SuperDuperSeoClient | null = null let clientKey = '' function cms(env: Env): SuperDuperSeoClient { if (!client || clientKey !== env.SUPERDUPERSEO_CONTENT_KEY) { client = createClient({ key: env.SUPERDUPERSEO_CONTENT_KEY, baseUrl: env.SUPERDUPERSEO_CONTENT_URL }) clientKey = env.SUPERDUPERSEO_CONTENT_KEY } return client } const edgeCache = (): EdgeCache | null => (globalThis as unknown as { caches?: { default?: EdgeCache } }).caches?.default ?? null const date = (iso: string) => new Date(iso).toLocaleDateString('en', { dateStyle: 'long', timeZone: 'UTC' }) function html(body: string, status = 200, extra: Record<string, string> = {}): Response { return new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': `public, max-age=60, s-maxage=${EDGE_TTL}`, ...extra } }) } const text = (body: string, type: string) => new Response(body, { headers: { 'Content-Type': `${type}; charset=utf-8`, 'Cache-Control': `public, max-age=300, s-maxage=${EDGE_TTL}` } }) function headFor(seo: { title: string; description: string | null; canonical: string; robots: string }): string { return `${seo.description ? `<meta name="description" content="${esc(seo.description)}">` : ''}<link rel="canonical" href="${esc(seo.canonical)}"><meta name="robots" content="${esc(seo.robots)}">` } function card(p: PostSummary): string { return `<article class="sds-card"><h2><a href="${esc(p.path)}">${esc(p.title)}</a></h2>${p.summary ? `<p>${esc(p.summary)}</p>` : ''}<p class="sds-meta"><time datetime="${p.published_at}">${date(p.published_at)}</time> · ${p.reading_time} min read</p></article>` } function renderPost(site: Site, post: Post): string { const img = post.feature_image const hero = img // The largest element: load it first (LCP), with its size (no layout shift). ? `<img src="${esc(img.url)}"${img.srcset ? ` srcset="${esc(img.srcset)}" sizes="(max-width: 760px) 100vw, 760px"` : ''} alt="${esc(img.alt ?? '')}"${img.width ? ` width="${img.width}"` : ''}${img.height ? ` height="${img.height}"` : ''} fetchpriority="high" class="sds-hero">${img.credit ? `<p class="sds-credit">Photo: ${img.credit.url ? `<a href="${esc(img.credit.url)}" rel="nofollow">${esc(img.credit.name)}</a>` : esc(img.credit.name)}</p>` : ''}` : '' const crumbs = post.breadcrumbs.map((b, i) => i < post.breadcrumbs.length - 1 ? `<a href="${esc(new URL(b.url).pathname)}">${esc(b.name)}</a>` : esc(b.name)).join(' / ') const toc = post.toc.filter(t => t.level === 2) const updated = post.updated_at !== post.published_at ? ` · Updated <time datetime="${post.updated_at}">${date(post.updated_at)}</time>` : '' const body = `<nav class="sds-crumbs" aria-label="Breadcrumb">${crumbs}</nav> <article class="sds-post"> <h1>${esc(post.title)}</h1> <p class="sds-meta"><time datetime="${post.published_at}">${date(post.published_at)}</time>${updated} · ${post.reading_time} min read</p> ${hero} ${toc.length > 2 ? `<nav class="sds-toc" aria-label="Contents"><ul>${toc.map(t => `<li><a href="#${esc(t.id)}">${esc(t.text)}</a></li>`).join('')}</ul></nav>` : ''} ${post.html} </article> ${post.related.length ? `<aside class="sds-related"><h2>Related</h2><ul>${post.related.map(r => `<li><a href="${esc(new URL(r.url).pathname)}">${esc(r.title)}</a></li>`).join('')}</ul></aside>` : ''}` // post.head_html: description, canonical, robots, Open Graph, Twitter and the one JSON-LD block. return layout({ site, title: post.seo.title, head: post.head_html, body }) } async function render(path: string, url: URL, env: Env): Promise<Response | null> { const c = cms(env) if (path === '/sitemap.xml') { const x = await c.sitemapXml(); return x ? text(x, 'application/xml') : null } if (path === '/llms.txt') { const t = await c.llmsTxt(); return t ? text(t, 'text/plain') : null } if (path === `${BLOG}/rss.xml`) { const x = await c.feedXml(); return x ? text(x, 'application/rss+xml') : null } const keyFile = /^\/([A-Za-z0-9-]{8,128})\.txt$/.exec(path) if (keyFile) { const k = await c.indexNowKey(); return k && k === keyFile[1] ? text(k, 'text/plain') : null } if (path !== BLOG && !path.startsWith(`${BLOG}/`)) return null // Our redirects first: 301 for moved pages, 410 for removed ones. const redirect = (await c.redirects()).find(r => r.from === path) if (redirect?.to) return Response.redirect(redirect.to, 301) if (redirect) return html(layout({ site: await c.site(), title: 'Gone', head: '<meta name="robots" content="noindex">', body: '<h1>This page was removed</h1>' }), 410) const site = await c.site() if (!site) return null if (path === BLOG) { const page = Math.max(1, Number(url.searchParams.get('page')) || 1) const index = await c.posts(page) if (!index || (page > 1 && index.posts.length === 0)) return null const canonical = `${site.url.replace(/\/$/, '')}${BLOG}${page > 1 ? `?page=${page}` : ''}` const nav = `<nav class="sds-pages">${page > 1 ? `<a href="${BLOG}${page === 2 ? '' : `?page=${page - 1}`}">Newer</a>` : ''} ${index.next ? `<a href="${BLOG}?page=${page + 1}">Older</a>` : ''}</nav>` return html(layout({ site, title: `Blog - ${site.name}`, head: headFor({ title: `Blog - ${site.name}`, description: site.description, canonical, robots: 'index, follow, max-image-preview:large' }) + `<link rel="alternate" type="application/rss+xml" href="${esc(site.feed)}">`, body: `<h1>Blog</h1>${index.posts.map(card).join('')}${nav}`, })) } const [, kind, slug] = /^\/blog\/(?:(author|topic)\/)?([^/]+)$/.exec(path) ?? [] if (!slug) return null if (kind === 'author' || kind === 'topic') { const page = kind === 'author' ? await c.author(slug) : await c.topic(slug) if (!page) return null const bio = kind === 'author' ? (page as AuthorPage).bio : null const intro = bio ? `<p>${esc(bio)}</p>` : '' return html(layout({ site, title: page.seo.title, head: page.head_html, body: `<h1>${esc(page.name)}</h1>${intro}${page.posts.map(card).join('')}` })) } const hit = await c.resolve(path) return hit?.type === 'post' ? html(renderPost(site, hit.post)) : null } /** * Answer a blog request, or return null to let the rest of the site handle * it. A /blog path that matches nothing gets a real 404 (never a 200 "not * found" page, which search engines treat as a soft 404). */ export async function handleBlog(request: Request, env: Env, ctx?: Ctx): Promise<Response | null> { const url = new URL(request.url) if (url.pathname === '/api/superduperseo/webhook' && request.method === 'POST') { const body = await request.text() const event = await verifyWebhook(env.SUPERDUPERSEO_WEBHOOK_SECRET ?? '', body, request.headers.get('superduperseo-signature')) if (!event) return new Response('Invalid signature', { status: 401 }) cms(env).clearCache() const cache = edgeCache() if (cache) { for (const p of [event.path, BLOG, `${BLOG}/rss.xml`, '/sitemap.xml', '/llms.txt']) { if (p) await cache.delete(`${url.origin}${p}`) } } return Response.json({ ok: true }) } if (request.method !== 'GET' && request.method !== 'HEAD') return null // One URL per page: no trailing slash. if (url.pathname.length > 1 && url.pathname.endsWith('/') && (url.pathname.startsWith(`${BLOG}/`))) { return Response.redirect(`${url.origin}${url.pathname.replace(/\/+$/, '')}${url.search}`, 301) } const path = url.pathname const cache = edgeCache() const cacheKey = new Request(url.toString(), { method: 'GET' }) if (cache) { const cached = await cache.match(cacheKey) if (cached) return cached } let res: Response | null try { res = await render(path, url, env) } catch (err) { console.error('[superduperseo] render failed:', err) return path === BLOG || path.startsWith(`${BLOG}/`) ? new Response('Temporarily unavailable', { status: 503, headers: { 'Retry-After': '60' } }) : null } if (!res && (path === BLOG || path.startsWith(`${BLOG}/`))) { res = html(layout({ site: await cms(env).site().catch(() => null), title: 'Not found', head: '<meta name="robots" content="noindex">', body: '<h1>Page not found</h1><p><a href="/blog">Back to the blog</a></p>' }), 404) } if (res && res.status === 200 && cache && ctx) ctx.waitUntil(cache.put(cacheKey, res.clone())) return res } ```` ### `src/layout.ts` ````ts /** * Your site's frame around every blog page: make it look like the rest of * your site (header, footer, fonts, CSS). Keep <html lang>, the <title> and * ${head} as they are: they carry the SEO tags. */ import type { Site } from '@superduperseo/content' import { esc } from './html' export function layout(args: { site: Site | null; title: string; head: string; body: string }): string { const { site, title, head, body } = args const name = site?.name ?? '' return `<!doctype html> <html lang="${esc(site?.language ?? 'en')}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>${esc(title)} ${head}
${esc(name)} · Blog
${body}
` } ```` ### `src/html.ts` ````ts /** Escape text for HTML text and attribute values. */ export const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') ```` ### `functions/blog/[[path]].ts` ````ts /** Cloudflare Pages: /blog and everything under it (kickmyass.io's shape). */ import { handleBlog, type Env } from '../../src/blog' interface PagesContext { request: Request; env: Env; waitUntil(p: Promise): void; next(): Promise } export async function onRequest(context: PagesContext): Promise { return (await handleBlog(context.request, context.env, context)) ?? context.next() } ```` ### `functions/[file].ts` ````ts /** Cloudflare Pages: /sitemap.xml, /llms.txt and the IndexNow key file; anything else falls through to your site. */ import { handleBlog, type Env } from '../src/blog' interface PagesContext { request: Request; env: Env; waitUntil(p: Promise): void; next(): Promise } export async function onRequest(context: PagesContext): Promise { const path = new URL(context.request.url).pathname if (path !== '/sitemap.xml' && path !== '/llms.txt' && !/^\/[A-Za-z0-9-]{8,128}\.txt$/.test(path)) return context.next() return (await handleBlog(context.request, context.env, context)) ?? context.next() } ```` ### `functions/api/superduperseo/webhook.ts` ````ts /** Cloudflare Pages: our webhook (POST), which drops cached copies of the changed page. */ import { handleBlog, type Env } from '../../../src/blog' interface PagesContext { request: Request; env: Env; waitUntil(p: Promise): void; next(): Promise } export async function onRequestPost(context: PagesContext): Promise { return (await handleBlog(context.request, context.env, context)) ?? new Response('Not found', { status: 404 }) } ```` ### `.dev.vars.example` ````bash # Local development only (wrangler dev). In production use: npx wrangler secret put ... SUPERDUPERSEO_CONTENT_KEY= SUPERDUPERSEO_WEBHOOK_SECRET= ````