<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tony Spiro</title>
    <description>The latest articles on DEV Community by Tony Spiro (@tonyspiro).</description>
    <link>https://dev.to/tonyspiro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F36636%2F37a4c910-90d9-40b1-9a8b-69e1ad31b4f6.jpeg</url>
      <title>DEV Community: Tony Spiro</title>
      <link>https://dev.to/tonyspiro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tonyspiro"/>
    <language>en</language>
    <item>
      <title>Connect a Cosmic Agent to Google Sheets</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Fri, 25 Sep 2026 15:28:20 +0000</pubDate>
      <link>https://dev.to/tonyspiro/connect-a-cosmic-agent-to-google-sheets-58lf</link>
      <guid>https://dev.to/tonyspiro/connect-a-cosmic-agent-to-google-sheets-58lf</guid>
      <description>&lt;p&gt;Letting an agent write to a Google Sheet is a small capability with a surprisingly sharp setup. The auth is a service account, the API is public and well documented, and the create payload is three fields. The working version also calls a different Google API than the task description implies.&lt;/p&gt;

&lt;p&gt;This is the setup end to end, written so you can follow it with your own Google account and your own Cosmic agent. If a step fails on you, the Troubleshooting section at the end covers what this path actually produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you will have at the end
&lt;/h2&gt;

&lt;p&gt;An agent that can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new spreadsheet in a location your team owns&lt;/li&gt;
&lt;li&gt;Write multiple tabs into it in one pass, some tabular, some prose&lt;/li&gt;
&lt;li&gt;Share it with a human reviewer&lt;/li&gt;
&lt;li&gt;Read that spreadsheet back later, including whatever the human changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last item is what makes this worth wiring up. A one-way export is a file. A sheet the agent can re-read is a two-way surface between a run and a person.&lt;/p&gt;

&lt;p&gt;You need a Google account that can create a service account and a Shared Drive, which in practice means Google Workspace rather than a personal Gmail account. You also need a Cosmic agent with the API request capability enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Set up the Google side
&lt;/h2&gt;

&lt;p&gt;Four things, in this order, in the &lt;a href="https://console.cloud.google.com/" rel="noopener noreferrer"&gt;Google Cloud console&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Create a project, or pick an existing one.&lt;/strong&gt; The API enablement and the service account below both happen inside this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Enable both APIs.&lt;/strong&gt; Google Sheets API and Google Drive API. Both, because the call that actually creates the file is a Drive call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Create a service account and download its JSON key.&lt;/strong&gt; IAM and Admin, Service Accounts, Create Service Account, then Keys, Add Key, JSON. The file you get contains a &lt;code&gt;client_email&lt;/code&gt;, a &lt;code&gt;private_key&lt;/code&gt;, and the project identifiers. Treat it like a password. It is one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Create a Shared Drive, then add the service account to it as a member.&lt;/strong&gt; Google Drive, Shared drives, Create shared drive. Open Manage members, paste the service account's &lt;code&gt;client_email&lt;/code&gt;, and grant it Content manager or Manager. Copy the Shared Drive ID out of the URL when you open the drive: it is the segment after &lt;code&gt;/drive/folders/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set up the Cosmic side
&lt;/h2&gt;

&lt;p&gt;Three pieces, all of which live in your own Cosmic account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store the key as a secret.&lt;/strong&gt; Ask your agent to save the service account JSON with &lt;code&gt;manage_secrets&lt;/code&gt;. It gets stored encrypted and referenced by id, so it never appears in a prompt, a log, or a saved request body. This is the part people get wrong by pasting a private key into an agent instruction and hoping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Save the Google calls as endpoints.&lt;/strong&gt; Your agent can register saved API endpoints with default headers, so the Drive create, the Sheets batch update, the values write, and the read-back each become a named endpoint instead of a URL it has to reconstruct from memory every run. Reference the token in the default &lt;code&gt;Authorization&lt;/code&gt; header as a secret placeholder rather than a literal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call them with &lt;code&gt;api_request&lt;/code&gt;.&lt;/strong&gt; From there the agent makes plain HTTPS calls to &lt;code&gt;googleapis.com&lt;/code&gt;. There is no Cosmic-side Google integration doing anything clever underneath. Every call in the rest of this post is a call you could paste into curl.&lt;/p&gt;

&lt;p&gt;One honest rough edge before you start. A service account authenticates by signing a JWT with RS256 and exchanging it at &lt;code&gt;https://oauth2.googleapis.com/token&lt;/code&gt; for an access token that lasts an hour. The exchange is a plain HTTP POST your agent can make. The RS256 signing is not something an agent does with an HTTP tool alone. Two practical ways through it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For following this walkthrough:&lt;/strong&gt; mint a token once from your own machine and store that token as a secret. &lt;code&gt;gcloud auth print-access-token --impersonate-service-account=YOUR_SERVICE_ACCOUNT_EMAIL&lt;/code&gt; does it in one line, as does a five-line Node script using &lt;code&gt;google-auth-library&lt;/code&gt;. It expires in an hour, which is plenty to work through these steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For anything running on a schedule:&lt;/strong&gt; put the signing in a small endpoint you own, have it return a fresh access token, and register that as one more saved endpoint the agent calls before the Google calls. Store its shared secret with &lt;code&gt;manage_secrets&lt;/code&gt; the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Request both scopes on every token, not just the one matching the call you think you are making:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.googleapis.com/auth/spreadsheets
https://www.googleapis.com/auth/drive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creation happens through Drive and writing happens through Sheets, so a token carrying both keeps a single token path across every call in this post. The broader scope is acceptable here because the identity holding it is a service account whose only access is the one Shared Drive you added it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Create the file through the Drive API
&lt;/h2&gt;

&lt;p&gt;Create the spreadsheet with a Drive call rather than &lt;code&gt;sheets.spreadsheets.create&lt;/code&gt;, because a service account has no Drive storage quota of its own and therefore has to create the file inside a Shared Drive it has been added to as a member.&lt;/p&gt;

&lt;p&gt;This is the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://www.googleapis.com/drive/v3/files?supportsAllDrives=true&amp;amp;fields=id,webViewLink
Authorization: Bearer &amp;lt;token&amp;gt;
Content-Type: application/json

