How to build an AI content generator for Webflow

Learn how to build an AI content generator that writes Webflow CMS drafts via a Cloud App Route Handler.

How to build an AI content generator for Webflow

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

The fastest way to turn editor prompts into Webflow CMS drafts is to let OpenAI generate the content and the Webflow Data API store it, with a Route Handler stitching them together server-side. The result is a generator your content team can use without your OpenAI bill becoming a liability.

How to build an AI content generator that writes to Webflow CMS with Webflow Cloud

A standalone public Worker with Access-Control-Allow-Origin: * and a "Save to CMS" button is the most common shape I see AI content generators take on Webflow, and it's the riskiest one to ship.

The Worker URL is discoverable, the /save route has no authentication, and anyone who finds the endpoint can POST arbitrary content into the CMS until you rotate the token and notice the damage.

Webflow Cloud App Route Handlers fix half of this. The OpenAI API key and Webflow API token live in server-side environment variables, so neither shows up in client code, and the Route Handler sits on your Webflow site's domain, so there's no CORS configuration and no separate Worker URL to keep track of.

The endpoint itself is still publicly reachable, though, which means a Route Handler alone isn't enough. The rest of this guide adds a shared-secret header for a working prototype, and points to session-based authentication as the production-grade fix.

What do you need to build an AI content generator with Webflow Cloud?

You need a Webflow Cloud App, an OpenAI API key, a Webflow API token scoped to your site, and a Webflow CMS collection to receive the generated content.

Be sure to confirm these prerequisites before starting:

  • AnOpenAI API access: In this guide, we use gpt-5.6-luna, the cost-sensitive tier of OpenAI's current generation, which is more than capable for blog intros, product descriptions and similar content. Set a monthly spend limit before writing any code
  • A Webflow site on a plan that includes the CMS: CMS writes go through the Data API, so the site needs CMS capacity. Note that the old CMS and Business site plans merged into Premium in 2026, so that is the tier to look for rather than a plan called CMS
  • A sense of what generation will cost: OpenAI's pricing is per token, and a generator your team uses daily adds up differently than one you test twice
  • AWebflow Cloud Appalready scaffolded: Using Next.js (this guide uses Next.js App Router, but the same approach works with Astro); run webflow auth login && webflow cloud init if you haven't yet.
  • A Webflow Data API token with write access to your site: Generate one at Site settings → Apps & integrations → API access

Once these are in place, you can proceed with the build.

4 steps to build an AI content generator with Webflow Cloud

The Cloud App handles generation, and CMS writes server-side. Webflow handles the generator UI. These steps build each piece in order: CMS schema first, then credentials, then the Route Handlers, then the frontend.

Let’s dive into the steps.

1. Create the Webflow CMS collection for generated content

Before writing any code, build the CMS collection that will receive generated content. In the Webflow Designer, open the CMS panel and click New Collection.

Name it "Generated Content" (or whatever fits your use case) and add these fields:

  • Name (Plain Text, required): The article or content title
  • Slug (auto-generated from Name)
  • Topic (Plain Text): The prompt topic the editor entered
  • Body (Rich Text): The AI-generated content
  • Tone (Plain Text): Professional, Conversational, Technical. Deliberately plain text rather than an Option field, for the reason in step 3

Use Rich Text for the Body field if your generated output will include headings or lists. Plain Text works for shorter outputs, such as meta descriptions or product taglines.

Save the collection. Note the Collection ID from the URL in your Webflow dashboard. It appears in the path as /cms/[collection-id]. You need it as an environment variable in the next step.

Checkpoint: The CMS collection exists in your project with the five fields listed above. Review status is handled by isDraft on the API write rather than by a CMS field, so there is no Status field to maintain. The collection is empty. Content flows in from the generator in Step 3.

2. Add credentials to your Webflow Cloud environment

Your Cloud App needs four environment variables.

Add them to .env.local for local development:

# .env.local
OPENAI_API_KEY=sk-...your_openai_key
WEBFLOW_API_TOKEN=your_webflow_data_api_token
WEBFLOW_COLLECTION_ID=your_collection_id_here
GENERATOR_SECRET=a-long-random-string-you-choose

In Webflow Cloud, open the Deployments Dashboard for your environment, click Environment Variables, and add the same four keys. Mark OPENAI_API_KEY, WEBFLOW_API_TOKEN, and GENERATOR_SECRET as Secrets so they're encrypted and masked in the dashboard.

GENERATOR_SECRET is a shared secret the Webflow form sends in an X-Generator-Secret header on every request. The Route Handler checks it before doing anything. This prevents arbitrary public requests from hitting your generation or CMS-write routes.

For a production deployment used by your whole team, pair this with proper session-based authentication.

Checkpoint: Run next dev locally and confirm process.env.OPENAI_API_KEY and process.env.WEBFLOW_API_TOKEN resolve.

3. Build the generate and save Route Handlers

Create two Route Handlers in your Cloud App. The first calls OpenAI and returns generated content. The second takes that content and creates a draft CMS item via the Webflow Data API.

One thing to leave out, because it is the most common way these handlers get broken: do not add export const runtime = 'edge' to them. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter's setup guide says to remove the directive because the Next.js Edge runtime isn't supported.

Your Route Handlers should use the Node.js runtime, which is the default and which gives you the Node APIs that the Workers runtime provides.

This is worth stating plainly because Webflow's own bring-your-own-app page still tells you to add the directive for API routes. If you follow that and the adapter's guide at the same time, you get a contradiction; the adapter builds your app, so follow it.

Both handlers validate the GENERATOR_SECRET header before processing anything:

// app/api/generate/route.ts
// No `export const runtime = 'edge'` here. See the note below.

function authorized(request: Request): boolean {
  return (
    request.headers.get('X-Generator-Secret') ===
    process.env.GENERATOR_SECRET
  )
}

export async function POST(request: Request) {
  if (!authorized(request)) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { topic, tone, contentType } = await request.json()

  if (!topic?.trim()) {
    return Response.json({ error: 'Topic is required' }, { status: 422 })
  }

  const instructions = `You are a professional content writer. Write ${contentType ?? 'a blog introduction'} in a ${tone ?? 'professional'} tone. Be specific, avoid filler phrases, and write for a developer-savvy audience. Target length: 300 to 400 words.`

  const res = await fetch('https://api.openai.com/v1/responses', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-5.6-luna',
      instructions,
      input: `Write about: ${topic}`,
    }),
  })

  if (!res.ok) {
    const err = await res.text()
    console.error('OpenAI error:', err)
    return Response.json({ error: 'Generation failed' }, { status: 502 })
  }

  const data = await res.json()

  // `output_text` is a convenience property the official SDKs add, not
  // a field of the raw JSON. Calling the endpoint with bare fetch means
  // walking the output array yourself.
  const content = (data.output ?? [])
    .filter((item: { type?: string }) => item.type === 'message')
    .flatMap((item: { content?: { type?: string; text?: string }[] }) => item.content ?? [])
    .filter((part: { type?: string }) => part.type === 'output_text')
    .map((part: { text?: string }) => part.text ?? '')
    .join('')
    .trim()

  if (!content) {
    return Response.json({ error: 'No content returned from OpenAI' }, { status: 502 })
  }

  return Response.json({ content })
}

The handler validates the secret, parses the JSON body, requires a topic, builds an instructions string from the tone and content type, and posts to /v1/responses.

The text extraction is the part worth reading twice. If you have used the OpenAI SDK, you will reach for data.output_text, and it will be undefined here: the official SDKs add that property rather than the endpoint returning it. With raw fetch, you walk the output, take the message item, and join its output_text parts.

Do not assume the text sits at output[0].content[0].text either, since the array can include other item types before the message.

On a 502 or a missing output_text, it logs the OpenAI error in your server logs and returns a generic message to the client. There's no upside to exposing OpenAI's raw error text to a public endpoint. The generated content comes back as JSON, ready for the save handler to pick up.