{
  "name": "Weekly content report",
  "mimeType": "application/vnd.google-apps.spreadsheet",
  "parents": ["YOUR_SHARED_DRIVE_ID"]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gives you an &lt;code&gt;id&lt;/code&gt;, which is the spreadsheet id every later Sheets call uses, and a &lt;code&gt;webViewLink&lt;/code&gt;, which is the URL you hand to a human.&lt;/p&gt;

&lt;p&gt;Three parts of that request carry weight. &lt;code&gt;mimeType&lt;/code&gt; is what makes Drive mint a native Google Sheets file the Sheets API can address by id, rather than an opaque blob. &lt;code&gt;parents&lt;/code&gt; assigns the file to the Shared Drive, so the Drive owns it and the service account is simply the thing that created it. &lt;code&gt;supportsAllDrives=true&lt;/code&gt; is required on every Drive call that touches a file in a Shared Drive, including this one and the permissions call below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Write multiple tabs, then read the edits back
&lt;/h2&gt;

&lt;p&gt;The first tab exists as soon as the file does. Every additional tab is two calls.&lt;/p&gt;

&lt;p&gt;Add the sheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://sheets.googleapis.com/v4/spreadsheets/&amp;lt;id&amp;gt;:batchUpdate
Authorization: Bearer &amp;lt;token&amp;gt;
Content-Type: application/json

{
  "requests": [
    { "addSheet": { "properties": { "title": "Code snippets" } } }
  ]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then write values into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT https://sheets.googleapis.com/v4/spreadsheets/&amp;lt;id&amp;gt;/values/Code%20snippets!A1?valueInputOption=RAW
Authorization: Bearer &amp;lt;token&amp;gt;
Content-Type: application/json

{
  "values": [["Slug", "Status"], ["pricing", "live"]]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;values&lt;/code&gt; is an array of rows, each row an array of cells. For narrative content rather than a table, split the prose on blank lines and make each paragraph a single-cell row, which puts one paragraph per row in column A.&lt;/p&gt;

&lt;p&gt;Two practical notes. Sheet names with spaces have to be URL-encoded in the range, which is the &lt;code&gt;Code%20snippets!A1&lt;/code&gt; above. And when you are writing a dozen tabs, collect per-tab failures rather than aborting the whole job on the first bad one. A partial write that reports what it could not do beats losing eleven good tabs because the twelfth had an invalid name.&lt;/p&gt;

&lt;p&gt;Share it with a human using the Drive permissions endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://www.googleapis.com/drive/v3/files/&amp;lt;id&amp;gt;/permissions?supportsAllDrives=true&amp;amp;sendNotificationEmail=false
Authorization: Bearer &amp;lt;token&amp;gt;
Content-Type: application/json

{ "role": "writer", "type": "user", "emailAddress": "reviewer@yourcompany.com" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;supportsAllDrives=true&lt;/code&gt; again. It is required on this call for the same reason it was required on the create.&lt;/p&gt;

&lt;p&gt;Then read the whole thing back, including whatever the human changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://sheets.googleapis.com/v4/spreadsheets/&amp;lt;id&amp;gt;?includeGridData=true&amp;amp;fields=properties.title,sheets(properties.title,data.rowData.values.formattedValue)
Authorization: Bearer &amp;lt;token&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The narrow &lt;code&gt;fields&lt;/code&gt; mask is the detail worth copying. Without it, &lt;code&gt;includeGridData=true&lt;/code&gt; returns the entire grid including every formatting property on every cell, and the payload gets very large very fast on a sheet a human has been editing for a week. With the mask you get titles and &lt;code&gt;formattedValue&lt;/code&gt; strings and nothing else. Normalize missing cells to an empty string on your side so whatever consumes this never has to null-check a cell.&lt;/p&gt;

&lt;p&gt;That read-back closes the loop. Your agent wrote a draft, a person edited it in a tool they already had open with no new login and no new app, and the next run can see exactly what they left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things this makes possible
&lt;/h2&gt;

&lt;p&gt;Creating a file is a small capability with a large operational consequence attached. The destination has to be a Shared Drive the team owns, because the service account cannot own anything itself. That same constraint shows up in any content operation spanning many properties: one shared credential writing into one owned destination, rather than a separate identity and a separate orphaned folder per property.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A draft handoff a human can actually edit
&lt;/h3&gt;

&lt;p&gt;An agent writes a draft into a sheet and shares it with a reviewer. The reviewer edits in place. A later run reads the spreadsheet back and picks up exactly what the human left behind. The read-back is what turns the sheet into a two-way surface instead of an export.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Structured exports that are not one flat table
&lt;/h3&gt;

&lt;p&gt;Most real reports have more than one shape in them. A content audit has a table of pages with statuses, and it also has a pile of prose that does not fit in a grid. Multi-tab writing handles both in one pass: rows for the tabular tab, paragraph-per-row for the narrative tab. Collecting per-tab failures means a twelve-tab export that hits one bad tab name still delivers eleven tabs and tells you which one it dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. One credential across many properties
&lt;/h3&gt;

&lt;p&gt;This is the use worth the most attention, and it is where the Shared Drive constraint stops being an annoyance and starts being the point.&lt;/p&gt;

&lt;p&gt;A team running five to twenty distinct web properties has the same reporting job repeated per property: pull what changed, write it somewhere a human will actually read, keep the history. The naive version gives every property its own credential, its own destination folder, and its own half-remembered setup. Six months later nobody knows which service account owns which folder, and the person who configured property nine has left the company.&lt;/p&gt;

&lt;p&gt;Creating through Drive with an explicit &lt;code&gt;parents&lt;/code&gt; forces the better shape by default. The file is owned by a Shared Drive from the moment it exists. Access is governed by the Drive permissions the team already maintains, not by whoever happens to hold the key. Removing someone from the Drive removes them from every sheet the agent has ever written, across every property, without touching the agent or rotating anything. Adding a property means passing a different parent folder id on the request and provisioning no new identity at all.&lt;/p&gt;

&lt;p&gt;The read-back compounds this. Once each sheet is a real addressable destination with a stable id, a run next week can read what a human changed this week and act on it, which holds across properties and across time rather than only within a single job. If your team is already running a dozen properties, the same consolidation argument applies one level up to the content layer itself: &lt;a href="https://www.cosmicjs.com/workspaces?utm_source=dev.to&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=connect-agent-to-google-sheets&amp;amp;utm_content=body-workspaces"&gt;Workspaces&lt;/a&gt; puts every brand on one bill and one team.&lt;/p&gt;

&lt;p&gt;Where this stops is worth naming plainly. The capability is that an agent can create, write to, share, and read a spreadsheet in a location the team owns. What gets built on top of it, whether per-property reporting, a review queue, or a staging area for bulk import, is a workflow decision you make. The setup guarantees the destination is owned by the team and reachable by the next run, and deliberately assumes nothing beyond that.&lt;/p&gt;

&lt;p&gt;If you are wiring agents into systems that write, the same question applies to your content layer: what exactly is the credential allowed to do, and can you verify it rather than assume it. We wrote up the Cosmic answer in &lt;a href="https://www.cosmicjs.com/blog/ai-agent-write-access-cms-controls?utm_source=dev.to&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=connect-agent-to-google-sheets&amp;amp;utm_content=body-write-access-controls"&gt;AI Agent Write Access: The 4 Real Controls You Get With Your CMS&lt;/a&gt;, including the two controls we do not ship yet. The broader picture of agents reading and writing structured content is on the &lt;a href="https://www.cosmicjs.com/ai?utm_source=dev.to&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=connect-agent-to-google-sheets&amp;amp;utm_content=body-ai-page"&gt;Cosmic for AI teams&lt;/a&gt; page, and you can &lt;a href="https://app.cosmicjs.com/signup?utm_source=dev.to&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=connect-agent-to-google-sheets&amp;amp;utm_content=body-signup"&gt;start on the Free plan&lt;/a&gt;, no credit card required, and give an agent its first saved endpoint in about ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;403 PERMISSION_DENIED&lt;/code&gt; on &lt;code&gt;POST /v4/spreadsheets&lt;/code&gt;.&lt;/strong&gt; The service account has no Drive storage of its own, so it cannot own the file that call is asking Google to create. Create the spreadsheet through the Drive API instead, into a Shared Drive the service account has been added to as a member, and pass &lt;code&gt;supportsAllDrives=true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reviewer cannot open the sheet.&lt;/strong&gt; The file inherits the permissions of the Shared Drive it was created in, so anyone who is not a member of that Drive has no access until you share the file explicitly. Grant it with the Drive permissions call in Step 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A write lands somewhere other than the tab you expected.&lt;/strong&gt; The tab name in the range has to match a sheet that already exists, and a range with no tab name goes to the first sheet in the file. Add the tab with &lt;code&gt;batchUpdate&lt;/code&gt; before writing to it, and URL-encode any spaces in the name.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>tutorial</category>
      <category>googlesheets</category>
    </item>
    <item>
      <title>Give your agent an inbox, then sign it up for Cosmic</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:55:04 +0000</pubDate>
      <link>https://dev.to/tonyspiro/give-your-agent-an-inbox-then-sign-it-up-for-cosmic-32n0</link>
      <guid>https://dev.to/tonyspiro/give-your-agent-an-inbox-then-sign-it-up-for-cosmic-32n0</guid>
      <description>&lt;p&gt;Hand an agent a task like "build me a recipe blog" and it can scaffold the app, write the content, and ship it. Then it hits a wall. The CMS wants an account, the account wants an email address, and the verification code lands in a human's mail client. The run stops and waits for someone to paste six digits back.&lt;/p&gt;

&lt;p&gt;This tutorial removes that stop. The agent provisions its own email inbox from &lt;a href="https://www.agentmail.to/" rel="noopener noreferrer"&gt;AgentMail&lt;/a&gt;, passes that address to &lt;a href="https://www.cosmicjs.com/docs/api/agents" rel="noopener noreferrer"&gt;Cosmic agent signup&lt;/a&gt; as &lt;code&gt;human_email&lt;/code&gt;, reads the claim code out of its own inbox, and submits it to verify. Working curl for every step, and no human in the loop.&lt;/p&gt;

&lt;p&gt;Why it is worth wiring up: until that code is submitted, the new project sits in a restricted state with a deletion clock running on it. An agent that can read its own mail clears the gate itself and finishes the run holding a verified Cosmic project, a bucket, and the read and write keys to start writing content to it.&lt;/p&gt;

&lt;p&gt;This is for developers building an agent (Cursor, Claude, a backend service) that needs a real content backend and can be handed an email address. You need a domain you can receive mail on, and nothing else.&lt;/p&gt;

&lt;p&gt;The flow below was run against the production APIs on September 22, 2026. Signup returned an unclaimed project, the claim code arrived in the AgentMail message preview, and verify returned &lt;code&gt;auth_type: verified&lt;/code&gt; with the restricted limits lifted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get at the end
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AgentMail inbox at &lt;code&gt;my-agent@agentmail.to&lt;/code&gt;, plus an API key the agent uses to read it&lt;/li&gt;
&lt;li&gt;A Cosmic project and bucket in &lt;code&gt;auth_type: verified&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bucket &lt;code&gt;read_key&lt;/code&gt; and &lt;code&gt;write_key&lt;/code&gt;, ready to pass to the SDK&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;agent_key&lt;/code&gt; (&lt;code&gt;agk_...&lt;/code&gt;) for later status checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between signup and verify the bucket sits in an unclaimed state, which you pass straight through rather than stop in. While unclaimed it allows 50 objects and 5 MB of media, AI generation is off, and the project is hard-deleted after 14 days if nobody claims it. Verifying lifts all of that.&lt;/p&gt;

&lt;p&gt;Reference docs: &lt;a href="https://www.cosmicjs.com/docs/api/agents" rel="noopener noreferrer"&gt;Cosmic agent signup&lt;/a&gt; and the &lt;a href="https://www.agentmail.to/docs/quickstart" rel="noopener noreferrer"&gt;AgentMail quickstart&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different email addresses
&lt;/h2&gt;

&lt;p&gt;This is the part that trips people up, so settle it before touching any code. Two addresses are in play and they do different jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The AgentMail owner address.&lt;/strong&gt; A real mailbox on a normal domain, something like &lt;code&gt;you@yourdomain.com&lt;/code&gt;. You pass it as &lt;code&gt;human_email&lt;/code&gt; on &lt;code&gt;POST /v0/agent/sign-up&lt;/code&gt;, once, the first time this human creates an AgentMail account. AgentMail sends its own 6-digit OTP there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The agent inbox.&lt;/strong&gt; Created by that signup, in the form &lt;code&gt;username@agentmail.to&lt;/code&gt;. This is the address you hand to Cosmic as &lt;code&gt;human_email&lt;/code&gt;. Cosmic's claim email lands here, and the agent reads it with the AgentMail API key.&lt;/p&gt;

&lt;p&gt;Using one address for both jobs fails. AgentMail rejects &lt;code&gt;@agentmail.to&lt;/code&gt; as an owner address, and pointing Cosmic at your personal mailbox puts the claim code somewhere the agent cannot reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create the AgentMail inbox
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.agentmail.to/v0/agent/sign-up &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "human_email": "you@yourdomain.com",
    "username": "my-agent",
    "source": "cursor"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries &lt;code&gt;api_key&lt;/code&gt;, &lt;code&gt;inbox_id&lt;/code&gt;, and &lt;code&gt;organization_id&lt;/code&gt;. With &lt;code&gt;username: "my-agent"&lt;/code&gt; the inbox is &lt;code&gt;my-agent@agentmail.to&lt;/code&gt;, and &lt;code&gt;inbox_id&lt;/code&gt; is that same email address. Store the API key somewhere durable, because it is not shown again.&lt;/p&gt;

&lt;p&gt;AgentMail also emails a 6-digit OTP to &lt;code&gt;you@yourdomain.com&lt;/code&gt;. Submit it to unlock full permissions on the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.agentmail.to/v0/agent/verify &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AGENTMAIL_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "otp_code": "123456" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field here is &lt;code&gt;otp_code&lt;/code&gt;. Keep that in mind, because Cosmic's verify endpoint names the same concept differently in a later step.&lt;/p&gt;

&lt;p&gt;One observation from the September 22 run: the key returned by signup was already able to list inbox messages before this OTP was submitted. Do not build on that. AgentMail documents the key as limited until verification, so treat verifying as part of setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Errors you will actually hit
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@agentmail.to&lt;/code&gt; as the owner address&lt;/strong&gt; returns &lt;code&gt;403&lt;/code&gt; with &lt;code&gt;code: "forbidden"&lt;/code&gt; and the message &lt;code&gt;Domain is forbidden&lt;/code&gt;. The owner address has to be an ordinary mailbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disposable inbox domains&lt;/strong&gt; return the same &lt;code&gt;403&lt;/code&gt; and &lt;code&gt;forbidden&lt;/code&gt; code. Use a real domain you control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An address that already has an AgentMail account&lt;/strong&gt; returns &lt;code&gt;403&lt;/code&gt; with &lt;code&gt;code: "already_exists"&lt;/code&gt; and the message &lt;code&gt;User already exists&lt;/code&gt;, and no new API key comes back. Plus-aliases count as the same user, so &lt;code&gt;you+agent@yourdomain.com&lt;/code&gt; collides with &lt;code&gt;you@yourdomain.com&lt;/code&gt;. When the human already has an account, skip signup entirely: use their existing API key and create the mailbox with &lt;code&gt;POST /v0/inboxes&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Branch on the &lt;code&gt;code&lt;/code&gt; field rather than the message text. AgentMail documents those codes as stable and the prose as subject to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign up for Cosmic with the agent inbox
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://dapi.cosmicjs.com/v3/agents/sign-up &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "human_email": "my-agent@agentmail.to",
    "project_name": "Recipe Blog",
    "agent_id": "cursor"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;human_email&lt;/code&gt; has to be the AgentMail inbox. The owner's personal address belongs only to the AgentMail signup in the previous step.&lt;/p&gt;

&lt;p&gt;The response contains &lt;code&gt;agent_key&lt;/code&gt; (prefixed &lt;code&gt;agk_&lt;/code&gt;), &lt;code&gt;access_token&lt;/code&gt;, &lt;code&gt;auth_type: "unclaimed"&lt;/code&gt;, the &lt;code&gt;project&lt;/code&gt;, &lt;code&gt;bucket.slug&lt;/code&gt;, &lt;code&gt;bucket.read_key&lt;/code&gt;, &lt;code&gt;bucket.write_key&lt;/code&gt;, a &lt;code&gt;claim_url&lt;/code&gt;, and the &lt;code&gt;limits&lt;/code&gt; object. Persist the &lt;code&gt;agent_key&lt;/code&gt; and the bucket keys. If your sample code writes state to disk, keep those values out of anything you log.&lt;/p&gt;

&lt;p&gt;Cosmic then emails the agent inbox. The subject reads &lt;code&gt;An AI agent created a Cosmic project for you: "Recipe Blog"&lt;/code&gt;, and the plain-text body contains the line &lt;code&gt;Your one-time claim code is 123456 (expires in 15 minutes).&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two behaviors worth coding for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calling signup again with the same &lt;code&gt;human_email&lt;/code&gt; and &lt;code&gt;agent_id&lt;/code&gt; is idempotent. You get the same project back and a fresh code, which is the correct recovery path for an expired code.&lt;/li&gt;
&lt;li&gt;If that email already belongs to a human Cosmic account, the response is &lt;code&gt;409&lt;/code&gt; with &lt;code&gt;code: "user_already_exists"&lt;/code&gt; and a &lt;code&gt;claim_existing_url&lt;/code&gt;. Stop there and surface the URL. Retrying with a different email is the wrong move and leaves stray projects behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Read the claim code from the inbox
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.agentmail.to/v0/inboxes/my-agent@agentmail.to/messages?subject=Cosmic&amp;amp;limit=20"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AGENTMAIL_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;inbox_id&lt;/code&gt; path segment is the email address itself. The &lt;code&gt;subject&lt;/code&gt; query parameter does a substring match server side, so the agent can ask for the Cosmic message directly instead of scanning everything that arrives. Drop the filter if you would rather poll the whole list and match client side.&lt;/p&gt;

&lt;p&gt;Messages come back newest first, each with &lt;code&gt;message_id&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, and an optional &lt;code&gt;preview&lt;/code&gt;. In the live run the 6-digit code was sitting in &lt;code&gt;preview&lt;/code&gt;. When the preview is empty or cut short, fetch the single message at &lt;code&gt;GET /v0/inboxes/{inbox_id}/messages/{message_id}&lt;/code&gt; and read its &lt;code&gt;text&lt;/code&gt; body.&lt;/p&gt;

&lt;p&gt;Match on the labeled code rather than the first six digits on the page, because a timestamp or an ID can beat the real code to the regex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/claim code is&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(\d{6})&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code expires after 15 minutes. If your poll loop runs past that, call Cosmic signup again with the same email and &lt;code&gt;agent_id&lt;/code&gt; and read the fresh code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the Cosmic project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://dapi.cosmicjs.com/v3/agents/verify &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$COSMIC_AGENT_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "code": "123456" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cosmic names this field &lt;code&gt;code&lt;/code&gt;. AgentMail named the equivalent field &lt;code&gt;otp_code&lt;/code&gt; back in step one. Sending the wrong key is an easy mistake when both calls sit in the same script.&lt;/p&gt;

&lt;p&gt;A successful call returns &lt;code&gt;Verified. Restricted-mode limits lifted.&lt;/code&gt; along with &lt;code&gt;auth_type: "verified"&lt;/code&gt;. Confirm it independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://dapi.cosmicjs.com/v3/agents/status &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$COSMIC_AGENT_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth_type&lt;/code&gt; reads &lt;code&gt;verified&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt; comes back &lt;code&gt;null&lt;/code&gt;. The bucket is now on standard free-plan limits, AI generation included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the bucket
&lt;/h2&gt;

&lt;p&gt;From here it is an ordinary Cosmic bucket, with one step that trips people up. A bucket created by agent signup is empty. It has no Object types in it, and an Object cannot exist without a type to live in. So the agent models the content first, then writes to it.&lt;/p&gt;

&lt;p&gt;Pass the &lt;code&gt;slug&lt;/code&gt;, &lt;code&gt;read_key&lt;/code&gt;, and &lt;code&gt;write_key&lt;/code&gt; from the signup response to &lt;code&gt;createBucketClient&lt;/code&gt;. The write key is what authorizes both calls below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_WRITE_KEY&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the Object type. Only &lt;code&gt;title&lt;/code&gt; is required, and the slug defaults to the title converted to a slug. Declare the fields the agent intends to write in &lt;code&gt;metafields&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objectTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;singular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;📝&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metafields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the object write succeeds, because &lt;code&gt;type: 'posts'&lt;/code&gt; resolves to the type that exists and the &lt;code&gt;content&lt;/code&gt; key in &lt;code&gt;metadata&lt;/code&gt; matches a metafield on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello from an agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Written by an agent that signed itself up.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to handle if this runs unattended. Object type slugs are unique per bucket, so an agent that might run more than once should call &lt;code&gt;cosmic.objectTypes.find()&lt;/code&gt; first and create the type only when it is missing. And any key you set in &lt;code&gt;metadata&lt;/code&gt; has to exist as a metafield on the type, so adding a field later means a &lt;code&gt;cosmic.objectTypes.updateOne()&lt;/code&gt; call before the write rather than a new key in the object payload. The &lt;a href="https://www.cosmicjs.com/docs/api/object-types" rel="noopener noreferrer"&gt;Object types reference&lt;/a&gt; covers the full set of parameters, and the &lt;a href="https://www.cosmicjs.com/docs/api/metafields" rel="noopener noreferrer"&gt;Metafields reference&lt;/a&gt; lists every field type available.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.cosmicjs.com/docs/api/agents" rel="noopener noreferrer"&gt;agent signup docs&lt;/a&gt; cover the rest of the surface: refreshing &lt;code&gt;access_token&lt;/code&gt;, handing the &lt;code&gt;claim_url&lt;/code&gt; to a human when one is available, and the &lt;code&gt;402&lt;/code&gt; with &lt;code&gt;agent_unclaimed_limit&lt;/code&gt; that you hit if verification gets skipped and the agent keeps writing.&lt;/p&gt;

&lt;p&gt;The whole point of this setup is that the claim code never has to pass through a person. An agent that can receive email can provision its own content backend, verify it, and start writing, in one uninterrupted run.&lt;/p&gt;

&lt;p&gt;Ready to try it? &lt;a href="https://www.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=agentmail-agent-signup&amp;amp;utm_content=closing-cta" rel="noopener noreferrer"&gt;Start free&lt;/a&gt; or &lt;a href="https://www.cosmicjs.com/docs/api/agents" rel="noopener noreferrer"&gt;read the agent API docs&lt;/a&gt;. If you are wiring agents into a larger content operation, &lt;a href="https://calendly.com/tonyspiro/cosmic-intro" rel="noopener noreferrer"&gt;grab time with our CEO&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.cosmicjs.com/blog/agentmail-cosmic-agent-signup" rel="noopener noreferrer"&gt;Cosmic blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Moving a 1,700-page site to the Next.js 16 App Router without a sitemap hole</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Mon, 21 Sep 2026 17:13:22 +0000</pubDate>
      <link>https://dev.to/tonyspiro/moving-a-1700-page-site-to-the-nextjs-16-app-router-without-a-sitemap-hole-1lj3</link>
      <guid>https://dev.to/tonyspiro/moving-a-1700-page-site-to-the-nextjs-16-app-router-without-a-sitemap-hole-1lj3</guid>
      <description>&lt;p&gt;Most "we upgraded to Next.js 16" posts are version bumps with a changelog attached. This is not that. This is what it actually took to move cosmicjs.com, a 1,696-URL marketing and docs site backed by &lt;a href="https://www.cosmicjs.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=nextjs16-app-router-migration" rel="noopener noreferrer"&gt;Cosmic&lt;/a&gt;, from the Pages Router to the App Router, and the five problems that cost real time.&lt;/p&gt;

&lt;p&gt;None of them were in the migration guide. All five are things you will hit if your site is large, CMS-driven, and deployed on Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the site
&lt;/h2&gt;

&lt;p&gt;Worth setting the scale, because every problem below is a function of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,696 URLs in the sitemap&lt;/li&gt;
&lt;li&gt;686 blog posts and 243 daily Rundown issues, all Objects in Cosmic&lt;/li&gt;
&lt;li&gt;Comparison pages, solutions pages, templates, marketplace listings, integrations, and partners, also all CMS-driven&lt;/li&gt;
&lt;li&gt;MDX docs, and a &lt;code&gt;next-seo&lt;/code&gt; setup with &lt;code&gt;&amp;lt;NextSeo&amp;gt;&lt;/code&gt; calls scattered across a few hundred view components&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration ran as a coexistence first. The App Router and Pages Router happily share a repo, so we moved routes in batches and kept &lt;code&gt;pages/&lt;/code&gt; around for the stragglers. That part was uneventful. Then we cut over the rest, and the build stopped finishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the build hit Vercel's 45-minute cap
&lt;/h2&gt;

&lt;p&gt;The first full App Router production deploy ran for 46 minutes and then died. Not a crash, not a type error. It hit the ceiling.&lt;/p&gt;

&lt;p&gt;The cause was &lt;code&gt;generateStaticParams&lt;/code&gt;. Under the Pages Router, &lt;code&gt;getStaticPaths&lt;/code&gt; with &lt;code&gt;fallback: 'blocking'&lt;/code&gt; had been quietly doing the right thing for years. When we ported the route files, every dynamic segment got a &lt;code&gt;generateStaticParams&lt;/code&gt; that returned the full slug list, so the build tried to prerender roughly 1,700 pages, each one making its own Cosmic request, inside a 45-minute budget.&lt;/p&gt;

&lt;p&gt;The fix is an environment variable and a guard in every path loader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadPaths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GetStaticPaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SKIP_BUILD_STATIC_GENERATION&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...fetch every slug from Cosmic&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;SKIP_BUILD_STATIC_GENERATION=true&lt;/code&gt; set on Vercel, the build prerenders none of the CMS slug pages. Each one is generated on its first request and then cached, with &lt;code&gt;export const revalidate = 60&lt;/code&gt; on the route keeping it fresh. Build time drops from "does not finish" to a handful of minutes. The first visitor to a cold slug triggers the render and every visitor after that gets a cached page. We measured what that trade actually costs, further down.&lt;/p&gt;

&lt;p&gt;This is the right trade for a content site. It is the wrong trade if you have a hard requirement that every page is warm the instant a deploy finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: skipping the prerender quietly broke the sitemap
&lt;/h2&gt;

&lt;p&gt;Here is the part that would have been an SEO incident if we had shipped it without checking.&lt;/p&gt;

&lt;p&gt;We generate &lt;code&gt;sitemap.xml&lt;/code&gt; with &lt;code&gt;next-sitemap&lt;/code&gt; in &lt;code&gt;postbuild&lt;/code&gt;. &lt;code&gt;next-sitemap&lt;/code&gt; works from the Next.js route manifest. It lists what the build produced. So the moment we stopped prerendering the CMS pages, &lt;code&gt;next-sitemap&lt;/code&gt; stopped listing them. The sitemap would have gone from 1,696 URLs to 433, on a site where the blog is the top of the funnel.&lt;/p&gt;

&lt;p&gt;Nothing would have errored. The deploy would have looked perfect.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;additionalPaths&lt;/code&gt;. Rather than relying on the route manifest, we query Cosmic directly at &lt;code&gt;postbuild&lt;/code&gt; time and inject every published slug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;comparisons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;solutions&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;fetchAllObjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug,modified_at,metadata.last_updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;fetchAllObjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;categories&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug,id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isRundownSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/rundown/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;extraPaths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;last_updated&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modified_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;next-sitemap&lt;/code&gt; merges &lt;code&gt;additionalPaths&lt;/code&gt; by &lt;code&gt;loc&lt;/code&gt;, so this is safe whether or not the prerender ran. Turn &lt;code&gt;SKIP_BUILD_STATIC_GENERATION&lt;/code&gt; off for a full build and you get no duplicates.&lt;/p&gt;

&lt;p&gt;Two details that are easy to miss:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paginated listing routes disappear too.&lt;/strong&gt; &lt;code&gt;/blog/page/2&lt;/code&gt; through &lt;code&gt;/blog/page/76&lt;/code&gt; are prerendered pages, not CMS Objects, so they vanish along with everything else. We recompute them from the post count and the page size rather than hardcoding a number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn off &lt;code&gt;autoLastmod&lt;/code&gt;.&lt;/strong&gt; Stamping today's date on all 1,696 URLs on every deploy is a signal crawlers learn to discount. We set &lt;code&gt;autoLastmod: false&lt;/code&gt; and pull a real per-post &lt;code&gt;lastmod&lt;/code&gt; from the Cosmic Object's &lt;code&gt;modified_at&lt;/code&gt;, which means the dates in the sitemap now mean something.&lt;/p&gt;

&lt;p&gt;We also added a loud failure. If &lt;code&gt;SKIP_BUILD_STATIC_GENERATION&lt;/code&gt; is on and the Cosmic fetch returns nothing, the build logs an error instead of silently producing a short sitemap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SKIP_BUILD_STATIC_GENERATION&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;extraPaths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[sitemap] SKIP_BUILD_STATIC_GENERATION is on but no Cosmic CMS paths were fetched.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dry run injected 1,261 paths. The live sitemap now carries 1,263 CMS-driven URLs out of 1,696 total, which is the number that would have quietly disappeared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Turbopack compiled forever in production
&lt;/h2&gt;

&lt;p&gt;With the prerender skipped, the build still hung. Different place this time: "Creating an optimized production build" with no output and no error, until the cap.&lt;/p&gt;

&lt;p&gt;Turbopack is the default bundler for &lt;code&gt;next build&lt;/code&gt; in Next.js 16 and it is genuinely faster in development. On this repo, in Vercel's production build environment, the compile step never finished. Locally it was fine, which is the worst kind of bug.&lt;/p&gt;

&lt;p&gt;We did not root-cause it. We shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yarn generate-search-index &amp;amp;&amp;amp; yarn check-types &amp;amp;&amp;amp; next build --webpack"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webpack finished in minutes. &lt;code&gt;next dev&lt;/code&gt; still uses Turbopack, so we keep the fast feedback loop and give up nothing except a slightly slower CI build. If your production build hangs at compile with no diagnostic output, try &lt;code&gt;--webpack&lt;/code&gt; before you spend a day bisecting your own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: &lt;code&gt;draftMode()&lt;/code&gt; turned every blog post dynamic, then 500'd
&lt;/h2&gt;

&lt;p&gt;Cosmic's live preview needs the page to fetch draft content instead of published content. Under the Pages Router this came through &lt;code&gt;context.preview&lt;/code&gt;. The obvious App Router port is &lt;code&gt;draftMode()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This is the version that broke things&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pageLoaderArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;draftMode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isEnabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_PREVIEW_COOKIE&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;draftMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;draftMode()&lt;/code&gt; or &lt;code&gt;cookies()&lt;/code&gt; inside a route opts that route out of static rendering entirely. Every blog post became dynamic, ISR stopped applying, and several slugs started returning 500s in production because the loader was now running in a request context it did not expect.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking the canonical route whether it is in preview. Preview gets its own route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/preview/[...path]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catch-all resolves &lt;code&gt;blog&lt;/code&gt;, &lt;code&gt;rundown&lt;/code&gt;, &lt;code&gt;partners&lt;/code&gt;, &lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;templates&lt;/code&gt;, and marketplace slugs to the same loaders and views the canonical routes use, but runs them with &lt;code&gt;preview: true&lt;/code&gt; and returns &lt;code&gt;robots: { index: false, follow: false }&lt;/code&gt;. The canonical &lt;code&gt;/blog/[slug]&lt;/code&gt; route went back to a plain synchronous argument builder with no &lt;code&gt;cookies()&lt;/code&gt; call anywhere in its tree, which made it statically renderable again.&lt;/p&gt;

&lt;p&gt;The general rule: keep dynamic APIs out of the routes you want cached. If a feature needs per-request state, give it a separate route rather than making your entire content tree dynamic to serve a handful of editor sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: &lt;code&gt;next-seo&lt;/code&gt; went silent and took 400 page titles with it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next-seo&lt;/code&gt; is built on &lt;code&gt;next/head&lt;/code&gt;, which does nothing in the App Router. Not an error, not a warning. The component renders &lt;code&gt;null&lt;/code&gt; and your &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; quietly becomes the layout default.&lt;/p&gt;

&lt;p&gt;We had &lt;code&gt;&amp;lt;NextSeo&amp;gt;&lt;/code&gt; calls in a few hundred view components. Deleting them all as step one of the migration would have meant a very large diff with no way to verify anything in between. Instead we aliased the package to a no-op shim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/next-seo-app.tsx&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;noop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_props&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NextSeo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;noop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ArticleJsonLd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;noop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...every other export&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aliased in both &lt;code&gt;next.config.mjs&lt;/code&gt; (&lt;code&gt;turbopack.resolveAlias&lt;/code&gt; and the webpack &lt;code&gt;resolve.alias&lt;/code&gt;) and &lt;code&gt;tsconfig.json&lt;/code&gt; paths. Everything compiled, nothing crashed, and every page shipped the default title.&lt;/p&gt;

&lt;p&gt;Then we rebuilt metadata properly with &lt;code&gt;generateMetadata&lt;/code&gt;. The pattern that scaled across 100-plus route files is a single helper that runs the page's existing data loader, picks the SEO fields out of the result, and falls back safely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;metadataFromLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;defaultSeoPick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flattenParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fillPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;loaderArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;pageMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;titleFromPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;LAYOUT_DEFAULT_DESCRIPTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`generateMetadata failed for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;pageMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;titleFromPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LAYOUT_DEFAULT_DESCRIPTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route files become three lines, with a per-route &lt;code&gt;pick&lt;/code&gt; function for the shapes that differ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;metadataFromLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/blog/[slug]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;articleSeoPick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try/catch&lt;/code&gt; matters more than it looks. A metadata function that throws takes the whole page down. Falling back to a title derived from the path means a bad CMS field costs you a weak title, not a 500.&lt;/p&gt;

&lt;p&gt;JSON-LD needed the same treatment. &lt;code&gt;next-seo&lt;/code&gt;'s &lt;code&gt;ArticleJsonLd&lt;/code&gt; was now a no-op, and a &lt;code&gt;'use client'&lt;/code&gt; component that injects a script tag is not reliably visible to crawlers. We render it from the server component instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;JsonLd&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ld-article"&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;articleJsonLd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The thing that actually de-risked this
&lt;/h2&gt;

&lt;p&gt;We wrote a script that crawls every URL in the sitemap and records status code, &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, meta description, &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, canonical, and JSON-LD block count. Then we ran it three times: against production before the merge, against the Vercel preview, and against production after the deploy.&lt;/p&gt;

&lt;p&gt;Diffing those three files is what caught the compare pages falling back to a generic &lt;code&gt;Compare | Cosmic&lt;/code&gt; title instead of &lt;code&gt;Contentful vs Cosmic&lt;/code&gt;. It is what confirmed the four slugs that had been 500ing were fixed. It is what proved the sitemap still had all 1,696 URLs.&lt;/p&gt;

&lt;p&gt;It is about 40 lines of &lt;code&gt;curl&lt;/code&gt; and a parser. No Lighthouse run and no amount of clicking around the homepage would have found any of it, because every one of those problems lived on CMS slugs the homepage never links to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually bought us
&lt;/h2&gt;

&lt;p&gt;Numbers, because "it feels faster" is not a result. These come from the Vercel API across every production deployment of this project.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Successful builds&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages Router, Jul 20 to Sep 18&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;6.4 min&lt;/td&gt;
&lt;td&gt;6.0 min&lt;/td&gt;
&lt;td&gt;2.2 to 11.7 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Router, Sep 19 onward&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1.9 min&lt;/td&gt;
&lt;td&gt;2.0 min&lt;/td&gt;
&lt;td&gt;1.5 to 3.3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly a 70% cut in median build time. The window between those two rows is its own data point: four consecutive production builds at 27.1, 41.3, 44.8, and 46.2 minutes, every one of them cancelled or errored.&lt;/p&gt;

&lt;p&gt;Read that table carefully before quoting it. The Pages Router distribution is bimodal, clustering at 2 to 3 minutes and again at 6 to 8 minutes, which is almost certainly build cache hits against misses, so the median is the honest figure and the mean is not. The App Router sample is six builds over two days. And most of the gain comes from the ISR trade in Problem 1 rather than from the App Router itself. "Next.js 16 made our builds faster" would be the wrong conclusion to draw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sitemap held.&lt;/strong&gt; 1,696 URLs live, 1,263 of them CMS-driven. That second number is what &lt;code&gt;additionalPaths&lt;/code&gt; carries and what would otherwise have disappeared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime did not change, and we are not going to pretend it did.&lt;/strong&gt; Vercel keeps old deployments reachable at their unique URLs, so we measured the last Pages Router production deploy against current production on the same four routes. TTFB came back at 78 to 116 ms on the new build and 72 to 260 ms on the old. That is noise. Both serve CDN-cached HTML, so there was never a mechanism for the App Router to make it faster. Anyone promising you a speed win from this migration is selling something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ISR trade turned out to be free.&lt;/strong&gt; The stated risk in Problem 1 was that the first visitor to a cold slug pays for the render. We pulled two rarely-visited Rundown posts and got &lt;code&gt;x-vercel-cache: HIT&lt;/code&gt; and &lt;code&gt;STALE&lt;/code&gt; at 82 to 134 ms. Stale-while-revalidate serves the cached copy and regenerates behind it, so in practice nobody waits.&lt;/p&gt;

&lt;p&gt;What we cannot show yet is the measurement that matters most: Search Console impressions, clicks, and coverage before against after. That needs a few weeks of post-deploy data. If the sitemap work did its job, the correct result there is that nothing happens at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;If you are moving a large CMS-backed site to the App Router:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Budget your build.&lt;/strong&gt; Count how many pages &lt;code&gt;generateStaticParams&lt;/code&gt; will prerender and multiply by your CMS round-trip. If that number approaches your platform's build cap, skip the prerender and lean on ISR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your sitemap comes from the route manifest, not from reality.&lt;/strong&gt; The moment you stop prerendering, check what &lt;code&gt;next-sitemap&lt;/code&gt; actually emitted. Query your CMS for &lt;code&gt;additionalPaths&lt;/code&gt; and fail the build loudly if it comes back empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the production build hangs at compile, try &lt;code&gt;--webpack&lt;/code&gt;.&lt;/strong&gt; Keep Turbopack for &lt;code&gt;next dev&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;cookies()&lt;/code&gt; and &lt;code&gt;draftMode()&lt;/code&gt; out of cacheable routes.&lt;/strong&gt; Preview belongs on its own &lt;code&gt;force-dynamic&lt;/code&gt; route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume &lt;code&gt;next-seo&lt;/code&gt; is doing nothing.&lt;/strong&gt; Alias it to a no-op so you can migrate incrementally, then verify with a crawl rather than a spot check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff the crawl, not the vibes.&lt;/strong&gt; Before, preview, after.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The site is live on Next.js 16 with the App Router, builds in minutes, and did not lose a URL. The content still lives in Cosmic, which is the part that made the sitemap fix possible: when your content model is queryable over an API, "list every URL this site should have" is a question you can answer at build time, independent of whatever your framework decided to prerender.&lt;/p&gt;

&lt;p&gt;If you want to see the setup, &lt;a href="https://www.cosmicjs.com/docs/frameworks#next-js" rel="noopener noreferrer"&gt;the Cosmic Next.js docs&lt;/a&gt; cover the SDK patterns used here, and &lt;a href="https://www.cosmicjs.com/changelog/live-preview-see-drafts-on-your-real-site" rel="noopener noreferrer"&gt;live preview&lt;/a&gt; is the feature behind the preview route.&lt;/p&gt;

&lt;p&gt;Cosmic is an AI-powered headless CMS with a REST API, TypeScript SDK, and AI agents. &lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=nextjs16-app-router-migration" rel="noopener noreferrer"&gt;Start free, no credit card required&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>seo</category>
      <category>react</category>
    </item>
    <item>
      <title>Automating Editorial Triage: Routing Drafts Across 13 Properties with Cosmic and TypeSafe</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:09:31 +0000</pubDate>
      <link>https://dev.to/tonyspiro/automating-editorial-triage-routing-drafts-across-13-properties-with-cosmic-and-typesafe-2mac</link>
      <guid>https://dev.to/tonyspiro/automating-editorial-triage-routing-drafts-across-13-properties-with-cosmic-and-typesafe-2mac</guid>
      <description>&lt;p&gt;Draft-only publishing is the right default for any editorial team large enough that no single person sees everything. Nothing reaches the public until an editor approves it. The policy is easy to adopt, and it creates a new problem on day one: every draft now needs a person, and people read in the order things arrive rather than the order that matters.&lt;/p&gt;

&lt;p&gt;Drafts can arrive from everywhere. Staff writers, section editors, freelancers filing against a brief, contributors who write for you twice a year, syndicated copy, and increasingly AI tools drafting alongside the team. The volume problem is the same whatever the source, and it shows up long before anyone adopts an agent.&lt;/p&gt;

&lt;p&gt;For one team running multiple web properties, that queue stops being readable within a week. Our &lt;a href="https://www.cosmicjs.com/blog/content-governance-at-scale" rel="noopener noreferrer"&gt;content governance at scale&lt;/a&gt; piece argued the policy. This one builds the mechanism that makes the policy survive volume.&lt;/p&gt;

&lt;p&gt;The approach: Cosmic holds the content, the draft state and the roles. A classification model decides which drafts a human actually has to open, and in what order. Below is the architecture, working code against both TypeScript SDKs, and the accuracy we measured on a real corpus, including the parts that did not work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Jev does, and what it does not do
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.typesafe.ai/introduction" rel="noopener noreferrer"&gt;TypeSafe&lt;/a&gt; ships a model called Jev that answers typed questions about a block of text. You send content as &lt;code&gt;state&lt;/code&gt; along with a map of questions, and you get structured answers back. There are three question types, documented on their &lt;a href="https://docs.typesafe.ai/api" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; (read September 20, 2026):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Noul&lt;/strong&gt; returns the probability that a yes/no question is yes, as a number from 0 to 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choice&lt;/strong&gt; picks one option from a set you define, and returns the full probability distribution plus a &lt;code&gt;confidence&lt;/code&gt; value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score&lt;/strong&gt; rates content against an ordered rubric you define, from two to ten levels, and also returns &lt;code&gt;confidence&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Jev generates no prose and writes no code. It classifies and scores. That constraint is what makes it useful here, because editorial triage is mostly a classification job: decide what each draft is, then decide who needs to read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A draft is created in Cosmic by a staff writer, a freelancer filing against a brief, an occasional contributor, a syndication feed, or an agent. The pipeline does not care which.&lt;/li&gt;
&lt;li&gt;A webhook fires to your route handler.&lt;/li&gt;
&lt;li&gt;The handler sends the draft body to Jev with every question attached to a single request.&lt;/li&gt;
&lt;li&gt;Confidence decides what happens next: high-confidence answers write straight back to the object, low-confidence answers route to a human.&lt;/li&gt;
&lt;li&gt;The editor opens a queue that is already sorted, categorised and flagged.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The draft never publishes itself. Everything below only decides &lt;strong&gt;who looks at it and when&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: write the questions, and take criteria seriously
&lt;/h2&gt;

&lt;p&gt;This is the part that decides whether the whole thing works. Every question type accepts an optional or required &lt;code&gt;criteria&lt;/code&gt; field, and the quality of your criteria matters more than the wording of your instructions.&lt;/p&gt;

&lt;p&gt;Here is the question set we ran, targeting a fictional media group with thirteen consumer titles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;the draft body&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"property"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which of our titles should publish this draft?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"switchback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cycling: road, gravel, commuting, bike gear and maintenance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"field_and_forge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Outdoor gear: hiking, camping, trail running, packs and footwear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"the_marrow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Food: recipes, technique, restaurants, ingredient deep dives"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"marrow_dispatch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Marrow's newsletter: short, single-subject, first-person food writing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ledger_lane"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Personal finance: saving, borrowing, tax, consumer money decisions"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"voice_fit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rate how well this matches our editorial voice."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Promotional or hyped. Superlatives without evidence, marketing register, reads like copy written to sell."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Flat or generic. Accurate but characterless, could have come from any publication."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Plain, specific and first-hand. Concrete detail, measured claims, an identifiable point of view."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"unverified_claim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does this draft state a factual claim without attributing it to a source?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Contains a statistic, price, comparative claim or third-party assertion with no named source."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Every factual claim is attributed, or the piece is explicitly first-hand or opinion."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"What kind of piece is this?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"news_report"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Reports something that happened, with dates, named sources or quotes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"how_to_guide"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Instructional. Steps, technique or maintenance the reader is meant to follow."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"product_review"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Assesses named products against criteria and reaches a verdict."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"opinion_column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Argues a position in an identifiable writer's voice."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"roundup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A list of products, places or picks grouped by a theme."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"undisclosed_promotion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does this draft promote a product, brand or sponsor without disclosing the relationship?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Promotional intent with no visible disclosure of sponsorship, gifting or affiliate links."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Either no promotional intent, or the relationship is disclosed in the draft."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"structural_completeness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rate how complete this draft is as a finished piece."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Truncated or fragmentary. Ends mid-thought, or whole sections are missing."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Drafted but unfinished. Complete thoughts, with visible gaps or placeholders."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Complete. An opening, a body and an ending that lands."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the shape, because it is easy to get wrong. &lt;code&gt;questions&lt;/code&gt; is a &lt;strong&gt;map keyed by ids you choose&lt;/strong&gt;, and your answers come back under those same keys. Choice &lt;code&gt;criteria&lt;/code&gt; is a map of option to rubric description. Score &lt;code&gt;criteria&lt;/code&gt; is an &lt;strong&gt;ordered array&lt;/strong&gt; of level descriptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The single most useful thing we learned:&lt;/strong&gt; rubrics transformed the score question. Running voice fit with instructions alone, the model returned values of 1.64, 1.58 and 1.95 on a three-level scale, at confidence between 0.37 and 0.45. Those numbers are unusable, and we were ready to drop the question. Adding a described level for every step, exactly as written above, moved the same question to correct answers at confidence 0.94 and above. Same model, same task, same inputs.&lt;/p&gt;

&lt;p&gt;If a question is behaving badly, write better criteria before you conclude the model cannot do the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: one call, every question
&lt;/h2&gt;

&lt;p&gt;TypeSafe's &lt;a href="https://docs.typesafe.ai/patterns/fan-out" rel="noopener noreferrer"&gt;speculative fan-out pattern&lt;/a&gt; documents that questions in a request are evaluated in parallel, so adding more of them does not typically add latency. Combined with output tokens being free on Jev, asking six questions about a draft costs close to what asking one costs.&lt;/p&gt;

&lt;p&gt;That changes how you design the question set. Ask everything you might want, including questions that only matter sometimes, and ignore the irrelevant answers in code.&lt;/p&gt;

&lt;p&gt;Here is the handler. Both SDKs are TypeScript, so this is one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./questions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the question map from Step 1&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_WRITE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TYPESAFE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.typesafe.ai/v1/systemone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TYPESAFE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TYPESAFE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jev-latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`TypeSafe &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;objectId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;objectId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id,title,slug,metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeBack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Jev accepts 64k tokens per request across state and all questions,&lt;/span&gt;
&lt;span class="c1"&gt;// and 32k for state plus the longest single question. Long-form bodies&lt;/span&gt;
&lt;span class="c1"&gt;// need trimming before they go in.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`TITLE: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markdown_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A response comes back keyed by your question ids, with a &lt;code&gt;usage&lt;/code&gt; object carrying &lt;code&gt;input_tokens&lt;/code&gt; and &lt;code&gt;output_tokens&lt;/code&gt;. TypeSafe also publishes &lt;a href="https://docs.typesafe.ai/sdk" rel="noopener noreferrer"&gt;client SDKs&lt;/a&gt; that handle retries automatically with their default retry policy. If you call the HTTP API with raw &lt;code&gt;fetch&lt;/code&gt; as above, add your own backoff for the documented &lt;a href="https://docs.typesafe.ai/models" rel="noopener noreferrer"&gt;429 rate limit responses&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: gate on confidence
&lt;/h2&gt;

&lt;p&gt;This is where classification becomes a workflow. Choice and Score answers carry a &lt;code&gt;confidence&lt;/code&gt; value derived from the probability distribution. Above your threshold, act automatically. Below it, hand the draft to a person.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unverified_claim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Property unclear: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Content type unclear: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unverified_claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noul&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unverified_claim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Possible unsourced factual claim&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choice&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flagged reasons matter as much as the flag. An editor who sees "cycling 0.78, outdoor gear 0.21" knows exactly what decision is being asked of them, and can make it in two seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: write the answer back
&lt;/h2&gt;

&lt;p&gt;The result lands on the Cosmic object, so the queue an editor opens is already organised:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeBack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;property&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;voice_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voice_fit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;triage_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;needs_review&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto_filed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;triage_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; | &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;needsHuman&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;property&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The object stays in draft. Nothing here publishes anything, and the human approval step is untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we measured
&lt;/h2&gt;

&lt;p&gt;We built a corpus of 18 drafts across 13 fictional properties and five content types, with defects deliberately seeded on known labels so there was something real to score against. Two of the thirteen properties were newsletter microsites that overlap their parent titles, included specifically to make routing harder. Every draft went through one request carrying the full question set: 18 calls, 102 individual judgments, all HTTP 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Property routing: 18 of 18 correct.&lt;/strong&gt; Twelve came back at confidence 0.95 or above, including both newsletter traps. The finance newsletter resolved to the newsletter over its parent title at 0.84 against 0.16. The food newsletter resolved at 0.98 against 0.02.&lt;/p&gt;

&lt;p&gt;With the gate at 0.80, &lt;strong&gt;16 of 18 auto-filed with zero incorrect auto-files&lt;/strong&gt;, and 2 routed to a human. Both of those were genuinely ambiguous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fell-running shoe review: cycling title 0.78, outdoor title 0.21.&lt;/li&gt;
&lt;li&gt;A festival food guide: events title 0.46, food title 0.39, food newsletter 0.15, confidence 0.41.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are exactly the two a human should see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Voice fit: 18 of 18 correct&lt;/strong&gt;, confidence 0.71 or above throughout. An affiliate-spam draft scored 0.00 at confidence 1.00. This is the question that was unusable before rubrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content type: 13 of 18 correct&lt;/strong&gt;, and every single disagreement arrived below 0.55 confidence. Set that gate at 0.60 and everything passing the gate was right. As a demonstration of confidence gating, this is cleaner than the routing result, because the model reliably flagged its own errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undisclosed promotion: 12 of 12.&lt;/strong&gt; A sponsored hotel review that discloses the sponsorship in its first line scored 0.05. An undisclosed affiliate roundup scored 0.94. We dropped this question from the last batch to keep payloads small, so the sample is 12 drafts rather than 18.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it was weak
&lt;/h2&gt;

&lt;p&gt;Publishing only the wins would make this useless to you, so here are the three limitations we hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confidence catches genre ambiguity and misses topical overlap.&lt;/strong&gt; We built a batch specifically to break routing, using drafts that legitimately belong to two titles. It hedged on two and answered confidently on four where a second title had a real claim. An electric-vehicle charger payback analysis, which is half personal finance, returned its energy title at confidence 1.00 with the finance title at exactly 0. Do not use these probabilities to find cross-posting candidates. They answer "which one" and not "how many."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The unsourced-claim question tracks claim density more than sourcing.&lt;/strong&gt; Across twelve clean drafts it ranged from 0.11 to 0.73. A pure-opinion column containing no factual claims at all scored 0.11, while a fully sourced council report scored 0.59. Separation still held: every seeded-defect draft landed between 0.88 and 0.99, every clean draft stayed at or below 0.73, and a 0.80 gate was correct on all 18. Gate on the separation you observe in your own corpus rather than trusting the absolute value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structural completeness was the least reliable.&lt;/strong&gt; It caught a deliberately truncated guide at 0.03 with confidence 0.97, and it returned confidence 0.00 twice on drafts that are complete and simply end on a short line. Two zero-confidence outputs in 18 is worth knowing before you gate anything important on it.&lt;/p&gt;

&lt;p&gt;TypeSafe publishes a &lt;a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" rel="noopener noreferrer"&gt;jaggedness page&lt;/a&gt; for the model, which is an honest signal from a vendor and a reminder to measure on your own content before trusting any question in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Jev is priced at $0.042 per million input tokens with output tokens free (&lt;a href="https://docs.typesafe.ai/models" rel="noopener noreferrer"&gt;TypeSafe models&lt;/a&gt;, read September 20, 2026). Our run consumed 22,416 input tokens across 18 calls and returned 4,656 output tokens that were not billed.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;$0.00094 for the whole corpus&lt;/strong&gt;, roughly $0.000052 per draft, or about 19,000 classified drafts per dollar. Cost is not the constraint on this design. Treat these figures as an illustration from one small run rather than a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it without webhooks
&lt;/h2&gt;

&lt;p&gt;Webhooks on Cosmic are a $99/month per-project add-on, or $199/month bundled with Localization, Revision History and Automatic Backups (&lt;a href="https://www.cosmicjs.com/pricing" rel="noopener noreferrer"&gt;Cosmic pricing&lt;/a&gt;, read September 20, 2026). You do not need them to try this.&lt;/p&gt;

&lt;p&gt;Poll for untriaged drafts on a schedule instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;objects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;articles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata.triage_status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id,title,metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeBack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same classification, same write-back, no add-on required. Move to webhooks when the latency starts to bother you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves the review queue
&lt;/h2&gt;

&lt;p&gt;The editor still approves everything. What changed is what they open: a queue where most drafts are already filed against the right property, scored for voice, and where the handful carrying a possible unsourced claim are at the top with the reason attached.&lt;/p&gt;

&lt;p&gt;For a team running five to twenty properties, that difference decides whether draft-only publishing is a policy you can hold or one you quietly abandon.&lt;/p&gt;

&lt;p&gt;Cosmic gives you the content model, the draft state, the roles and the API to build this. &lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=typesafe-jev-triage" rel="noopener noreferrer"&gt;Start free&lt;/a&gt;, read the &lt;a href="https://www.cosmicjs.com/docs" rel="noopener noreferrer"&gt;API and SDK docs&lt;/a&gt;, or &lt;a href="https://calendly.com/tonyspiro/cosmic-intro" rel="noopener noreferrer"&gt;book a walkthrough&lt;/a&gt; if you want to talk through the architecture for your own properties.&lt;/p&gt;

&lt;p&gt;Teams running this across many properties at once are who the &lt;a href="https://www.cosmicjs.com/pricing" rel="noopener noreferrer"&gt;Cosmic Workspace plans&lt;/a&gt; are built for: several projects, one team, one bill.&lt;/p&gt;

&lt;p&gt;If you are connecting AI tools to your content more broadly, the &lt;a href="https://www.cosmicjs.com/mcp-server" rel="noopener noreferrer"&gt;Cosmic MCP server&lt;/a&gt; is the other half of this story.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cosmicjs.com/blog/automating-editorial-triage-typesafe-cosmic" rel="noopener noreferrer"&gt;the Cosmic blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Content Durability: How to Get Everything Out of Your CMS</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:09:38 +0000</pubDate>
      <link>https://dev.to/tonyspiro/content-durability-how-to-get-everything-out-of-your-cms-2693</link>
      <guid>https://dev.to/tonyspiro/content-durability-how-to-get-everything-out-of-your-cms-2693</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.cosmicjs.com/blog/content-durability-cms-export-path" rel="noopener noreferrer"&gt;Cosmic blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every CMS decision is a bet on where your content will live for the next five years. The part teams skip when they make that bet is the exit: what happens if you need to move, and what shape the content arrives in when it gets there.&lt;/p&gt;

&lt;p&gt;Content durability is the property that makes that question boring. Your content is durable when you can retrieve all of it, in a structured format, on demand, without filing a support ticket, and rebuild it somewhere else. Most teams assume they have this and find out they do not on the week they need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup, revision history, and export solve three different failures
&lt;/h2&gt;

&lt;p&gt;These get conflated constantly, and the confusion is expensive because each one covers a failure the others do not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backup&lt;/strong&gt; is a point-in-time restore back into the same system. It covers "we broke production at 3pm."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revision history&lt;/strong&gt; is a per-object change log inside the system. It covers "someone rewrote the pricing paragraph and we want the old one."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; is a complete, structured copy that leaves the system. It covers "we are migrating," "legal needs an archive," and "the vendor changed terms."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A backup does not help you leave. An export does not help you recover a paragraph from yesterday. If you only have one of the three, you have a gap, and it is usually the export.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "structured" actually has to mean
&lt;/h2&gt;

&lt;p&gt;An export is only useful if the thing you get back can be rebuilt without a human reading it. Six properties matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JSON, not rendered pages.&lt;/strong&gt; Page HTML is a lossy projection of your content. Field boundaries, types, and relationships are gone the moment it renders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The schema, not just the values.&lt;/strong&gt; Field keys, types, select options, required rules, and validation are what let you recreate the model somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable IDs.&lt;/strong&gt; If identifiers are regenerated on export, every relationship in your content becomes a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relationships as references.&lt;/strong&gt; A post that points at an author and three tags should export those pointers, not flatten them into strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media you can actually retrieve.&lt;/strong&gt; File references are worthless without reachable originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything, not just what is live.&lt;/strong&gt; Drafts, scheduled items, and locale variants are content too.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Path 1: the dashboard export
&lt;/h2&gt;

&lt;p&gt;Every Cosmic Bucket can export its content as a JSON file from Bucket &amp;gt; Settings &amp;gt; Import / Export. The export includes Object types, Objects, Media, and folders, which covers the schema and the values in one file. It does not include team members, Object revisions, or backups. That scope is documented on the &lt;a href="https://www.cosmicjs.com/docs/dashboard/buckets" rel="noopener noreferrer"&gt;Buckets documentation page&lt;/a&gt;, and the same screen imports a JSON file back into a Bucket, which is how you clone an environment or seed a fresh one.&lt;/p&gt;

&lt;p&gt;This is the fastest way to answer the durability question for yourself right now. Run the export, open the file, and check that the six properties above are present. That takes about five minutes and tells you more than any vendor claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 2: the API on a schedule
&lt;/h2&gt;

&lt;p&gt;A manual export is a snapshot. A scheduled export is an insurance policy. The &lt;a href="https://www.cosmicjs.com/docs" rel="noopener noreferrer"&gt;REST API&lt;/a&gt; and the &lt;a href="https://www.npmjs.com/package/@cosmicjs/sdk" rel="noopener noreferrer"&gt;TypeScript SDK&lt;/a&gt; let you write the same data to your own storage on a cron, so a current copy always lives somewhere you control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;objects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id,slug,title,type,status,locale,metadata,created_at,modified_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;any&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`./export/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;exportType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Exported &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; objects`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make the difference between a real export and a partial one. &lt;code&gt;status('any')&lt;/code&gt; includes drafts, which a default read leaves out. Requesting &lt;code&gt;metadata&lt;/code&gt; explicitly keeps every custom field rather than the summary props.&lt;/p&gt;

&lt;p&gt;Commit the output to a private repository or push it to object storage, and you get version history on your content for free. Once your object counts run into the thousands, switch the loop to cursor pagination with &lt;code&gt;after&lt;/code&gt;, which the API supports and which is more efficient than paging with large &lt;code&gt;skip&lt;/code&gt; offsets.&lt;/p&gt;

&lt;p&gt;A read key is enough for this job. An export process never needs write access, and keeping the two keys separate is the cheapest safety measure available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 3: the CLI
&lt;/h2&gt;

&lt;p&gt;For scripting against a terminal rather than an application, the &lt;a href="https://www.cosmicjs.com/docs/cli" rel="noopener noreferrer"&gt;Cosmic CLI&lt;/a&gt; reads objects, types, and media and can emit JSON for piping into other tools. It is the shortest route to a one-off dump inside a shell script or a CI job, without standing up a Node project first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people forget: media
&lt;/h2&gt;

&lt;p&gt;Content exports usually succeed. Media migrations are where projects stall, because a JSON file full of CDN URLs is not the same thing as owning the files.&lt;/p&gt;

&lt;p&gt;Build the download step into your export job: walk the media references, fetch each original, and store it alongside the JSON. Do that once and your archive is genuinely self-contained.&lt;/p&gt;

&lt;p&gt;Two behaviors are worth knowing while you plan this. Replacing a media file keeps the same URL and id, so every reference in your content stays valid after the swap. Deleting media purges it from the CDN and does not update the references pointing at it, so a delete can leave broken links behind in objects you have forgotten about. Audit media before a migration, not after.&lt;/p&gt;

&lt;p&gt;If you are relying on transformations rather than storing multiple renditions, the derivative sizes are generated at request time from the original. Keeping the originals is what preserves your ability to regenerate everything later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups and revisions, since export does not cover them
&lt;/h2&gt;

&lt;p&gt;Because the JSON export explicitly excludes revisions and backups, treat those as separate controls, and budget for them. Automatic Backups and Revision History are both add-ons on Cosmic, $99/month each, or $199/month for the bundle that also covers Webhooks and Localization. With Automatic Backups enabled, a backup runs daily, and you can take a snapshot on demand, download it, or restore from it. Revision history is tracked per object.&lt;/p&gt;

&lt;p&gt;The practical setup for most teams: backups on for recovery, revisions for editorial mistakes, and a scheduled JSON export for durability. Three controls, three failures, no overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the drill once a quarter
&lt;/h2&gt;

&lt;p&gt;An export you have never restored is a hypothesis. Turn it into a fact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run a full export, dashboard or API, whichever your process uses.&lt;/li&gt;
&lt;li&gt;Download every referenced media original into the same archive.&lt;/li&gt;
&lt;li&gt;Import the JSON into a clean Bucket, or stand up a local database from it.&lt;/li&gt;
&lt;li&gt;Point a staging build at the restored data and load ten pages, including one with relationships and one with media.&lt;/li&gt;
&lt;li&gt;Write down what broke and how long it took.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The acceptance test is simple: could a developer who has never seen your setup rebuild a working staging site from the archive alone, in a day, with no access to the original account? If the answer is no, you have found the gap while it is cheap to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven questions to ask any CMS before you sign
&lt;/h2&gt;

&lt;p&gt;Bring these to the evaluation call and write down the answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can I export all content myself, without contacting support?&lt;/li&gt;
&lt;li&gt;Is the export structured JSON, and does it include the schema as well as the values?&lt;/li&gt;
&lt;li&gt;Are object IDs stable across export and import?&lt;/li&gt;
&lt;li&gt;Do relationships export as references I can resolve?&lt;/li&gt;
&lt;li&gt;Are drafts, scheduled content, and locale variants included?&lt;/li&gt;
&lt;li&gt;Can I retrieve every media original programmatically?&lt;/li&gt;
&lt;li&gt;Can the whole thing run on a schedule through the API?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A vendor that answers all seven cleanly is one you can leave, which is exactly why you probably will not need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Cosmic stands
&lt;/h2&gt;

&lt;p&gt;Content in Cosmic is stored as structured Objects with a defined content model, reachable through the REST API and the TypeScript SDK, with a full JSON import and export in the dashboard and a CLI for scripting. Nothing about the format requires our software to read it. That is the standard worth holding every &lt;a href="https://www.cosmicjs.com/headless-cms" rel="noopener noreferrer"&gt;headless CMS&lt;/a&gt; to, including this one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.cosmicjs.com/signup?utm_source=dev.to&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-content&amp;amp;utm_content=content-durability-cms-export-path"&gt;Start free with a Cosmic account&lt;/a&gt;, no credit card required.&lt;/p&gt;

</description>
      <category>cms</category>
      <category>api</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>When Agents Outnumber Editors</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Mon, 24 Aug 2026 22:39:53 +0000</pubDate>
      <link>https://dev.to/tonyspiro/when-agents-outnumber-editors-79p</link>
      <guid>https://dev.to/tonyspiro/when-agents-outnumber-editors-79p</guid>
      <description>&lt;p&gt;There is a ratio inside every content team that nobody tracks: how many things get written by people versus how many get written by software. For most teams that number was zero for a long time. Then it was a rounding error. Now, on a lot of teams, it has quietly crossed one to one.&lt;/p&gt;

&lt;p&gt;Once agents outnumber editors, the shape of the job changes. Drafts become cheap and abundant. The scarce resource is the judgment required to decide what deserves to ship. If your workflow was built around the old bottleneck, it will break in a specific and predictable way: a queue of plausible-looking drafts nobody has time to verify.&lt;/p&gt;

&lt;p&gt;The mechanics of what an agent is allowed to touch are a separate subject, and we covered those in &lt;a href="https://www.cosmicjs.com/blog/ai-agent-write-access-cms-controls" rel="noopener noreferrer"&gt;AI Agent Write Access: The 4 Real Controls You Get With Your CMS&lt;/a&gt;, including the two controls we do not ship yet. This post is about what happens upstream of any of those settings: how the daily work of a content team reorganizes itself once the ratio flips, and which four operating habits have to change with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ratio flips faster than the process does
&lt;/h2&gt;

&lt;p&gt;A two-person content team with four agents running scheduled jobs can produce more drafts in a week than the two humans can carefully read. Reading a 1,500-word post closely, checking its claims against primary sources, and verifying its links takes real time. Generating it takes minutes.&lt;/p&gt;

&lt;p&gt;So the throughput ceiling moves from writing to reviewing, and every process decision should follow that move. The goal is to make review fast and to make bad output structurally hard to publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift 1: scope becomes a commissioning decision
&lt;/h2&gt;

&lt;p&gt;The most common mistake is giving one agent broad access to everything and hoping the prompt holds. Prompts drift. Keys do not.&lt;/p&gt;

&lt;p&gt;When you spin up a new agent, treat its scope the way you would treat a job description rather than a configuration screen you fill in afterward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write down the object types each agent is expected to touch, and put that list in its instructions. A social copy agent has no reason to touch your pricing page object, and it should be told so explicitly.&lt;/li&gt;
&lt;li&gt;Default every agent to draft output. Publishing should be a separate, deliberate action taken by someone accountable for it.&lt;/li&gt;
&lt;li&gt;Separate read keys from write keys in your environment. An agent that only summarizes traffic never needs a write key at all.&lt;/li&gt;
&lt;li&gt;Keep destructive operations off the table unless a human explicitly asks for them in that session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Be clear with yourself about which of these are enforced and which are conventions. In Cosmic today, the enforced boundary is the key an agent holds: read-only or read-write across the bucket. There is no per-object-type permission setting, so "this agent only touches blog posts" is an instruction you give and a habit you audit, not a rule the platform enforces for you. That is exactly why the read-key/write-key split matters so much, and why draft-by-default carries real weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift 2: review becomes the throughput ceiling
&lt;/h2&gt;

&lt;p&gt;Draft status is the cheapest safety mechanism you have. It costs nothing and it catches everything. The habit that has to change is calendar-level: review is now the work you schedule your week around, rather than a step you tack onto the end of someone else's.&lt;/p&gt;

&lt;p&gt;That means the review queue has to be a real surface your team sees without remembering to check the dashboard. Pull it with the &lt;a href="https://www.npmjs.com/package/@cosmicjs/sdk" rel="noopener noreferrer"&gt;TypeScript SDK&lt;/a&gt; and render it wherever your team already works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id,title,slug,status,created_at,metadata.author&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;any&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reviewQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Approval is then an explicit write, with a key that only your review surface holds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_WRITE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;objectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;last_updated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look. First, read the object back after a write and confirm the field actually stored the value you sent. A write that reports success while dropping a field is the kind of bug that ships a post with an empty meta description. Second, use explicit object IDs for relationship fields such as tags and category. Slugs can collide across object types and resolve to the wrong record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift 3: attribution becomes a weekly number
&lt;/h2&gt;

&lt;p&gt;When five agents and two humans all touch the same bucket, "who wrote this" turns into an operational question you have to answer quickly. You need it to debug a bad claim, to retire an underperforming agent, and to answer the inevitable question about how much of the site is machine-written.&lt;/p&gt;

&lt;p&gt;Cosmic Insights attributes traffic by the actor who created the underlying object, splitting results across human users, agents, and automations. That answers a question most teams cannot answer at all: is the agent-authored content earning attention, or just filling the index?&lt;/p&gt;

&lt;p&gt;Run that report before you scale an agent up, and put it on the same cadence as your traffic review. An agent producing thirty posts a month that collectively earn less traffic than three human posts is a cost, and the fix is usually narrower scope rather than more volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift 4: the content model does the editing you no longer have time for
&lt;/h2&gt;

&lt;p&gt;The content model is the only guardrail that applies to every writer equally, human or machine. A required field is required for everyone. A select field with five options cannot be answered with a sixth. Every rule you move into the model is a review comment you never have to write again.&lt;/p&gt;

&lt;p&gt;Things worth encoding in the model rather than in a prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Required SEO fields.&lt;/strong&gt; If &lt;code&gt;seo_title&lt;/code&gt; and &lt;code&gt;seo_description&lt;/code&gt; are required, no agent can ship a post without them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Character limits.&lt;/strong&gt; A &lt;code&gt;maxlength&lt;/code&gt; on the meta description enforces the limit at write time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select options in place of free text.&lt;/strong&gt; Category, content type, and funnel stage should be closed sets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A verification field.&lt;/strong&gt; For anything that cites a third party, a required &lt;code&gt;last_verified&lt;/code&gt; date makes staleness visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required relationships.&lt;/strong&gt; An author reference on every post means attribution is always captured.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these turns a review comment you would otherwise write by hand into a validation error the agent has to resolve before the object saves. Given that write access is bucket-wide rather than scoped per type, the model is doing more enforcement work than most teams realize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes to watch for
&lt;/h2&gt;

&lt;p&gt;Four patterns show up repeatedly once agent volume rises:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Near-duplicate content.&lt;/strong&gt; Two agents, or the same agent on two runs, produce posts that target the same query. They then compete with each other in search. Search your own bucket before commissioning anything new.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidently stale third-party claims.&lt;/strong&gt; An agent reuses a competitor price or feature limit from an older draft. The rule that fixes this is simple: any claim about another product must be re-verified against that vendor's live page in the same run that writes the sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invented specificity.&lt;/strong&gt; Round numbers, benchmark figures, and customer counts that no source supports. Require a link next to every number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift from the house voice.&lt;/strong&gt; Individually fine, collectively recognizable as machine output. Keep an explicit list of banned constructions and check drafts against it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fifth one is worth naming because it caught us while writing this post: an agent describing your own product's capabilities from memory instead of from current documentation. The first version of this article claimed Cosmic could scope an agent's write access to a single object type. It cannot. Apply the same verification rule to your own product that you apply to competitors.&lt;/p&gt;

&lt;p&gt;Each of these is an argument for putting the gate before publication rather than after a correction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does to headcount
&lt;/h2&gt;

&lt;p&gt;The interesting outcome is that the team does not grow the way you would expect. Output rises while headcount stays flat, and the roles that remain shift toward editing, verification, and deciding what to commission.&lt;/p&gt;

&lt;p&gt;Customers describe the same pattern when a CMS stops requiring a developer for routine changes. As Maximilian Wuhr, Co-Founder at FINN, put it: "Cosmic is: us never having to ask a developer to change anything on the backend of our website."&lt;/p&gt;

&lt;p&gt;If you do add a reviewer, the cost is predictable. Cosmic plans include a set number of team members (Free includes 2, Builder 3, Team 5, Business 10), and additional users are $29 per user per month. Current plan details are on the &lt;a href="https://www.cosmicjs.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;If you are heading toward this ratio, the sequence that works is: tighten the content model first, make draft the default second, build the review queue third, and add agents fourth.&lt;/p&gt;

&lt;p&gt;Cosmic gives you the pieces for all four. The &lt;a href="https://www.cosmicjs.com/headless-cms" rel="noopener noreferrer"&gt;headless CMS&lt;/a&gt; provides the content model and validation, the REST API and TypeScript SDK provide the review surface, the &lt;a href="https://www.cosmicjs.com/mcp-server" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; connects Claude, Cursor, or any MCP client directly to your content, and Insights tells you whether any of it is working. If you want the specific permission controls behind all of this, &lt;a href="https://www.cosmicjs.com/blog/ai-agent-write-access-cms-controls" rel="noopener noreferrer"&gt;we documented each one and how to verify it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-content&amp;amp;utm_content=when-agents-outnumber-editors-body" rel="noopener noreferrer"&gt;Start free with a Cosmic account&lt;/a&gt;, no credit card required.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.cosmicjs.com/blog/when-agents-outnumber-editors" rel="noopener noreferrer"&gt;cosmicjs.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cms</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The Content Flywheel: How We Run AI Agents From Publish to Insights and Back</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:46:32 +0000</pubDate>
      <link>https://dev.to/tonyspiro/the-content-flywheel-how-we-run-ai-agents-from-publish-to-insights-and-back-3m64</link>
      <guid>https://dev.to/tonyspiro/the-content-flywheel-how-we-run-ai-agents-from-publish-to-insights-and-back-3m64</guid>
      <description>&lt;p&gt;Most AI content tooling stops at the draft. You prompt a model, it produces something, a human edits it, and the loop ends there. Whatever happens to that content after publish never makes it back to the thing that wrote it.&lt;/p&gt;

&lt;p&gt;At Cosmic, we've completed the loop. Our agents publish into Cosmic, &lt;a href="https://www.cosmicjs.com/insights" rel="noopener noreferrer"&gt;Cosmic Insights&lt;/a&gt; measures what the content does, and the same agents read that data on a schedule and decide what to write, fix, or retire. That closed loop is what we call the content flywheel, and we run it on cosmicjs.com every day.&lt;/p&gt;

&lt;p&gt;Here is how it actually works, with real numbers from the project that powers content on this website.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four stages
&lt;/h2&gt;

&lt;p&gt;A content flywheel needs four things wired together. Miss one and it stops being a loop.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A content model an agent can write to.&lt;/strong&gt; Structured, queryable fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An agent with real capabilities.&lt;/strong&gt; Read the CMS, write to the CMS, browse the web, post to Slack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurement attached to the content.&lt;/strong&gt; Traffic attributed back to the object itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scheduled goal that closes the loop.&lt;/strong&gt; Something that wakes up, reads the data, and acts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cosmic gives you all four in one place, which is the entire reason the loop is short enough to be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: The content model
&lt;/h2&gt;

&lt;p&gt;Our blog post type has the fields you would expect, plus the ones that make measurement possible: &lt;code&gt;seo_title&lt;/code&gt;, &lt;code&gt;seo_description&lt;/code&gt;, &lt;code&gt;teaser&lt;/code&gt;, &lt;code&gt;published_date&lt;/code&gt;, &lt;code&gt;last_updated&lt;/code&gt;, and relationship fields for author, category, and tags.&lt;/p&gt;

&lt;p&gt;That structure matters more than it sounds. Because &lt;code&gt;last_updated&lt;/code&gt; is a real date field and &lt;code&gt;tags&lt;/code&gt; is a real relationship, an agent can ask questions like "which posts tagged AI Agents have not been touched in 90 days" and get an answer it can act on. A single freeform body field leaves that question unanswerable.&lt;/p&gt;

&lt;p&gt;Agents read and write this model through the same REST API and TypeScript SDK your app uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Find posts in a topic cluster that are going stale.&lt;/span&gt;
&lt;span class="c1"&gt;// Query relationship fields by object ID, not slug: slugs can&lt;/span&gt;
&lt;span class="c1"&gt;// collide across object types and resolve to the wrong object.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;objects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata.tags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;6a21aa1ed66dd9646b9e028a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// "AI Agents" tag object ID&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata.last_updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$lt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-05-20&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id,title,slug,metadata.last_updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent uses the same query. There is no separate agent API to learn and no second copy of your content to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: The agents
&lt;/h2&gt;

&lt;p&gt;An agent in Cosmic is a team member you configure. You grant capabilities: read content, write content, browse the web, call external APIs, send messages to Slack or Telegram, read analytics, work on a connected repository. What the agent does is decided by the capabilities you give it.&lt;/p&gt;

&lt;p&gt;Ours are split by job, the same way a content team is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A content lead agent that researches, writes, and publishes into the bucket.&lt;/li&gt;
&lt;li&gt;A social coordinator agent that pulls the day's published posts and writes platform-specific copy.&lt;/li&gt;
&lt;li&gt;A growth agent that reads Insights and reports to the team in Slack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They hand work to each other. The social agent asks the content agent what shipped in the last 24 hours, gets back titles, slugs, and categories, and writes from that instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: Measurement that points back at the content
&lt;/h2&gt;

&lt;p&gt;This is the stage most teams skip, and it is the one that makes the loop real.&lt;/p&gt;

&lt;p&gt;Cosmic Insights tracks pageviews, visitors, sessions, bounce rate, custom events, and revenue. The part that matters for a flywheel is object attribution. Add one meta tag to your page and traffic rolls up by Cosmic object alongside the URL path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"cosmic-context"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;'{"object_id":"6a873688f234aa885b778173","object_type":"blog-posts"}'&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the agent that wrote the post can ask how that exact object performed. Custom events fill in the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cosmicInsights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_cta_click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;6a873688f234aa885b778173&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;object_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On cosmicjs.com, here is what our event data looked like over the 30 days ending August 20, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;th&gt;Visitors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;signup_started&lt;/td&gt;
&lt;td&gt;751&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;signup_completed&lt;/td&gt;
&lt;td&gt;350&lt;/td&gt;
&lt;td&gt;335&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cta_click&lt;/td&gt;
&lt;td&gt;314&lt;/td&gt;
&lt;td&gt;247&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;signup_cta_click&lt;/td&gt;
&lt;td&gt;265&lt;/td&gt;
&lt;td&gt;223&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lesson_view&lt;/td&gt;
&lt;td&gt;243&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 46.6% completion rate from signup start to finish is a number we can hold a strategy against. Google organic sent 19,664 events from 16,838 visitors in that same window, which tells us which half of the funnel deserves attention. These are rolling-window figures, so they move; the point is that an agent can pull them on demand rather than guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: The scheduled goal that closes it
&lt;/h2&gt;

&lt;p&gt;A capability without a cadence is still a manual process. In Cosmic you give an agent a goal with a cron schedule, and it works through that goal in a bounded loop until it decides it is done.&lt;/p&gt;

&lt;p&gt;Our loop-closing goals look roughly like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekly, Tuesday morning:&lt;/strong&gt; read the last seven days of Insights by path and by custom event, compare against the prior seven, and post the deltas to Slack with content recommendations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly:&lt;/strong&gt; scan competitor blogs and pricing pages, cite every claim with the URL fetched in that same run, and recommend counter-content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily:&lt;/strong&gt; report which posts published in the last 24 hours so downstream agents can act on them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule we enforce on every one of these: every number in the output has to come from a tool call made in that same run. No remembered figures, no plausible-looking estimates. An agent that fabricates a metric poisons the flywheel faster than a slow one starves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The week the flywheel caught something we missed
&lt;/h2&gt;

&lt;p&gt;Here is the concrete example, and it is not a flattering one.&lt;/p&gt;

&lt;p&gt;Our highest-traffic blog cluster is a set of AI coding tool comparisons. They pull thousands of Google visitors a month. When we pulled &lt;code&gt;signup_cta_click&lt;/code&gt; broken down by page, none of those posts appeared in the list. Zero measured CTA clicks against our best organic traffic.&lt;/p&gt;

&lt;p&gt;The obvious conclusion was that the posts had no calls to action. So we read the objects back out of the CMS and checked. Every post had a mid-article signup block and a bottom CTA block, both rendering correctly on the live page.&lt;/p&gt;

&lt;p&gt;The actual problem was in the shared CTA blocks. Cosmic lets you define reusable rich-text blocks and reference them from any post with a token like &lt;code&gt;{{bottom-cta}}&lt;/code&gt;. Ours pointed at a bare signup URL with no UTM parameters and no click event bound to it. The clicks were happening. Nothing was recording them.&lt;/p&gt;

&lt;p&gt;One edit to two shared blocks instrumented every blog post in the bucket at once, because the blocks are referenced by every post that uses them. The blog channel now shows up in source attribution.&lt;/p&gt;

&lt;p&gt;The most valuable output here was diagnostic. The flywheel told us that a question we thought we had answered had never actually been measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we got wrong the first time
&lt;/h2&gt;

&lt;p&gt;Three honest lessons from running this for a while:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attribution gaps look like content failures.&lt;/strong&gt; Our first read of the data said the comparison posts were bad at converting. The real answer was that we could not see the conversions. Verify instrumentation before you rewrite content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the object back after every write.&lt;/strong&gt; We have had writes report success while silently dropping a field, usually on repeaters and relationship fields. Our agents now re-read every object after writing it and confirm the field actually stored. Use explicit object IDs for relationship fields, since slugs can collide across object types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounce rate is a weak signal on its own.&lt;/strong&gt; A developer who lands from Google, reads a comparison post, and leaves satisfied counts as a bounce. Pair it with custom events before you conclude anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building your own
&lt;/h2&gt;

&lt;p&gt;The shortest path to a working loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Model your content with real fields, especially dates and relationships. Agents can only act on structure they can query.&lt;/li&gt;
&lt;li&gt;Add the Insights script and a &lt;code&gt;cosmic-context&lt;/code&gt; meta tag to your content templates so traffic rolls up by object.&lt;/li&gt;
&lt;li&gt;Fire one custom event on your primary conversion action.&lt;/li&gt;
&lt;li&gt;Create an agent with content read and write capability, plus analytics access.&lt;/li&gt;
&lt;li&gt;Give it one scheduled goal that reads last week's data and reports what changed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Start with step five as a report-only goal. Let it tell you what it sees for a few weeks before you let it publish anything. Trust in the loop should be earned with evidence, and the loop is very good at producing evidence.&lt;/p&gt;

&lt;p&gt;You can read more about how agents are configured on the &lt;a href="https://www.cosmicjs.com/ai/agents" rel="noopener noreferrer"&gt;Cosmic AI agents page&lt;/a&gt;, or connect your own AI tools directly to your content model through the &lt;a href="https://www.cosmicjs.com/mcp-server" rel="noopener noreferrer"&gt;Cosmic MCP server&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Give your AI agents a content backend they can write to.&lt;/strong&gt; Structured, versioned content objects, a REST API and TypeScript SDK, and an MCP server your coding agent connects to directly. The Free plan includes 1 Bucket, 1,000 Objects, and 1 agent. No credit card required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-content&amp;amp;utm_content=devto-bottom-signup-cta" rel="noopener noreferrer"&gt;Start for free&lt;/a&gt; or &lt;a href="https://www.cosmicjs.com/ai?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=blog-content&amp;amp;utm_content=devto-bottom-ai-page" rel="noopener noreferrer"&gt;see Cosmic for AI teams&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cms</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>How to Migrate from Payload CMS to Cosmic</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Sat, 15 Aug 2026 19:34:05 +0000</pubDate>
      <link>https://dev.to/tonyspiro/how-to-migrate-from-payload-cms-to-cosmic-4p7c</link>
      <guid>https://dev.to/tonyspiro/how-to-migrate-from-payload-cms-to-cosmic-4p7c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Last verified: August 14, 2026.&lt;/strong&gt; Every claim about Payload on this page was checked against Payload's own live pages on that date: &lt;a href="https://payloadcms.com/cloud-pricing" rel="noopener noreferrer"&gt;Payload Cloud pricing and status&lt;/a&gt;, &lt;a href="https://payloadcms.com/docs/getting-started/what-is-payload" rel="noopener noreferrer"&gt;What is Payload&lt;/a&gt;, and the &lt;a href="https://payloadcms.com/docs/local-api/overview" rel="noopener noreferrer"&gt;Local API docs&lt;/a&gt;. Cosmic pricing was checked against &lt;a href="https://www.cosmicjs.com/pricing" rel="noopener noreferrer"&gt;cosmicjs.com/pricing&lt;/a&gt;. Note that &lt;code&gt;payloadcms.com/pricing&lt;/code&gt; returned a 404 on this date; Cloud plan details now live on the &lt;code&gt;/cloud-pricing&lt;/code&gt; page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What actually changed at Payload
&lt;/h2&gt;

&lt;p&gt;Payload has joined Figma. The announcement is &lt;a href="https://www.figma.com/blog/payload-joins-figma/" rel="noopener noreferrer"&gt;on the Figma blog&lt;/a&gt;, and it is linked from the top of every page on payloadcms.com.&lt;/p&gt;

&lt;p&gt;Two things follow from that, both stated by Payload directly on its Cloud page:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;New Payload Cloud deployments are paused.&lt;/strong&gt; In Payload's words: "Although deployment of new projects is currently paused, existing Cloud projects will continue running as normal."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing Cloud projects will eventually move.&lt;/strong&gt; From the same page's FAQ, answering "Will I need to migrate my project?": "Yes, eventually. There is no rush, but we are planning to build something better that you will be able to migrate to once it's available."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is what has &lt;em&gt;not&lt;/em&gt; changed. Payload the framework is still open source, still actively developed, and still self-hostable. Payload's own docs state that Payload remains a self-hosted solution, and that anywhere you can run a Next.js app, you can run Payload. If you self-host Payload today, nothing above forces your hand.&lt;/p&gt;

&lt;p&gt;The teams with a real decision to make are the ones who picked Payload Cloud because they wanted someone else to run the database, the file storage, and the deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you migrate at all?
&lt;/h2&gt;

&lt;p&gt;Three honest options. Pick based on your team, not on vendor news.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay on Payload and self-host it.&lt;/strong&gt; The right call if your content model leans on Payload's code-first strengths: custom access control, hooks, field-level permissions, or an admin panel you have meaningfully customized in React. You will need to own a database, object storage, and a deploy target. Payload is a Next.js application, so this is familiar work for a Next.js team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait for whatever Figma ships.&lt;/strong&gt; Payload says there is no rush and that a migration path to something new is planned. If your project is stable and you have infrastructure people, waiting costs you little.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move to a managed API-first CMS.&lt;/strong&gt; The right call if the reason you chose Payload Cloud was that you did not want to run infrastructure, and you would rather not go back to running it. That is the path this guide covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up moving to Cosmic
&lt;/h2&gt;

&lt;p&gt;Stating this plainly, because a migration guide that pretends there are no tradeoffs is not worth reading.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Local API goes away.&lt;/strong&gt; Payload's biggest technical advantage is that it runs in the same Node process as your app, so you can query the database directly from a React Server Component with no network hop. Cosmic is an HTTP API. For most content sites the difference disappears behind caching and static generation, but it is a genuine architectural change and you should know it going in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GraphQL.&lt;/strong&gt; Payload exposes REST and GraphQL. Cosmic offers a REST API and a TypeScript SDK, and does not offer GraphQL. If your frontend is built on Payload's GraphQL endpoint, that query layer gets rewritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code-first config becomes dashboard-first modeling.&lt;/strong&gt; In Payload your collections live in version-controlled TypeScript. In Cosmic you define Object Types in the dashboard or over the API. Some teams consider that a downgrade in reviewability, and others consider it the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom admin components.&lt;/strong&gt; React components you injected into the Payload admin panel do not carry over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth and access control.&lt;/strong&gt; Payload ships user auth and granular access control as first-class features. If you used Payload as your application's auth layer and not only as a CMS, that responsibility moves elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If several of those matter a lot to you, self-hosting Payload is probably the better answer, and you should stop reading here. No hard feelings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping Payload concepts to Cosmic
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Payload&lt;/th&gt;
&lt;th&gt;Cosmic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Collection&lt;/td&gt;
&lt;td&gt;Object Type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document&lt;/td&gt;
&lt;td&gt;Object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global&lt;/td&gt;
&lt;td&gt;Single Object (a type with one Object)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field&lt;/td&gt;
&lt;td&gt;Metafield&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;slug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Object slug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload collection&lt;/td&gt;
&lt;td&gt;Media library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relationship field&lt;/td&gt;
&lt;td&gt;Object metafield (&lt;code&gt;object&lt;/code&gt; / &lt;code&gt;objects&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Array field&lt;/td&gt;
&lt;td&gt;Repeater metafield&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocks field&lt;/td&gt;
&lt;td&gt;Repeater, or rich text with Content Blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Group field&lt;/td&gt;
&lt;td&gt;Parent metafield group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draft / published&lt;/td&gt;
&lt;td&gt;Object status (&lt;code&gt;draft&lt;/code&gt; / &lt;code&gt;published&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Localization&lt;/td&gt;
&lt;td&gt;Locale variants on an Object Type&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shapes line up closely enough that most migrations are a data-transform problem rather than a redesign.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: inventory what you actually have
&lt;/h2&gt;

&lt;p&gt;Before writing any script, list every collection and global, the document count in each, and which fields are genuinely used. Migrations balloon because teams port fields nobody has filled in for two years.&lt;/p&gt;

&lt;p&gt;Payload's Local API gives you counts quickly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPayload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@payload-config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collection&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;totalDocs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;totalDocs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the output down. It is your migration checklist and your verification target at cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: export from Payload
&lt;/h2&gt;

&lt;p&gt;Run this inside your Payload project so &lt;code&gt;@payload-config&lt;/code&gt; resolves. Two details matter: &lt;code&gt;pagination: false&lt;/code&gt; returns every document instead of the first page, and &lt;code&gt;depth: 0&lt;/code&gt; keeps relationships as raw IDs instead of expanding them into nested objects. You want raw IDs, because you are going to remap them yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPayload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@payload-config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./payload-export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Collections&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collection&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;docs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;overrideAccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`exported &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Globals&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findGlobal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`global-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit that folder somewhere safe. Everything after this point is reversible as long as the export exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: model your content in Cosmic
&lt;/h2&gt;

&lt;p&gt;Create one Object Type per Payload collection. You can do it in the dashboard, or script it so the whole migration is repeatable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_WRITE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objectTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;singular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metafields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;excerpt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Excerpt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textarea&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hero&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hero Image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;author&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Author&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;object_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrate types with no relationships first (authors, categories, tags), then the types that point at them. That ordering saves you a second reconciliation pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: move the media
&lt;/h2&gt;

&lt;p&gt;Payload upload documents store a filename and a URL. Pull each file and push it into the Cosmic media library, keeping a map from the old Payload ID to the new Cosmic media name so you can rewrite references later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;uploadDocs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;media&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;media&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;originalname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payload-import&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alt_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;idMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you set &lt;code&gt;alt&lt;/code&gt; text in Payload, carry it across now. Alt text lives on the Cosmic media record itself, so every Object referencing that image inherits it, which is one less accessibility cleanup later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: convert rich text
&lt;/h2&gt;

&lt;p&gt;This is the step that surprises people, so budget real time for it.&lt;/p&gt;

&lt;p&gt;Payload stores rich text as a structured JSON tree, not as markdown or HTML. You cannot drop that JSON into a markdown metafield and expect it to render. You need a serializer that walks the tree and emits markdown or HTML.&lt;/p&gt;

&lt;p&gt;Payload's rich text documentation covers converting its editor state to other formats, and that is the tool to reach for. Two things to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uploads embedded in rich text&lt;/strong&gt; become nodes referencing an upload ID. Rewrite those to the Cosmic media URLs from your &lt;code&gt;idMap&lt;/code&gt; in Step 4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocks embedded in rich text&lt;/strong&gt; need a destination. Either flatten them into markdown, or create a reusable Cosmic Content Block and reference it with a &lt;code&gt;{{block-name /}}&lt;/code&gt; token in a rich text metafield.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Convert one document, eyeball the output, then convert the rest. Do not batch-convert 2,000 documents on faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: import into Cosmic
&lt;/h2&gt;

&lt;p&gt;With media mapped and rich text serialized, the import itself is short. Keep a Payload-ID to Cosmic-ID map as you go so relationship fields can be resolved on a second pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./payload-export/posts.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_WRITE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;idMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authorIdMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preserve the original &lt;code&gt;slug&lt;/code&gt; values. That single decision is what keeps your URLs, your rankings, and your inbound links intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: rewire the frontend
&lt;/h2&gt;

&lt;p&gt;If your app is Next.js, this is the most mechanical part of the whole project. Payload Local API calls become Cosmic SDK calls.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;docs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-publishedDate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;objects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;.props()&lt;/code&gt; to request only the fields the page renders. Payload's Local API had no network cost, so over-fetching was cheap. Over an HTTP API it is not, and trimming the payload is the single easiest performance win in the port.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: verify, then cut over
&lt;/h2&gt;

&lt;p&gt;Work through this before you flip DNS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Counts match.&lt;/strong&gt; Compare every Object Type count against the inventory from Step 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot-check the ugly documents.&lt;/strong&gt; Not the simple ones. The post with nested blocks, four images, and a relationship array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every relationship resolves.&lt;/strong&gt; Query with &lt;code&gt;.depth(1)&lt;/code&gt; and confirm nothing comes back null.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slugs are identical&lt;/strong&gt; to production Payload for every public URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media loads&lt;/strong&gt; on a real page render, not only in the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects staged&lt;/strong&gt; for any URL that genuinely had to change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Payload running&lt;/strong&gt; in parallel until the checks pass. There is no prize for deleting it early.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this costs on Cosmic
&lt;/h2&gt;

&lt;p&gt;Verified against &lt;a href="https://www.cosmicjs.com/pricing" rel="noopener noreferrer"&gt;cosmicjs.com/pricing&lt;/a&gt; on August 14, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Buckets&lt;/th&gt;
&lt;th&gt;Team members&lt;/th&gt;
&lt;th&gt;Objects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0/mo&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Builder&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team&lt;/td&gt;
&lt;td&gt;$299/mo&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;$499/mo&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Additional team members beyond a plan's included seats are $29/user/month.&lt;/p&gt;

&lt;p&gt;The practical move is to run the whole migration on the Free plan first. One Bucket and 1,000 Objects is enough to prove the export, the transform, and the import against real content before anyone approves a budget line.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Payload CMS being shut down?&lt;/strong&gt;&lt;br&gt;
No, and nothing on this page should be read that way. Payload is open source and self-hostable, and Payload states that existing Cloud projects continue running as normal. What changed is that new Payload Cloud deployments are paused, and Payload says existing Cloud projects will eventually need to migrate to a future replacement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I keep my URLs?&lt;/strong&gt;&lt;br&gt;
Yes, as long as you carry the &lt;code&gt;slug&lt;/code&gt; field across unchanged. Cosmic Objects have a slug you control, so a one-to-one mapping is normal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Cosmic support GraphQL?&lt;/strong&gt;&lt;br&gt;
No. Cosmic provides a REST API and a TypeScript SDK. Payload does expose GraphQL, so if your frontend depends on it, plan on rewriting that data layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does a migration take?&lt;/strong&gt;&lt;br&gt;
It depends almost entirely on rich text and blocks. A handful of collections with plain fields is an afternoon. A heavily block-driven site with thousands of documents is a multi-week project, and the serializer is where the time goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run both during the transition?&lt;/strong&gt;&lt;br&gt;
Yes, and you should. Import into Cosmic, point a staging branch at it, verify against the Step 8 checklist, and only then cut over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;If you were on Payload Cloud for the managed hosting and you want to stay out of the infrastructure business, the cheapest way to evaluate Cosmic is to migrate one collection and look at the result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.cosmicjs.com/signup?utm_source=dev.to&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=migrate-payload-cms-to-cosmic&amp;amp;utm_content=closing-cta"&gt;Create a free Bucket&lt;/a&gt; and run the export script above against your smallest collection. If you would rather walk through your content model with someone first, &lt;a href="https://calendly.com/tonyspiro/cosmic-intro" rel="noopener noreferrer"&gt;book time with our CEO Tony Spiro&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;More reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.cosmicjs.com/payload-alternative" rel="noopener noreferrer"&gt;Payload CMS Alternative&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cosmicjs.com/blog/payload-cms-vs-cosmic-which-headless-cms-is-right-for-you" rel="noopener noreferrer"&gt;Payload CMS vs Cosmic: Which Headless CMS Is Right for You?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cosmicjs.com/blog/payload-vs-strapi" rel="noopener noreferrer"&gt;Payload vs Strapi: Which Open-Source Headless CMS Should You Choose in 2026?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>headlesscms</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Claude Marks Its AI-Generated Content Now: What That Means for Your CMS</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Tue, 11 Aug 2026 22:03:00 +0000</pubDate>
      <link>https://dev.to/tonyspiro/claude-marks-its-ai-generated-content-now-what-that-means-for-your-cms-20cg</link>
      <guid>https://dev.to/tonyspiro/claude-marks-its-ai-generated-content-now-what-that-means-for-your-cms-20cg</guid>
      <description>&lt;p&gt;Anthropic published a support article on &lt;a href="https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content" rel="noopener noreferrer"&gt;how Claude marks AI-generated content&lt;/a&gt;, and it spent this morning on the Hacker News front page. The document itself is short and mostly technical. The discussion was neither, because it lands on a question most content teams using AI have been quietly deferring: when a model touches your content, is there a record, and whose job is it to keep one?&lt;/p&gt;

&lt;p&gt;Here is what actually shipped, what it can and cannot prove, and the part that falls to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Anthropic shipped
&lt;/h2&gt;

&lt;p&gt;Anthropic signed the EU AI Act's Article 50(2) Code of Practice on Transparency of AI-Generated Content. Claude models launched in the EU on or after August 2, 2026 support machine-readable marking at launch, and Anthropic says it is working to add marking support to models released before that date.&lt;/p&gt;

&lt;p&gt;There are two mechanisms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Imperceptible watermarks in text.&lt;/strong&gt; Claude weaves a mark directly into the text it generates. A reader cannot see it, and Anthropic states it does not change the meaning, quality, or readability of the output. Because the watermark is part of the text, it travels with the text when it is copied and pasted, and it may persist through some editing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C2PA signed provenance metadata on files.&lt;/strong&gt; When Claude generates a supported file type, including &lt;code&gt;.svg&lt;/code&gt;, &lt;code&gt;.png&lt;/code&gt;, and &lt;code&gt;.jpg&lt;/code&gt;, it attaches signed provenance metadata following the &lt;a href="https://c2pa.org/" rel="noopener noreferrer"&gt;Coalition for Content Provenance and Authenticity&lt;/a&gt; open standard. A signed label signals the file was processed by Claude and lets you detect whether it has been tampered with.&lt;/p&gt;

&lt;p&gt;Marking is applied at the model level, so it follows the models across the surfaces they run on: Claude Platform (API), Claude, Claude Code, Claude Cowork, and Claude Tag. Embedded watermarks also apply when supported models are accessed through AWS, Google Cloud, or Microsoft Foundry. Anthropic is explicit that coverage is not uniform, and that signed file metadata in particular may not be supported on every platform. Marking applies worldwide, not only to users in the EU. Detection documentation is described as forthcoming.&lt;/p&gt;

&lt;p&gt;If you build products on Claude, Anthropic's own guidance is that you should independently assess what Article 50 requires of you. Signing the code of practice covers Anthropic's models. It does not discharge your obligations for the thing you shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a detected mark tells you, and what it does not
&lt;/h2&gt;

&lt;p&gt;The limitations section is the most useful part of that document for anyone running a publishing pipeline, and Anthropic is unusually direct about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A mark means Claude processed the text.&lt;/strong&gt; It does not establish that Claude wrote it. Anthropic gives the examples itself: people use Claude to proofread, translate, summarize, and convert files, and the output can carry a mark even when the underlying ideas, text, or data came from somewhere else. Any policy that treats a detected mark as proof of machine authorship will produce false accusations against your own writers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of a mark proves nothing.&lt;/strong&gt; Anthropic lists the cases: content from a model released before marking was supported, text that has been heavily edited or paraphrased or translated, passages too short to carry a reliable signal, and files whose metadata was stripped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File metadata is fragile in completely ordinary ways.&lt;/strong&gt; Anthropic names format conversion, re-saving, and screenshots as things that strip C2PA metadata. If you have ever run images through an optimization or resizing pipeline, you have probably stripped provenance metadata already without noticing. Verify what actually survives your own media pipeline before you rely on it.&lt;/p&gt;

&lt;p&gt;So the honest summary: watermarking is a meaningful transparency measure for the open internet and a weak internal record for your team. Detection answers "did a model touch this" with a qualified maybe. It does not answer the questions your team will actually be asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions you will actually be asked
&lt;/h2&gt;

&lt;p&gt;When a claim in a published post turns out to be wrong, or a customer asks how your documentation is produced, or legal asks what your AI disclosure covers, the questions are specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which model produced this draft, at what version?&lt;/li&gt;
&lt;li&gt;Was it a person using an assistant, or an agent running unattended?&lt;/li&gt;
&lt;li&gt;Which human reviewed it before it went live, and on what date?&lt;/li&gt;
&lt;li&gt;Were the product facts, pricing, and customer references checked against a source of truth?&lt;/li&gt;
&lt;li&gt;How many published pages are fully machine-generated and have never been read by anyone on the team?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are recoverable from an invisible watermark, even a perfectly detected one. All of them are trivially recoverable from your CMS, if you decided in advance to store them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store provenance as typed fields
&lt;/h2&gt;

&lt;p&gt;Provenance that lives in a Slack thread, a spreadsheet, or someone's memory is not provenance. Add it to the content model so it is queryable, reportable, and impossible to skip.&lt;/p&gt;

&lt;p&gt;A minimal set of fields that covers the questions above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_assisted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switch&lt;/td&gt;
&lt;td&gt;Was a model involved at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_role&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;drafted&lt;/code&gt;, &lt;code&gt;edited&lt;/code&gt;, &lt;code&gt;translated&lt;/code&gt;, &lt;code&gt;researched&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text&lt;/td&gt;
&lt;td&gt;Model and version, e.g. &lt;code&gt;claude-sonnet-5&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;human_reviewer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Object&lt;/td&gt;
&lt;td&gt;Relationship to your authors type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;review_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Date&lt;/td&gt;
&lt;td&gt;When a person actually signed off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;facts_verified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switch&lt;/td&gt;
&lt;td&gt;Product facts and pricing checked against source&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;ai_role&lt;/code&gt; distinction is the one that matters most, because it is exactly the distinction watermark detection cannot make. A post Claude drafted end to end and a post Claude proofread can carry the same mark. Your own record separates them.&lt;/p&gt;

&lt;p&gt;Writing that record with the Cosmic TypeScript SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @cosmicjs/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-bucket-slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-read-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-write-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OBJECT_ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ai_assisted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ai_role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drafted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ai_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;human_reviewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jane-doe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;review_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-08-11&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;facts_verified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff is the query. Finding every AI-drafted post that no human has signed off on becomes one request instead of an audit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unreviewed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata.ai_assisted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata.facts_verified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug,title,metadata.ai_model,metadata.review_date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that on a schedule and the answer to "how much unreviewed machine-generated content is live on our site" stops being a guess. Because the fields are typed and served over the REST API, you can surface the same record on the page itself if your disclosure policy calls for it, without maintaining a second system to track it.&lt;/p&gt;

&lt;p&gt;One note on the practical side: adding a field to a content model and backfilling it is a content-team task in a headless CMS, not an engineering ticket. Maximilian Wuhr, Co-Founder at FINN, described the value of that arrangement plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Cosmic is: us never having to ask a developer to change anything on the backend of our website."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reporting on it
&lt;/h2&gt;

&lt;p&gt;Provenance fields tell you how a piece was made. The next question is whether it performs, and whether machine-drafted work holds up against human-drafted work on your own site.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cosmicjs.com/blog/cosmic-insights-web-analytics-that-knows-your-content" rel="noopener noreferrer"&gt;Cosmic Insights&lt;/a&gt; rolls traffic up by the actor who created the underlying content, splitting it across human users, agents, and automations. That turns a philosophical argument into a measurable one. If agent-drafted pages bounce harder or convert worse than human-drafted pages, you will see it in the numbers rather than debating it in a meeting. If they perform the same, that is worth knowing too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this quarter
&lt;/h2&gt;

&lt;p&gt;Six things, none of which require a replatform.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add provenance fields to your content models.&lt;/strong&gt; Start with the six above. Ship them before you need them, because retrofitting provenance onto two years of archives is guesswork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfill the last 90 days only.&lt;/strong&gt; Recent content is what people are reading and what will get questioned. Older archives can be marked unknown, honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test what your media pipeline does to C2PA metadata.&lt;/strong&gt; Upload a Claude-generated image, run it through your normal transform and optimization path, and check whether the Content Credentials survive. Assume they do not until you have verified it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down your disclosure policy in one paragraph.&lt;/strong&gt; What level of model involvement triggers a public disclosure on the page? "Claude drafted this" and "Claude proofread this" are different, and your policy should say so before a reader asks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep detection out of your enforcement policy.&lt;/strong&gt; If you are checking freelancer or agency submissions, treat a watermark hit as a reason to open a conversation. &lt;a href="https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content" rel="noopener noreferrer"&gt;Anthropic's own documentation&lt;/a&gt; states that a detected mark is not fully conclusive, so it cannot carry a policy violation on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope the keys your agents hold.&lt;/strong&gt; If an agent can write to production, the provenance record it leaves is the only trail you have. Cosmic issues separate read and write keys, so a client connected with a read-only key gets the read tools while every write tool and all four AI generation tools are blocked with a clear error message. Details are on the &lt;a href="https://www.cosmicjs.com/mcp-server" rel="noopener noreferrer"&gt;MCP server page&lt;/a&gt; and in &lt;a href="https://www.cosmicjs.com/blog/connect-claude-to-your-cms-mcp-server" rel="noopener noreferrer"&gt;Connect Claude to Your CMS&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The through line
&lt;/h2&gt;

&lt;p&gt;Model-level watermarking is a good development, and it solves a problem at the wrong altitude for your purposes. It helps the broader internet identify synthetic content at scale. It does very little for the team that has to explain how a specific paragraph on a specific page came to exist.&lt;/p&gt;

&lt;p&gt;That record is yours to keep, and the only place it can reliably live is next to the content itself, as structured fields you can query. Teams that add those fields now will answer provenance questions in one API call. Teams that wait will reconstruct them from memory, which is another way of saying they will not answer them at all.&lt;/p&gt;

&lt;p&gt;If you are already running AI in your publishing workflow, this is a one-afternoon change with a long tail of value. You can model provenance fields on the Cosmic free plan and query them over the REST API today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=cross_post&amp;amp;utm_campaign=claude-ai-content-watermarking-provenance-cms" rel="noopener noreferrer"&gt;Start free&lt;/a&gt; or &lt;a href="https://calendly.com/tonyspiro/cosmic-intro" rel="noopener noreferrer"&gt;book 15 minutes with our CEO&lt;/a&gt; to talk through how your content model should record agent work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic, &lt;a href="https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content" rel="noopener noreferrer"&gt;How Claude marks AI-generated content&lt;/a&gt;, Claude Help Center&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://c2pa.org/" rel="noopener noreferrer"&gt;C2PA&lt;/a&gt;, Coalition for Content Provenance and Authenticity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.cosmicjs.com/blog/claude-ai-content-watermarking-provenance-cms?utm_source=devto&amp;amp;utm_medium=cross_post&amp;amp;utm_campaign=claude-ai-content-watermarking-provenance-cms" rel="noopener noreferrer"&gt;Cosmic blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cms</category>
      <category>webdev</category>
      <category>contentstrategy</category>
    </item>
    <item>
      <title>Connect Claude to Your CMS: A 5-Minute Guide to the Cosmic MCP Server</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Mon, 10 Aug 2026 22:03:30 +0000</pubDate>
      <link>https://dev.to/tonyspiro/connect-claude-to-your-cms-a-5-minute-guide-to-the-cosmic-mcp-server-4hbf</link>
      <guid>https://dev.to/tonyspiro/connect-claude-to-your-cms-a-5-minute-guide-to-the-cosmic-mcp-server-4hbf</guid>
      <description>&lt;p&gt;If you already run Claude every day, the next obvious question is whether it can touch your content. Not summarize it. Not draft copy in a chat window you then paste somewhere. Actually read your content model, query it, and write back to it.&lt;/p&gt;

&lt;p&gt;Cosmic answers that with an MCP server. This guide gets you connected in about five minutes, then covers the part most people skip: what Claude is allowed to do once it is connected.&lt;/p&gt;




&lt;h2&gt;
  
  
  What MCP actually gives you
&lt;/h2&gt;

&lt;p&gt;Model Context Protocol is a standard way for an AI client to discover and call tools. Instead of you describing your CMS to Claude in prose, Claude asks the server what tools exist and calls them directly.&lt;/p&gt;

&lt;p&gt;The Cosmic MCP server exposes 18 bucket-scoped tools. They cover objects, object types, media, and AI generation. Bucket-scoped is the important word: the server operates against one Bucket, using the keys you give it, and it cannot reach across your other Buckets.&lt;/p&gt;




&lt;h2&gt;
  
  
  The five-minute setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Get your Bucket keys
&lt;/h3&gt;

&lt;p&gt;In your Cosmic dashboard, open your Bucket, then &lt;strong&gt;Settings &amp;gt; API Access&lt;/strong&gt;. Copy three things: your Bucket slug, your read key, and your write key. The keys are separate on purpose, and that separation is the single most useful control in this whole setup. More on that below.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Add the server to your MCP client config
&lt;/h3&gt;

&lt;p&gt;The hosted endpoint is the fastest path and needs no install. Point your client at &lt;code&gt;https://mcp.cosmicjs.com/v1/buckets/{your-bucket-slug}&lt;/code&gt; and authenticate with your keys in the &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;For Claude Desktop, edit your config file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS: &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Windows: &lt;code&gt;%APPDATA%\Claude\claude_desktop_config.json&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cosmic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.cosmicjs.com/v1/buckets/your-bucket-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer your-read-key:your-write-key"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursor takes the same shape in &lt;code&gt;.cursor/mcp.json&lt;/code&gt; for a single project, or &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; globally.&lt;/p&gt;

&lt;p&gt;Read the bearer token carefully: read key, colon, write key. Drop the &lt;code&gt;:your-write-key&lt;/code&gt; suffix and the connection becomes read-only. Hold onto that detail, because it is the control worth understanding before you point any of this at production.&lt;/p&gt;

&lt;p&gt;If you would rather run the MCP process inside your own dev environment, the &lt;code&gt;@cosmicjs/mcp&lt;/code&gt; package ships a stdio binary that reads credentials from environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cosmic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@cosmicjs/mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"COSMIC_BUCKET_SLUG"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-bucket-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"COSMIC_READ_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-read-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"COSMIC_WRITE_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-write-key"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the file and restart the client. The Cosmic tools show up in the tool list.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Verify the connection
&lt;/h3&gt;

&lt;p&gt;Ask Claude something read-only first:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"List the object types in this Bucket and tell me how many objects are in each."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you get back your real content model, you are connected. If you get nothing, the key or the Bucket slug is wrong, and the error message will say which.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Try a real task
&lt;/h3&gt;

&lt;p&gt;Once reads work, the useful prompts look like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Find every published blog post missing an SEO description and list the slugs."&lt;/p&gt;

&lt;p&gt;"Create a draft post from this outline, set the author to Tony Spiro, and leave status as draft."&lt;/p&gt;

&lt;p&gt;"Add alt text to every image in the media library that does not have any."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last one is the kind of chore that never gets done manually and takes an agent about a minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  The control that matters: read keys and write keys
&lt;/h2&gt;

&lt;p&gt;Here is the part worth understanding before you hand an agent your production Bucket.&lt;/p&gt;

&lt;p&gt;Cosmic issues separate read and write keys. Give an MCP client a read-only key and it gets the read tools only. Every write tool returns a clear blocked error. Nothing is guessed, nothing is inferred from a system prompt, and no amount of clever prompting talks its way past it.&lt;/p&gt;

&lt;p&gt;You can verify this yourself in under a minute:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set the &lt;code&gt;Authorization&lt;/code&gt; header to &lt;code&gt;Bearer your-read-key&lt;/code&gt; with no write key and no colon&lt;/li&gt;
&lt;li&gt;Ask Claude to create a new object&lt;/li&gt;
&lt;li&gt;Read the blocked error it returns&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a checkable guardrail, not a policy document. It is also the honest starting posture for anyone connecting an agent to real content: read-only first, then widen scope deliberately once you trust the workflow.&lt;/p&gt;

&lt;p&gt;When you are ready for writes, the safe pattern is a write-enabled key pointed at a staging Bucket, with the production Bucket still read-only. Cosmic plans include multiple Buckets from Builder up, so a dedicated staging Bucket for agent work is a reasonable setup rather than an exotic one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Writing content through the API instead
&lt;/h2&gt;

&lt;p&gt;If you would rather script the workflow than chat it, the TypeScript SDK covers the same ground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @cosmicjs/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_WRITE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Read: find drafts with no SEO description&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id,title,slug,metadata.seo_description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;seo_description&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Write: patch one of them&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;seo_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A short, specific summary under 155 characters.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;writeKey&lt;/code&gt; is a separate argument from &lt;code&gt;readKey&lt;/code&gt;. Omit it and every write call fails, which is the same guardrail the MCP server relies on.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is good for, and what it is not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good fits today:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Content audits: missing metadata, broken internal links, stale dates, orphaned objects&lt;/li&gt;
&lt;li&gt;Bulk mechanical edits: alt text, SEO descriptions, tag normalization, category cleanup&lt;/li&gt;
&lt;li&gt;Draft generation against your real content model, so the shape is correct on the first pass&lt;/li&gt;
&lt;li&gt;Answering questions about your own content library without opening the dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not a good fit yet:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unsupervised publishing to production. Keep a human on the publish step.&lt;/li&gt;
&lt;li&gt;Anything where a wrong write is expensive and hard to reverse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule: let the agent do the reading and the drafting, and keep the irreversible actions behind a person.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a paid plan to use the MCP server?&lt;/strong&gt;&lt;br&gt;
You can start on the Free plan, which includes 1 Bucket, 2 team members, and 1,000 Objects. If you want a separate staging Bucket for agent writes, that starts at the Builder plan ($49/month, 2 Buckets, 3 team members, 5,000 Objects).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Cosmic offer a GraphQL endpoint for this?&lt;/strong&gt;&lt;br&gt;
No. Cosmic offers a REST API and the JavaScript/TypeScript SDK. The MCP server sits on top of the same REST API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I limit which object types an agent can touch?&lt;/strong&gt;&lt;br&gt;
The key-level control is read versus write. For tighter scoping, point the agent at a Bucket that only contains the content you want it to work on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with clients other than Claude?&lt;/strong&gt;&lt;br&gt;
MCP is a client-agnostic standard, so any MCP-compatible client can connect to the same server with the same 18 tools. Cursor is covered above, and the hosted endpoint speaks the streamable-HTTP transport that other clients use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it on a real Bucket
&lt;/h2&gt;

&lt;p&gt;The fastest way to understand what an agent can do with a proper content model behind it is to connect one and ask it a question about your own content.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=connect-claude-to-your-cms-mcp-server" rel="noopener noreferrer"&gt;Create a free Cosmic account&lt;/a&gt;. No credit card required.&lt;/li&gt;
&lt;li&gt;Read the &lt;a href="https://www.cosmicjs.com/docs/mcp-server?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=connect-claude-to-your-cms-mcp-server" rel="noopener noreferrer"&gt;MCP server docs&lt;/a&gt; for the exact client configuration&lt;/li&gt;
&lt;li&gt;Want a walkthrough of the agent workflows other teams are running? &lt;a href="https://calendly.com/tonyspiro/cosmic-intro" rel="noopener noreferrer"&gt;Book 30 minutes with Tony&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cosmic is a YC W19 company building the content layer for teams that want their AI tools to work against real, structured content instead of copy pasted into a chat box.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>cms</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Medusa.js + Next.js: How to Add a Content Layer to Your Storefront</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Fri, 07 Aug 2026 19:28:03 +0000</pubDate>
      <link>https://dev.to/tonyspiro/medusajs-nextjs-how-to-add-a-content-layer-to-your-storefront-43be</link>
      <guid>https://dev.to/tonyspiro/medusajs-nextjs-how-to-add-a-content-layer-to-your-storefront-43be</guid>
      <description>&lt;p&gt;&lt;a href="https://medusajs.com/" rel="noopener noreferrer"&gt;Medusa&lt;/a&gt; is a strong commerce engine. It owns products, variants, pricing, inventory, carts, orders, and fulfillment, and it exposes all of it through a clean Store API. Once you have the Next.js Starter Storefront running, the commerce half of your site is basically solved.&lt;/p&gt;

&lt;p&gt;Then marketing asks for a buying guide on every category page, a founder story block on the product detail page, a seasonal landing page that goes live Friday at 9am, and an FAQ section that changes weekly. None of that belongs in your commerce database, and none of it should require a deploy.&lt;/p&gt;

&lt;p&gt;This post walks through the pattern we see working best: keep Medusa as the source of truth for commerce, add a headless CMS as the source of truth for editorial content, and join them in the Next.js layer on a shared key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with putting content in Medusa
&lt;/h2&gt;

&lt;p&gt;Medusa lets you attach arbitrary &lt;code&gt;metadata&lt;/code&gt; to products. It is tempting to stuff your marketing copy in there and call it done. That falls apart quickly for three reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No editing experience.&lt;/strong&gt; &lt;code&gt;metadata&lt;/code&gt; is a key-value bag. Your content team gets a JSON blob, no rich text, no image handling, no preview, no revision history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No content modeling.&lt;/strong&gt; A buying guide has a title, hero image, intro, sections, related products, and an author. Flattening that into string keys means every consumer has to re-parse it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content that is not product-shaped has nowhere to live.&lt;/strong&gt; A holiday gift guide, a shipping policy page, a comparison table, a homepage hero: none of these map to a product record at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is a second system with its own model and its own editor, joined at read time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the ownership line first
&lt;/h2&gt;

&lt;p&gt;Before you write any code, write down who owns what. This one decision prevents most of the sync bugs teams hit later.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product handle, title, variants&lt;/td&gt;
&lt;td&gt;Medusa&lt;/td&gt;
&lt;td&gt;Commerce truth, drives cart and checkout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price, inventory, availability&lt;/td&gt;
&lt;td&gt;Medusa&lt;/td&gt;
&lt;td&gt;Must be real time, never cached in a CMS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cart, orders, customers, fulfillment&lt;/td&gt;
&lt;td&gt;Medusa&lt;/td&gt;
&lt;td&gt;Transactional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDP long-form story, lifestyle imagery&lt;/td&gt;
&lt;td&gt;CMS&lt;/td&gt;
&lt;td&gt;Editorial, changes without a deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buying guides, blog posts, landing pages&lt;/td&gt;
&lt;td&gt;CMS&lt;/td&gt;
&lt;td&gt;No product record exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAQs, size guides, care instructions&lt;/td&gt;
&lt;td&gt;CMS&lt;/td&gt;
&lt;td&gt;Reusable across many products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Navigation, promo banners, homepage blocks&lt;/td&gt;
&lt;td&gt;CMS&lt;/td&gt;
&lt;td&gt;Merchandising, changes weekly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule to enforce in code review: &lt;strong&gt;price and inventory never come from the CMS.&lt;/strong&gt; Duplicating those into a content system creates a window where your site shows a price that checkout will reject. Read them from Medusa on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get the Medusa storefront running
&lt;/h2&gt;

&lt;p&gt;Start from the official Next.js Starter Storefront so you inherit the cart, checkout, and account flows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-medusa-app@latest my-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That scaffolds a Medusa server and a Next.js storefront. Your storefront needs two environment values to talk to the backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.local&lt;/span&gt;
&lt;span class="nv"&gt;NEXT_PUBLIC_MEDUSA_BACKEND_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:9000
&lt;span class="nv"&gt;NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pk_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publishable key is created in the Medusa Admin under Settings, and it scopes requests to a sales channel. Requests to the Store API without it will be rejected.&lt;/p&gt;

&lt;p&gt;Set up the Medusa JS SDK once and export a single client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/medusa.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Medusa&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@medusajs/js-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;medusa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Medusa&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_MEDUSA_BACKEND_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;publishableKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetching a product by its handle looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;medusa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;classic-tee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*variants.calculated_price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Model the content side
&lt;/h2&gt;

&lt;p&gt;Now add the content layer. In Cosmic, create an Object Type called &lt;strong&gt;Product Content&lt;/strong&gt; with a metafield for every editorial element your PDP needs.&lt;/p&gt;

&lt;p&gt;A model that has held up well in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metafield key&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;handle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text (unique)&lt;/td&gt;
&lt;td&gt;The join key. Must match the Medusa product handle exactly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;story&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;Long-form product narrative below the buy box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lifestyle_images&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Files&lt;/td&gt;
&lt;td&gt;Editorial photography separate from catalog shots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;care_guide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;Reusable care or sizing content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;faqs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Repeater&lt;/td&gt;
&lt;td&gt;Question and answer pairs rendered as an accordion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;related_reading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Objects&lt;/td&gt;
&lt;td&gt;Links to blog posts or buying guides&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;handle&lt;/code&gt; field is the whole design. Make it unique so two content entries can never claim the same product, and tell your editors it has to match Medusa character for character. Everything else in the model is free to change without touching commerce.&lt;/p&gt;

&lt;p&gt;Install the Cosmic SDK and create the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @cosmicjs/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/cosmic.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_BUCKET_SLUG&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COSMIC_READ_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a small helper that fetches content for a handle and returns &lt;code&gt;null&lt;/code&gt; instead of throwing when nothing exists yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/product-content.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./cosmic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProductContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata.handle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title,slug,metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;null&lt;/code&gt; return matters. It is what lets you launch a product before marketing has written anything for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Join them in a server component
&lt;/h2&gt;

&lt;p&gt;With both clients in place, the product page fetches from each system in parallel and renders one page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/products/[handle]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;medusa&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/medusa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getProductContent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/product-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;medusa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*variants.calculated_price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;getProductContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Commerce: always from Medusa */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BuyBox&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Editorial: from the CMS, optional by design */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;story&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"product-story"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;story&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;faqs&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FaqAccordion&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;faqs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Medusa decides whether the page exists.&lt;/strong&gt; If there is no product, you 404. A content entry with no matching product should never render a page, because there would be nothing to add to a cart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every content block is conditional.&lt;/strong&gt; The page has to render correctly with zero CMS content. Test that path deliberately by pointing a local build at a handle that has no entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fetch in parallel.&lt;/strong&gt; &lt;code&gt;Promise.all&lt;/code&gt; keeps the two round trips from stacking. On a PDP that difference is visible in your LCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keep pages fresh without redeploying
&lt;/h2&gt;

&lt;p&gt;Static rendering is what makes this pattern fast. Cache invalidation is what makes it usable for a content team.&lt;/p&gt;

&lt;p&gt;Tag your content fetches, then invalidate the tag from a webhook.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/api/revalidate/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-webhook-secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REVALIDATE_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-content-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;revalidated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point a Cosmic webhook at that route for the Object Edited and Object Published events. An editor publishes a change, the webhook fires, that single product page rebuilds. No deploy, no full site rebuild, no Slack message to a developer.&lt;/p&gt;

&lt;p&gt;Do the same in reverse for Medusa: subscribe to product update events and revalidate the matching path when a title or description changes.&lt;/p&gt;

&lt;p&gt;One exception to keep in mind. Price and inventory should not rely on webhook timing. Render those parts dynamically or fetch them client side so a customer never sees a cached price that checkout will reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Everything that is not a product page
&lt;/h2&gt;

&lt;p&gt;The PDP join is the interesting technical part. The larger traffic win is usually the pages that have no product record at all.&lt;/p&gt;

&lt;p&gt;With a content layer already wired in, these become straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buying guides and comparison pages.&lt;/strong&gt; These are the pages that rank for research-stage queries. Model them as a &lt;code&gt;guides&lt;/code&gt; type with a &lt;code&gt;related_products&lt;/code&gt; field holding a list of handles, then hydrate live prices from Medusa at render time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category landing pages.&lt;/strong&gt; Medusa gives you the collection and its products. The CMS gives you the hero, the intro copy, and the merchandising blocks above the grid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Campaign pages.&lt;/strong&gt; Marketing builds and schedules them without a developer. Cosmic supports scheduled publishing, so a Friday 9am launch is a field, not a deploy window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy and support pages.&lt;/strong&gt; Shipping, returns, sizing. Low glamour, high support-ticket deflection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same pattern each time: content from the CMS, live commerce data from Medusa, joined on the handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four mistakes to avoid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Copying prices into the CMS.&lt;/strong&gt; It will drift. When it drifts, customers see one number and get charged another. Always read price from Medusa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using the product ID as the join key.&lt;/strong&gt; IDs change when you reseed a database or migrate environments. Handles are stable, human readable, and already unique in Medusa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making content required.&lt;/strong&gt; If a missing CMS entry breaks the page, you have coupled your product launches to your content calendar. Optional by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two sources of truth for the product title.&lt;/strong&gt; Pick one, and it should be Medusa, since that is what appears on the order. If marketing needs a different display headline, add a separate &lt;code&gt;display_headline&lt;/code&gt; field so the intent is explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Cosmic fits this stack
&lt;/h2&gt;

&lt;p&gt;A few specifics that matter when you are pairing a CMS with Medusa:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;REST API and a TypeScript SDK.&lt;/strong&gt; &lt;code&gt;@cosmicjs/sdk&lt;/code&gt; is typed and works in Next.js server components without adapters or codegen steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully managed.&lt;/strong&gt; You are already running a Medusa server and a database. Adding a second self-hosted service to patch and scale is real operational cost. Cosmic is hosted, so the content layer is one API call, not another deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-assisted authoring.&lt;/strong&gt; Editors can draft product stories, FAQs, and guide content directly in the dashboard, which is where the volume problem usually lives for catalogs with thousands of SKUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled publishing and webhooks.&lt;/strong&gt; Both are built in, which is what makes the revalidation flow above work end to end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As Maximilian Wuhr, Co-Founder at FINN, put it: "Cosmic is: us never having to ask a developer to change anything on the backend of our website."&lt;/p&gt;

&lt;h2&gt;
  
  
  Get started
&lt;/h2&gt;

&lt;p&gt;The whole integration is about 60 lines of glue code: one Medusa client, one Cosmic client, one join key, one webhook route.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=medusajs-nextjs-headless-cms" rel="noopener noreferrer"&gt;Start free with Cosmic&lt;/a&gt; and model your first Product Content type in a few minutes. The &lt;a href="https://www.cosmicjs.com/docs?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=medusajs-nextjs-headless-cms" rel="noopener noreferrer"&gt;Cosmic docs&lt;/a&gt; cover the SDK and webhook setup, and the &lt;a href="https://docs.medusajs.com" rel="noopener noreferrer"&gt;Medusa docs&lt;/a&gt; cover the Store API side.&lt;/p&gt;

&lt;p&gt;If you are planning a larger migration or running a catalog with thousands of SKUs, &lt;a href="https://calendly.com/tonyspiro/cosmic-intro" rel="noopener noreferrer"&gt;book time with Tony&lt;/a&gt;, our CEO, and he will walk through the content model with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use Cosmic to manage products instead of Medusa?&lt;/strong&gt;&lt;br&gt;
You can model product content in Cosmic, but you should not replace Medusa's product records. Carts, pricing rules, inventory, and orders depend on them. Use Cosmic for the editorial layer around the catalog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with the Medusa Next.js Starter Storefront?&lt;/strong&gt;&lt;br&gt;
Yes. The pattern above drops into the starter's existing product route. You are adding a second data fetch alongside the Medusa one, and leaving the cart and checkout flows untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle products with no content entry yet?&lt;/strong&gt;&lt;br&gt;
Return &lt;code&gt;null&lt;/code&gt; from your content helper on a 404 and render every content block conditionally, as shown in Step 2 and Step 3. The product page stays fully functional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about multi-region or multi-language storefronts?&lt;/strong&gt;&lt;br&gt;
Medusa handles regional pricing and currency. Cosmic supports locales on objects, so you can request content in the visitor's locale using the same handle-based lookup.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cosmicjs.com/blog/medusajs-nextjs-headless-cms" rel="noopener noreferrer"&gt;the Cosmic blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ecommerce</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sanity vs Strapi: Which Headless CMS Should You Choose in 2026?</title>
      <dc:creator>Tony Spiro</dc:creator>
      <pubDate>Thu, 06 Aug 2026 22:03:25 +0000</pubDate>
      <link>https://dev.to/tonyspiro/sanity-vs-strapi-which-headless-cms-should-you-choose-in-2026-4h9g</link>
      <guid>https://dev.to/tonyspiro/sanity-vs-strapi-which-headless-cms-should-you-choose-in-2026-4h9g</guid>
      <description>&lt;p&gt;Sanity and Strapi both call themselves headless, both let you define schemas in code, and both have large developer followings. They answer a different question underneath.&lt;/p&gt;

&lt;p&gt;Sanity gives you a hosted content database, the Content Lake, with an open source editing environment on top. Strapi gives you a Node.js application under the MIT license that you run yourself, or pay Strapi Cloud to run for you.&lt;/p&gt;

&lt;p&gt;That one architectural fork decides your query language, your upgrade path, your compliance story, your AI options, and your bill. This comparison walks through each of those with numbers verified against both vendors' live pricing pages on August 5, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Sanity&lt;/th&gt;
&lt;th&gt;Strapi&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Hosted content platform (Content Lake)&lt;/td&gt;
&lt;td&gt;Open source Node.js app, MIT licensed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who operates it&lt;/td&gt;
&lt;td&gt;Sanity&lt;/td&gt;
&lt;td&gt;You, or Strapi Cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query API&lt;/td&gt;
&lt;td&gt;GROQ and GraphQL over HTTP, JS client&lt;/td&gt;
&lt;td&gt;REST and GraphQL from your own instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Managed, no direct access&lt;/td&gt;
&lt;td&gt;Postgres, MySQL, or MariaDB that you own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editing UI&lt;/td&gt;
&lt;td&gt;Sanity Studio, an open source React app you deploy&lt;/td&gt;
&lt;td&gt;Bundled admin panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry price&lt;/td&gt;
&lt;td&gt;Free plan, up to 20 seats, 10k documents&lt;/td&gt;
&lt;td&gt;Community edition, free forever, unlimited entries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seat cost&lt;/td&gt;
&lt;td&gt;$15/seat/month on Growth&lt;/td&gt;
&lt;td&gt;$15/seat/month on Growth, 3 seats included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting cost&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Yours to pay, or $35 to $450 per project per month on Cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Governance extras&lt;/td&gt;
&lt;td&gt;Comments, tasks, scheduled publishing on Growth; audit trail and SSO on Enterprise&lt;/td&gt;
&lt;td&gt;Content History and Releases on Growth; Review Workflows and Audit Logs on Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Teams that want structured content as a service with many editors&lt;/td&gt;
&lt;td&gt;Teams that need to own the runtime and the data layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture: hosted content lake vs. an app you run
&lt;/h2&gt;

&lt;p&gt;Sanity stores every document in the Content Lake, a hosted, schemaless JSON store with a real-time layer. You never provision a database or patch a server. You do give up direct database access, which matters if your security review requires the data to sit inside your own VPC.&lt;/p&gt;

&lt;p&gt;Strapi is a Node.js application. You choose Postgres, MySQL, or MariaDB, you deploy it, and you own the runtime. That means full control over network boundaries, extensions, and custom middleware. It also means you own Node version upgrades, dependency patches, major-version migrations, backups, and on-call.&lt;/p&gt;

&lt;p&gt;Strapi Cloud removes most of that operational load, and the pricing page shows the trade in plain terms. Starter projects at $35 per project per month sleep when idle. Always-on runtime begins with Pro at $90 per project per month. A 99.9% uptime SLA arrives at Business, $450 per project per month.&lt;/p&gt;

&lt;p&gt;A useful way to decide: if your team can name the person who will handle a CVE in a transitive dependency on a Friday afternoon, self-hosted Strapi is a reasonable choice. If nobody's name comes to mind, a managed platform is cheaper than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content modeling
&lt;/h2&gt;

&lt;p&gt;Both tools model content in code, which keeps schema changes in version control and in code review.&lt;/p&gt;

&lt;p&gt;Sanity schemas are JavaScript objects registered in your Studio. Portable Text handles rich text as structured data instead of an HTML blob, which pays off when the same content renders to web, native mobile, and voice. References are first class and resolve inside queries.&lt;/p&gt;

&lt;p&gt;Strapi models are defined through the Content-Type Builder in the admin panel, which writes JSON schema files into your repository. Dynamic Zones let editors assemble a page from a library of components, which is the closest thing either product has to visual page building out of the box. Components, relations, and custom fields cover most modeling needs, and the Blocks editor handles structured rich text.&lt;/p&gt;

&lt;p&gt;Strapi's advantage here is that the modeling UI ships with the product. Sanity's advantage is that Portable Text and the reference system are more rigorous once your content graph gets complicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  APIs and developer experience
&lt;/h2&gt;

&lt;p&gt;Sanity queries use GROQ, a projection language built for the Content Lake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sanity/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-project-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;useCdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`*[_type == "post" &amp;amp;&amp;amp; defined(slug.current)]{ title, "slug": slug.current, publishedAt }`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GROQ is genuinely powerful. It also has a learning curve, and every new engineer on your team pays it once.&lt;/p&gt;

&lt;p&gt;Strapi exposes REST endpoints from your own instance, with GraphQL available as a plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-app.example.com/api/articles?populate=cover&amp;amp;sort=publishedAt:desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRAPI_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strapi's API is conventional and quick to pick up. The cost shows up in performance work: response times depend on the instance you are running, the database you provisioned, and any caching you added yourself. Sanity's CDN handles that layer for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI capabilities in 2026
&lt;/h2&gt;

&lt;p&gt;This is where the two products have moved fastest, and where the fine print matters.&lt;/p&gt;

&lt;p&gt;Sanity ships AI credits with every plan, 1,000 per month on Free and Growth, with overage billed at $0.05 per credit. Its AI features cover generation and transformation inside the editing workflow, plus embeddings and semantic search over the Content Lake.&lt;/p&gt;

&lt;p&gt;Strapi introduced Strapi AI on the self-hosted Growth plan, also 1,000 credits per month, with additional credits at $1.50 per 100. Worth reading carefully before you commit: Strapi's own CMS pricing table lists Strapi AI as "Not yet available" in the Enterprise column at the time of writing. If AI in the editor is a requirement and you are heading toward an Enterprise contract, confirm availability with their sales team rather than assuming feature parity with Growth.&lt;/p&gt;

&lt;p&gt;Both vendors now ship an MCP server, which lets AI coding agents read and write content through a standard protocol. Strapi announced general availability of theirs recently. We wrote about how the two approaches differ in &lt;a href="https://www.cosmicjs.com/blog/cosmic-mcp-vs-strapi-mcp" rel="noopener noreferrer"&gt;Cosmic MCP Server vs Strapi MCP&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing at realistic team sizes
&lt;/h2&gt;

&lt;p&gt;Both vendors publish clear pricing, which is refreshing. The lists are not directly comparable, because Sanity's price includes hosting and Strapi's does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity, verified August 5, 2026&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free: $0, up to 20 seats, 2 public datasets, 10,000 documents, 250,000 API requests per month, 100 GB assets&lt;/li&gt;
&lt;li&gt;Growth: $15 per seat per month, up to 50 seats, private datasets, 25,000 documents, comments, tasks, scheduled publishing&lt;/li&gt;
&lt;li&gt;Enterprise: custom, adds SSO with SAML, custom roles, audit trail, uptime SLA&lt;/li&gt;
&lt;li&gt;Add-ons and overages: increased quota at $299 per month, dedicated support at $799 per month, extra datasets at $999 per dataset per month, additional API requests at $1 per 25,000, assets at $0.50 per GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Strapi, verified August 5, 2026&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Community: free forever, MIT license, unlimited entries and API calls, community support&lt;/li&gt;
&lt;li&gt;Growth: $45 per month including 3 seats, additional seats $15 each, adds Strapi AI, Live Preview, Releases, and 30 days of Content History&lt;/li&gt;
&lt;li&gt;SSO: a $150 per month add-on, plus $50 per month per seat&lt;/li&gt;
&lt;li&gt;Enterprise: custom, adds Review Workflows, Audit Logs, and a SOC 2 report&lt;/li&gt;
&lt;li&gt;Strapi Cloud hosting, priced per project: Starter $35, Pro $90, Business $450 per month, with additional environments at $60 or $300 per month depending on tier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A five-editor team, one production project&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;What is included&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sanity Growth&lt;/td&gt;
&lt;td&gt;$75 (5 x $15)&lt;/td&gt;
&lt;td&gt;Hosting, CDN, private dataset, 25k documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strapi Growth, self-hosted&lt;/td&gt;
&lt;td&gt;$75 license (3 seats + 2 x $15)&lt;/td&gt;
&lt;td&gt;License and features only. Your infrastructure, database, backups, and upgrade labor sit on top&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strapi Growth on Cloud Pro&lt;/td&gt;
&lt;td&gt;$75 license + $90 hosting&lt;/td&gt;
&lt;td&gt;Always-on runtime, weekly backups, multi-environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cosmic Team&lt;/td&gt;
&lt;td&gt;$299&lt;/td&gt;
&lt;td&gt;3 Buckets, 5 team members, 20,000 objects, fully managed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At five seats, Sanity Growth carries the lowest sticker price of the group, and we are not going to pretend otherwise. The number to watch on Sanity is per-seat scaling: twenty editors on Growth is $300 per month, and quota add-ons are priced in the hundreds. The number to watch on Strapi is the engineering time that never appears on the invoice, plus per-project hosting if you run more than one property.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance and compliance
&lt;/h2&gt;

&lt;p&gt;Sanity puts collaboration features on Growth: comments, tasks, scheduled publishing, and 90 days of change review. SSO, custom roles, and audit trail are Enterprise.&lt;/p&gt;

&lt;p&gt;Strapi gives Community users Role-Based Access Control for free, which is unusually generous. Content History with 30 days of retention and Releases arrive on Growth. Review Workflows and Audit Logs are Enterprise, and SSO is a paid add-on with its own per-seat charge.&lt;/p&gt;

&lt;p&gt;If approval chains and audit trails are non-negotiable for you, price the Enterprise tier on both sides early. That is the point where headless CMS quotes stop being self-serve on either platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to choose Sanity
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You want structured content as a service and no infrastructure to run&lt;/li&gt;
&lt;li&gt;You have many editors and want a low per-seat entry point&lt;/li&gt;
&lt;li&gt;Your content graph is complex and Portable Text plus references earn their keep&lt;/li&gt;
&lt;li&gt;Real-time collaborative editing matters to your workflow&lt;/li&gt;
&lt;li&gt;Your team is willing to learn GROQ&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to choose Strapi
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Data residency, VPC isolation, or air-gapped deployment is a hard requirement&lt;/li&gt;
&lt;li&gt;You want an MIT-licensed codebase you can fork and extend without vendor permission&lt;/li&gt;
&lt;li&gt;Your plugin and customization needs go beyond configuration&lt;/li&gt;
&lt;li&gt;You have real DevOps capacity, or you are happy to pay Strapi Cloud for it&lt;/li&gt;
&lt;li&gt;Unlimited entries and API calls at zero license cost fits your scale&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where Cosmic fits
&lt;/h2&gt;

&lt;p&gt;Cosmic sits in the managed camp with Sanity, with a deliberately smaller surface area. You get a REST API and a TypeScript SDK, AI built into the editing workflow, and a dashboard that non-technical teammates can use on day one without a Studio deployment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBucketClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cosmicjs/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBucketClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bucketSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-bucket-slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-read-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cosmic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objects&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title,slug,metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No local server, no database to provision, no GROQ to learn. Vuetify, the Vue component library with more than four million monthly npm downloads, &lt;a href="https://www.cosmicjs.com/blog/vuetify-headless-cms-case-study" rel="noopener noreferrer"&gt;cut server response times from 300-400 ms to about 50 ms&lt;/a&gt; after moving to Cosmic, with a team small enough that nobody has time to babysit a CMS.&lt;/p&gt;

&lt;p&gt;The editorial side is the other half of it. As Maximilian Wuhr, Co-Founder at FINN, put it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cosmic is: us never having to ask a developer to change anything on the backend of our website.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cosmic is a Y Combinator W19 company, and pricing is public: Free at $0 with 1 Bucket, 2 team members, and 1,000 objects; Builder at $49 per month; Team at $299 per month; Business at $499 per month; Enterprise custom. Additional users are $29 per user per month. Full details are on the &lt;a href="https://www.cosmicjs.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Strapi actually free?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Community edition is free under the MIT license, with unlimited entries and API calls. Hosting, database, backups, monitoring, and upgrades are yours to fund. Growth features like Live Preview, Releases, Content History, and Strapi AI start at $45 per month with 3 seats included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Sanity charge per seat?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Growth is $15 per seat per month for up to 50 seats. The Free plan allows up to 20 seats with 2 public datasets and 10,000 documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Cosmic charge per seat?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Each plan includes a set number of team members, 2 on Free, 3 on Builder, 5 on Team, 10 on Business, and additional users are $29 per user per month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Cosmic support GraphQL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Cosmic offers a REST API and a JavaScript/TypeScript SDK. Sanity and Strapi both offer GraphQL, so if GraphQL is a hard requirement, that is a real point in their favor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I migrate off Strapi without rebuilding my front end?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually yes, if your front end talks to a content API through a thin data layer. We documented the process in &lt;a href="https://www.cosmicjs.com/blog/migrate-from-strapi-to-cosmic" rel="noopener noreferrer"&gt;Migrate from Strapi to Cosmic&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I am comparing against Contentful too?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We have those head to head as well: &lt;a href="https://www.cosmicjs.com/blog/sanity-vs-contentful" rel="noopener noreferrer"&gt;Sanity vs Contentful&lt;/a&gt;, &lt;a href="https://www.cosmicjs.com/blog/strapi-vs-contentful" rel="noopener noreferrer"&gt;Strapi vs Contentful&lt;/a&gt;, and the full field in our &lt;a href="https://www.cosmicjs.com/blog/headless-cms-comparison-2026-cosmic-contentful-strapi-sanity-prismic-hygraph" rel="noopener noreferrer"&gt;2026 headless CMS comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;Pick Strapi if owning the runtime is worth the operational bill, and be realistic about who on your team pays that bill. Pick Sanity if you want a hosted content platform with a strong modeling story and a cheap seat, and budget for per-seat growth and quota add-ons. Pick Cosmic if you want managed infrastructure, a REST API with a TypeScript SDK, and AI in the editing workflow without a Studio to deploy or a Node app to patch.&lt;/p&gt;

&lt;p&gt;You can test the third option in about ten minutes. &lt;a href="https://app.cosmicjs.com/signup?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=sanity-vs-strapi" rel="noopener noreferrer"&gt;Start building on Cosmic for free&lt;/a&gt;, no credit card required. If you are mid-evaluation and want a second opinion on your specific stack, &lt;a href="https://calendly.com/tonyspiro/cosmic-intro?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=sanity-vs-strapi" rel="noopener noreferrer"&gt;book 20 minutes with our CEO&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pricing and feature details for Sanity and Strapi were verified against their public pricing pages on August 5, 2026. Vendor pricing changes often, so confirm current terms before signing anything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cms</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