The save-to-CMS Route Handler

The save handler does the same secret check, validates the title and content, derives a slug, and posts to the Webflow Data API.

It marks the item as a draft so nothing reaches the live site without a human review pass:

// app/api/save-to-cms/route.ts
function authorized(request: Request): boolean {
  return (
    request.headers.get('X-Generator-Secret') ===
    process.env.GENERATOR_SECRET
  )
}

export async function POST(request: Request) {
  if (!authorized(request)) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { title, topic, content, tone } = await request.json()

  if (!title?.trim() || !content?.trim()) {
    return Response.json({ error: 'Title and content are required' }, { status: 422 })
  }

  const slug = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    // Note the + and the g: /^-|-$/ replaces only the first match,
    // so a title with punctuation at both ends keeps a stray hyphen.
    .replace(/^-+|-+$/g, '')

  const wfRes = await fetch(
    `https://api.webflow.com/v2/collections/${process.env.WEBFLOW_COLLECTION_ID}/items`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.WEBFLOW_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fieldData: {
          name: title,
          slug,
          topic: topic ?? title,
          body: content,
          tone: tone ?? 'Professional',
        },
        isDraft: true,
      }),
    }
  )

  if (!wfRes.ok) {
    const err = await wfRes.text()
    console.error('Webflow CMS error:', err)
    return Response.json({ error: 'CMS write failed' }, { status: 502 })
  }

  const item = await wfRes.json()
  return Response.json({ success: true, itemId: item.id })
}

A few things I always do in these handlers that matter in production:

  • The authorized() check happens before await request.json(). Parsing the body of an unauthorized request wastes CPU and can expose error messages about malformed payloads to whoever is probing the endpoint.
  • isDraft: true on every CMS write. A single poorly-formed OpenAI response can produce incoherent content, and the manual review step in the Webflow CMS Editor catches problems before they reach live pages. Setting isDraft: false does not publish the item, which surprises people.

On the staged endpoint, it moves the item to "will be published on the next site-wide publish" rather than making it live; to go live immediately, use the /items/live endpoint or a subsequent publish call. Either way, keep the draft gate during initial rollout.

  • tone is written as plain text on purpose. Webflow Option fields don't accept the option's display name; they take the option's ID, which you have to look up from Get Collection Details and map yourself. Sending "Professional" into an Option field is rejected, and it is the likeliest way this save handler fails in practice.

If you want Tone as a real Option field, fetch the option IDs once and keep a name-to-id map on the server.

  • POST /v2/collections/{collection_id}/items is the staged-item endpoint, which is what you want for drafts. Note that it accepts either a single top-level fieldData or an items array, so it isn't a strictly single-item route; the separate endpoint is /items/bulk, which exists for multi-locale creation.

Checkpoint: With the dev server running, test the generate route:

curl -X POST http://localhost:3000/YOUR_MOUNT_PATH/api/generate \
  -H "Content-Type: application/json" \
  -H "X-Generator-Secret: your-generator-secret" \
  -d '{"topic":"Webflow CMS best practices","tone":"Professional","contentType":"blog introduction"}'

Replace YOUR_MOUNT_PATH with the mount path you set when creating the environment (commonly /app). You should get back { "content": "..." } with generated text. Without the secret header, you should get { "error": "Unauthorized" }.

4. Build the generator UI in Webflow

While you're testing this pattern, turn on password protection for the generator page: open Page settings and toggle it in the General section, not under SEO. Note that it requires a paid Site plan, so if you are on Starter, you will need an alternative way to keep the page out of reach during testing.

Add this to a Custom Code (footer code, before </body>) block on your generator page. The page needs a form with three input fields (attributes data-gen-topic, data-gen-tone, data-gen-type) and output elements (data-gen-output for the content preview, data-gen-title for the title input, data-gen-save for the Save button).

The script wires up the form's submit handler to call /api/generate, renders the result into the output element, and exposes the Save to CMS button only after generation succeeds.

Both fetch calls send the X-Generator-Secret header:

(function () {
  const GENERATE_URL = '/app/api/generate'
  const SAVE_URL = '/app/api/save-to-cms'
  const SECRET = document.querySelector('[data-generator-secret]')?.dataset.generatorSecret ?? ''

  const form = document.querySelector('[data-gen-form]')
  const outputEl = document.querySelector('[data-gen-output]')
  const titleInput = document.querySelector('[data-gen-title]')
  const saveBtn = document.querySelector('[data-gen-save]')

  if (!form || !outputEl) return

  let lastContent = ''
  let lastTone = ''

  form.addEventListener('submit', async function (e) {
    e.preventDefault()

    const topic = form.querySelector('[data-gen-topic]')?.value.trim()
    const tone = form.querySelector('[data-gen-tone]')?.value
    const contentType = form.querySelector('[data-gen-type]')?.value

    if (!topic) return

    const submitBtn = form.querySelector('[type="submit"]')
    if (submitBtn) submitBtn.disabled = true
    outputEl.textContent = 'Generating...'
    if (saveBtn) saveBtn.style.display = 'none'

    try {
      const res = await fetch(GENERATE_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Generator-Secret': SECRET,
        },
        body: JSON.stringify({ topic, tone, contentType }),
      })

      const data = await res.json()

      if (!res.ok || data.error) {
        outputEl.textContent = `Error: ${data.error ?? 'Generation failed'}`
        return
      }

      lastContent = data.content
      lastTone = tone
      outputEl.textContent = lastContent

      if (titleInput) titleInput.value = topic
      if (saveBtn) saveBtn.style.display = 'block'
    } catch {
      outputEl.textContent = 'Network error. Check the browser console.'
    } finally {
      if (submitBtn) submitBtn.disabled = false
    }
  })

  if (saveBtn) {
    saveBtn.addEventListener('click', async function () {
      if (!lastContent) return

      const title = titleInput?.value.trim() || form.querySelector('[data-gen-topic]')?.value.trim()
      saveBtn.disabled = true
      saveBtn.textContent = 'Saving...'

      try {
        const res = await fetch(SAVE_URL, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Generator-Secret': SECRET,
          },
          body: JSON.stringify({
            title,
            topic: title,
            content: lastContent,
            tone: lastTone,
          }),
        })

        const data = await res.json()
        saveBtn.textContent = data.success ? 'Saved to CMS' : 'Save failed — check console'
        if (!data.success) console.error('CMS save error:', data)
      } catch {
        saveBtn.textContent = 'Save failed — check console'
      } finally {
        saveBtn.disabled = false
      }
    })
  }
})()

Notice the script reads the secret from a data-generator-secret attribute on a hidden element rather than hardcoding it. Add a hidden div to the page with data-generator-secret="your-secret-value".

This is still visible in the page source, which is why GENERATOR_SECRET alone is not a complete security model. For a production tool accessible to your whole content team, add session-based authentication via the Cloud App and verify the auth cookie in the Route Handlers.

Checkpoint: Publish your Webflow site. Fill in the topic, select a tone and content type, and click Generate. The output area should populate within 3 to 8 seconds. Click Save to CMS and confirm a new draft item appears in the Generated Content collection in your Webflow CMS Editor.

What causes the AI generator to fail on Webflow Cloud?

Most failures come from three places:

  • The GENERATOR_SECRET not matching between the form and the Route Handler
  • The OpenAI Responses API returning an unexpected shape
  • The Webflow Data API rejecting the CMS write because of a field mapping mismatch

Let’s look into each.

Route Handler returns 401 on every request

The X-Generator-Secret header in the form's fetch call doesn't match process.env.GENERATOR_SECRET in the Route Handler.

Check two things: the secret value in .env.local matches what's in the Webflow Cloud environment variables panel, and the data-generator-secret attribute on the hidden element in the Webflow page matches the same value.

A trailing space or line break in the env var value causes a silent mismatch.

OpenAI returns a response, but output_text is undefined

The usual cause is reading an SDK-only property off a raw HTTP response. output_text is a convenience aggregation the official SDKs provide; the /v1/responses endpoint itself returns an output array, so data.output_text is undefined no matter how correct the rest of your call is.

Two ways out. Either extract the text from output as the handler above does, or install the openai package and call client.responses.create(...), at which point response.output_text exists because the SDK builds it for you.

Worth knowing the shapes so you can tell endpoints apart while debugging:

  • Chat Completions (/v1/chat/completions) takes messages and returns choices[0].message.content
  • The Responses API (/v1/responses) takes instructions and input, and returns an output array

If you are stuck, temporarily log the full response from OpenAI to your server console before the parsing logic runs. Often, the output array structure or the choices object will deviate slightly from your assumptions because of API version changes or specific model responses.

Seeing the raw JSON structure in your terminal is the fastest way to confirm whether your parsing logic needs to target a different path.

Webflow CMS write returns 400 or 422

Read the status carefully, because the two failures look similar and have different causes.

A 401 is the credential problem: a missing, malformed or wrong-scoped token. Confirm the header is Bearer {token} with a space, and that the token carries CMS:write rather than read-only CMS access.

A 400 means a malformed request, and the most common cause is a field name mismatch: the fieldData keys must match the slugs Webflow generated for each CMS field. Webflow doesn't return 422 for this, so if you are seeing one, it's coming from your Route Handler, not Webflow.

A 409 means a slug collision. Because the slug is derived from the title, and the title is pre-filled from the topic, generating it twice for the same topic triggers this. Item slugs must be unique within a collection, so append a short suffix when you retry.

A field named "Body Content" gets the slug body-content, not body or bodyContent. GET https://api.webflow.com/v2/collections/{collection_id} against the Webflow Data API to inspect the exact field slugs before sending a write request.

Build a complete content pipeline

For the CMS side in more depth, our CMS API guide covers reading, writing and publishing programmatically. With generation and CMS writes working, the natural next step is a review workflow. The Cloud App's /save-to-cms handler creates every item as a draft.

A Webflow webhook registered through the Create Webhook endpoint can fire on the collection_item_created event and notify a Slack channel when new drafts are ready for review.

You can create webhooks from Site settings → Apps & integrations → Webhooks and pick a trigger type there. The reason to prefer the API is different from what you may have read: dashboard-created webhooks do not send the headers needed to validate request signatures, so anything that needs verified delivery should be registered through the API.

If you'd rather not build the generator from scratch, Webflow + ChatGPT can walk you through the connector options.

Frequently asked questions

Can I call OpenAI directly from Webflow's custom code without a Cloud App?

You can make the fetch call, but your API key will be visible in the page source and every visitor's browser DevTools network tab. OpenAI's Chat Completions and Responses API endpoints require write-capable keys. Anyone who finds yours can generate content and charge it to your account until you rotate the key. The Cloud App keeps the key server-side where client code can't reach it, the same pattern our image generation proxy uses.

Do I need a paid Webflow plan to use the Data API for CMS writes?

Yes. The Starter free plan caps the CMS at 50 items, which makes it impractical for any real content-generation workflow. The Basic plan ($15/month, billed annually) has no CMS access. The realistic minimum is the Premium plan ($25/month, billed annually, $39/month monthly), which includes 20,000 CMS items, 40 CMS Collections, and the 120-requests-per-minute API rate limit required for batch generation work.

How do I prevent editors from saving low-quality output to the CMS?

Combine two strategies: keep isDraft: true in the Route Handler to enforce human review before publishing, and refine your system prompt. Specifying details like word count, required structure, and exclusions drives far better output quality than broad topics.

Can I use this same pattern to generate images and save them to Webflow CMS?

Yes, with one extra step. Use OpenAI's current image generation models (GPT-Image) instead of the superseded DALL-E or gpt-image-1. Since Webflow image fields accept an object with a publicly reachable url (e.g., { "my-image-field": { "url": "https://...", "alt": "..." } }), you can write directly to the CMS without a separate asset-library upload step.


Last Updated
September 12, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.