Webflow Cloud gives you a server right beside your site, which is exactly what it takes to bring UserVoice feedback onto your own domain safely.
The most common question from teams running UserVoice alongside a Webflow marketing site is some version of "how do I show our roadmap and idea list on our own domain instead of a uservoice.com subdomain?"
The instinct is to drop the UserVoice API key into a custom code embed and fetch ideas straight from the browser. That approach breaks the moment you ship it, because every line of client-side JavaScript is visible in view-source, and a UserVoice Admin API key grants full read and write access to your account.
The fix is a small server between your Webflow site and UserVoice that holds the credentials and exposes only the responses the page needs.
This guide walks through the whole thing: the trusted API client, the token exchange, the proxy Route Handler, the portal UI, a Webflow CMS sync, and signed webhooks for near-real-time updates.
What do you need to build a UserVoice feedback portal on Webflow Cloud?
You need a UserVoice account with a trusted API client, a Webflow Cloud app running Next.js, and permission to install the Webflow Cloud GitHub App on your repository.
There is no separate serverless host to provision and no SDK to install, because both the UserVoice Admin API and the Webflow Data API are plain REST endpoints that the edge runtime can reach with fetch.
Here is the full checklist I run through before writing any code:
- A UserVoice account where you are an admin, plus your subdomain (the
SUBDOMAINinSUBDOMAIN.uservoice.com) - A trusted API client in UserVoice, giving you an API key and secret
- A Webflow Cloud app running Next.js 15 or higher on the edge runtime
- A GitHub account with permission to install the Webflow Cloud GitHub App
- Permission to create apps in your Webflow Workspace
- Node.js 22 or higher locally for development
- A Webflow CMS collection if you plan to mirror feedback into the CMS (Step 5)
Once these are in place, the integration is mostly about moving a token around safely. The sections below start with the UserVoice client and end with webhooks, and each step builds on the environment variables set in the previous one.
6 steps to build a custom UserVoice feedback portal on Webflow Cloud
The build splits into two halves. The first half (Steps 1 to 4) gets a live portal on screen: create the API client, store the credentials in Webflow Cloud, proxy the Admin API from an edge Route Handler, and render the ideas in a Client Component.
The second half (Steps 5 and 6) makes the portal durable and current: mirror feedback into the Webflow CMS so it is indexable, and verify signed webhooks so the page reflects new ideas without a full re-sync.
Read the steps in order the first time through. Token handling in Step 3 is what everything else depends on, and Steps 5 and 6 both reuse the same proxy pattern rather than opening new connections to UserVoice.
1. Create a trusted UserVoice API client
Start in the UserVoice Admin Console, because the API key you generate here is what authorizes every server-side call in this guide. Open Settings, then Integrations, then UserVoice API keys, and click "Add API Key…". Name the key so you will recognize it later, leave the "APPLICATION URL" and "CALLBACK URL" fields blank, and check the "Trusted" box.
Trusted is the part people miss: without it, the key cannot use the client credentials grant that a server-to-server integration relies on. Click "Add API key" to finish.
UserVoice shows you two values: an API key and an API secret. In OAuth terms, the API key is your client_id and the API secret is your client_secret. Copy both into a password manager immediately, because the secret is what lets anyone mint admin tokens for your account. You will also need your subdomain, which is the first label of your UserVoice URL.
If your portal lives at acme.uservoice.com, your subdomain is acme. Keep all three values handy for the next step, where they become environment variables instead of hard-coded values.
2. Add your credentials to Webflow Cloud
Next, move those three values into Webflow Cloud so they live on the server and never ship to the browser. In your Webflow site settings, open Webflow Cloud, select your app, and select the environment you are configuring.
Open the Environment variables tab, click Add Variable for each value, and toggle the Secret option on for the API key and secret.
The secret toggle matters because it keeps the value out of build logs and the read-back UI, which is the whole point of not putting it in client code.
Add the variables under these names so the code samples below match your setup:
USERVOICE_SUBDOMAIN=acme
USERVOICE_CLIENT_ID=your_api_key
USERVOICE_CLIENT_SECRET=your_api_secret
USERVOICE_SSO_KEY=your_sso_key
Those names intentionally carry no NEXT_PUBLIC_ prefix. Anything prefixed NEXT_PUBLIC_ is inlined into the browser bundle, and these four values must stay server-side. USERVOICE_SSO_KEY isn't needed yet, but Step 6 uses it to verify webhook signatures, so I'm adding it now to avoid a second deploy later.
Mirror the same variables into your local .env.local for development. With the credentials in place, the server can authenticate, which is what the proxy in the next step does on every request.
3. Build the OAuth token and proxy Route Handler
This Route Handler is the core of the integration, so it does three jobs at once: it exchanges your client credentials for an access token, it caches that token, and it forwards a narrow allowlist of read requests to the UserVoice Admin API.
Because it runs on the edge runtime, it uses fetch for both the token exchange and the proxied call, with no Node-specific HTTP library involved.
The token exchange is a single POST to the UserVoice OAuth endpoint with the client credentials grant.
The handler below requests a token, holds it in an isolate-level variable, and reuses it until UserVoice rejects it with a 401, at which point it clears the cache and fetches a fresh one:
// app/api/uservoice/[resource]/route.ts
export const runtime = 'edge'
import { NextResponse, type NextRequest } from 'next/server'
// Only these resources can be requested through the proxy.
const ALLOWED = new Set(['suggestions', 'forums', 'statuses'])
let cachedToken: string | null = null
async function getToken(force = false): Promise<string> {
if (cachedToken && !force) return cachedToken
const sub = process.env.USERVOICE_SUBDOMAIN!
const res = await fetch(`https://${sub}.uservoice.com/api/v2/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.USERVOICE_CLIENT_ID!,
client_secret: process.env.USERVOICE_CLIENT_SECRET!,
}),
})
if (!res.ok) throw new Error(`Token request failed: ${res.status}`)
const { access_token } = (await res.json()) as { access_token: string }
cachedToken = access_token
return access_token
}
export async function GET(
request: NextRequest,
{ params }: { params: { resource: string } }
) {
const { resource } = params
if (!ALLOWED.has(resource)) {
return NextResponse.json({ error: 'Unknown resource' }, { status: 400 })
}
const sub = process.env.USERVOICE_SUBDOMAIN!
const url = `https://${sub}.uservoice.com/api/v2/admin/${resource}?per_page=50`
const call = (token: string) =>
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
// Try the cached token, then refresh once on a 401.
let token = await getToken()
let upstream = await call(token)
if (upstream.status === 401) {
token = await getToken(true)
upstream = await call(token)
}
if (!upstream.ok) {
return NextResponse.json(
{ error: 'UserVoice request failed' },
{ status: upstream.status }
)
}
const data = await upstream.json()
return NextResponse.json(data)
}
The allowlist is the security boundary that makes this safe to expose. The browser sends a resource name such as suggestions, and the handler refuses anything not in the ALLOWED set, so a curious visitor cannot rewrite the path to reach users or a write endpoint.
The token itself never leaves the server, and the 401 retry means a token that UserVoice expires or revokes triggers a single clean refresh rather than a wall of failed requests.
One thing you should know: on the edge runtime, the module-level cachedToken lives only as long as a warm isolate, so under low traffic you will occasionally pay for a fresh token exchange.
For heavier portals, move the cache into Webflow Cloud's Key Value Store so the token survives across the same durable storage a serverless Webflow Cloud app leans on. With the proxy live, the front end can read UserVoice data without ever seeing a credential.
4. Render the feedback portal in a Client Component
With the proxy in place, the portal UI becomes an ordinary fetch against your own domain. The Client Component below requests the suggestions resource, which UserVoice calls submitted ideas, and renders each one with its vote count.
Notice that it calls the proxy path, not uservoice.com, so the browser only ever talks to your own app:
'use client'
import { useEffect, useState } from 'react'
type Suggestion = {
id: number
title: string
votes_count: number
}
export function FeedbackPortal({ basePath = '' }: { basePath?: string }) {
const [ideas, setIdeas] = useState<Suggestion[]>([])
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
useEffect(() => {
fetch(`${basePath}/api/uservoice/suggestions`)
.then((res) => (res.ok ? res.json() : Promise.reject(res.status)))
.then((data) => {
setIdeas(data.suggestions ?? [])
setStatus('ready')
})
.catch(() => setStatus('error'))
}, [basePath])
if (status === 'loading') return <p>Loading feedback…</p>
if (status === 'error') return <p>Could not load feedback right now.</p>
return (
<ul className="feedback-list">
{ideas.map((idea) => (
<li key={idea.id}>
<span className="votes">{idea.votes_count}</span>
<span className="title">{idea.title}</span>
</li>
))}
</ul>
)
}
The one Webflow Cloud detail that trips people up is the basePath prop. A Webflow Cloud app is served under a mount path such as /app, and client-side fetch calls do not get that prefix added for you the way <Link> and useRouter() do.
If you call /api/uservoice/suggestions without the base path, the request resolves against the site root and returns your marketing 404 instead of JSON. Pass the mount path in as basePath and the fetch resolves correctly in both local development and production. At this point, you have a working portal reading live from UserVoice.
I reuse the same pattern (build a full-stack Webflow Cloud app around an edge proxy) across most integrations, and it is worth reading the full-stack Webflow Cloud app walkthrough if you want the broader architecture.
5. Sync UserVoice feedback to the Webflow CMS
Reading live from UserVoice works for a logged-in dashboard, but search engines and large language models cannot see content that appears only after a client-side fetch. To make your roadmap indexable, mirror each idea into a Webflow CMS collection so it renders as static HTML.
This is where the portal starts earning organic traffic instead of just serving existing users.
The sync is a second Route Handler that reads from UserVoice through the same token logic and writes to the Webflow Data API. Create a collection with name and slug fields plus a number field for votes, then map each UserVoice suggestion onto a CMS item.
The handler below creates items directly on the live site using the Data API's live-items endpoint:
// app/api/sync/route.ts
export const runtime = 'edge'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
// Protect the sync endpoint with a shared secret.
if (request.headers.get('authorization') !== `Bearer ${process.env.SYNC_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Read ideas through the proxy you already built.
const origin = new URL(request.url).origin
const uvRes = await fetch(`${origin}/api/uservoice/suggestions`)
const { suggestions } = (await uvRes.json()) as {
suggestions: { id: number; title: string; votes_count: number }[]
}
const fieldData = suggestions.map((s) => ({
name: s.title,
slug: `idea-${s.id}`,
votes: s.votes_count,
}))
const wfRes = await fetch(
`https://api.webflow.com/v2/collections/${process.env.WEBFLOW_COLLECTION_ID}/items/live`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WEBFLOW_SITE_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items }),
}
)
if (!wfRes.ok) {
return NextResponse.json({ error: 'CMS write failed' }, { status: wfRes.status })
}
return NextResponse.json({ synced: items.length })
}
Two things in that handler are worth calling out. First, the Data API creates items rather than updating them, so posting the same ideas again doesn't refresh the originals. Webflow keeps slugs unique by appending a suffix, which means a naive re-run fills the collection with duplicates.
To keep the sync idempotent, store each idea's Webflow item id, keyed by its UserVoice id, in the Key Value Store, then PATCH the existing item on later runs and create only the ideas you have not seen before. Deriving a stable key such as idea-123 from the UserVoice ID lets you match the two.
Second, the handler posts to /items/live, so ideas publish immediately, though you can switch to the staged /items endpoint if you would rather review before publishing.
Add WEBFLOW_COLLECTION_ID, WEBFLOW_SITE_TOKEN, and SYNC_SECRET to your environment variables first. If you are new to writing to collections this way, the Webflow CMS API guide covers tokens and field mapping in more depth.
Once the sync runs, your feedback lives in the CMS, and the only thing left is keeping it current.
6. Verify signed webhooks for real-time updates
Running the sync on a timer works, but it either lags reality or hammers the API. UserVoice service hooks close that gap by pushing an event the moment something changes, and because those requests arrive from the public internet, signature verification isn't optional.
This is a spot where Webflow Cloud's setup shapes the code, so read the explanation before copying it.
In the UserVoice Admin Console, open Integrations, then Service hooks, and add a custom webhook pointing to your handler, choosing events such as New Idea and Idea Votes Update. UserVoice signs the payload by computing HMAC-SHA256 over the data parameter using your SSO key, then sending the result in a signature parameter
Webflow Cloud enables Node.js compatibility by default, so Node's crypto.createHmac is available if you want it.
Webflow's own guidance recommends the Web Crypto API (crypto.subtle) for hashing, and because it is a Web platform standard, it behaves the same in local development and on the edge, so this guide verifies with it:
// app/api/webhooks/uservoice/route.ts
export const runtime = 'edge'
import { NextResponse, type NextRequest } from 'next/server'
async function isValid(data: string, signature: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(process.env.USERVOICE_SSO_KEY!),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data))
const expected = btoa(String.fromCharCode(...new Uint8Array(mac)))
// Constant-time compare to avoid leaking timing information.
if (expected.length !== signature.length) return false
let diff = 0
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
}
return diff === 0
}
export async function POST(request: NextRequest) {
const form = await request.formData()
const data = String(form.get('data') ?? '')
const signature = String(form.get('signature') ?? '')
if (!(await isValid(data, signature))) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const event = JSON.parse(data)
// Trigger a targeted re-sync or cache purge here using `event`.
return NextResponse.json({ received: true })
}
The signature check recomputes the HMAC with crypto.subtle.sign and compares it to what UserVoice sent, and the manual byte-by-byte loop is a constant-time comparison that avoids the timing side channel a naive === would open.
The reason to reach for crypto.subtle here is that it is part of the Web platform and behaves the same everywhere your code runs, which keeps the verifier portable across local development, the edge, and any other runtime you move it to later.
Once the signature passes, you can parse the data payload and refresh just the affected idea rather than re-syncing the whole collection. That keeps you comfortably inside the API rate limits, which is the failure mode the next section opens with.
What breaks a UserVoice and Webflow Cloud integration?
Most failures come from four predictable places: the trusted flag was never checked, so tokens are rejected; an environment variable was never added to the deployed environment; a bulk sync tripped a rate limit; or the base path was dropped from a client fetch.
Each produces a distinct symptom, and none are subtle once you know where to look.
The list below pairs the symptom you will actually see with the cause and the fix, in the order I tend to hit them on a new build.
The token request returns 401 invalid_client
If the OAuth token exchange fails with invalid_client, the API client is almost always missing the trusted flag. UserVoice only permits the client credentials grant for clients created with "Trusted" checked, so a key generated without it authenticates for interactive flows but not for server-to-server calls.
Delete the key, create a new one, and confirm the "Trusted" box is checked before saving. The other common cause is a copy-paste error in the secret, since a trailing space survives a paste into an environment variable and silently breaks the match.
Re-enter the USERVOICE_CLIENT_SECRET value by hand in the Webflow Cloud Environment variables tab, redeploy, and the token exchange should succeed.
The proxy works locally but returns a 500 on Webflow Cloud
When a handler runs fine in local development and then returns a 500 in production, the most common cause is an environment variable that exists in your local .env.local but was never added to the deployed environment, or was added to a different environment than the one serving traffic.
The token exchange in Step 3 throws when USERVOICE_CLIENT_ID or USERVOICE_CLIENT_SECRET is undefined, and that surfaces as a 500. Open Webflow Cloud, select the environment that is live, and confirm every variable from Step 2 is present, with the API key and secret marked as secrets.
Node's crypto module, including crypto.createHmac and crypto.timingSafeEqual, is available on Webflow Cloud because Node.js compatibility is enabled by default, so a missing variable is far more likely than an unsupported API.
Wiring the proxy into Sentry error tracking surfaces these runtime failures with a stack trace instead of a bare 500.
UserVoice returns 429 during a CMS sync
A 429 from UserVoice means you exceeded the request limit for the current minute. UserVoice doesn't publish a fixed number because the ceiling depends on your plan, but it returns the ceiling live in the X-Rate-Limit-Limit header, the count left in X-Rate-Limit-Remaining, and a Retry-After value when you are over.
The fix is to stop making one request per idea. Raise per_page so a single call returns up to fifty or a hundred records, use side-loading to pull associated records in the same response,and, when you do get a 429, read Retry-After, which UserVoice returns as a Unix epoch timestamp in seconds, and wait until that time before retrying rather than looping immediately.
The webhook approach in Step 6 sidesteps this entirely for updates, since you re-sync one changed idea instead of the whole list.
The portal loads but the feedback list is empty
If the component renders but shows no ideas, open your browser's network tab and check the request to /api/uservoice/suggestions. A response that is HTML rather than JSON, or a 404, points at a missing base path: the fetch resolved against the site root instead of your Webflow Cloud app mount path.
Pass the app's mount path into the component as basePath, as in Step 4, so the request reaches your Route Handler.
If the response is valid JSON but the array is empty, confirm the resource is spelled suggestions and that the forum actually contains public ideas, since UserVoice scopes suggestions to forums and a brand-new instance can legitimately return an empty set.
Take the portal further with SSO and search
At this point you have a feedback portal on your own domain, indexed in the CMS, and staying current through signed webhooks. The natural next step depends on who your portal is for. If it is a logged-in customer portal, you will want single sign-on so visitors are recognized without a second login.
UserVoice supports JWT SSO signed with HS256 using the same SSO key from Step 6, carrying a guid and email claim, and an edge-compatible library like jose signs that token inside a Route Handler without any Node dependency.
Routing your existing session into that token is the same pattern as any Auth0 authentication flow you may already run.
If the portal is public and content-heavy, prioritize discovery. Once ideas live in the CMS, layering full-text search over the collection lets visitors find prior requests before filing duplicates, and a real-time dashboard view turns raw votes into something a product team will actually open.
If your portal also accepts new ideas through a Webflow form, add reCAPTCHA spam protection before those submissions reach UserVoice. For anything beyond what these Route Handlers cover, the custom code API guide and the Webflow developer docs document the full surface, from webhook management to bulk CMS operations.
Frequently asked questions
Why can't I call the UserVoice API directly from Webflow custom code?
Because Webflow custom code runs entirely in the browser, and any API key you embed there is visible in view-source. A UserVoice Admin API key grants full account access, so it must stay on a server. Webflow Cloud gives you that server inside the same project.
Do I need a paid UserVoice plan for API access?
API access and its rate ceiling depend on your UserVoice plan and terms of service, not a single published number. Confirm your current limits in your account, since the Admin API returns your live ceiling in the X-Rate-Limit-Limit response header on every request.
Can I run this on Astro instead of Next.js?
Yes. Webflow Cloud supports Astro 6 and 7 in addition to Next.js 15 and higher. The proxy logic is identical, since it relies on fetch and the Web Crypto API rather than any framework feature. Only the route file location and handler signature differ between the two.
Why does my webhook handler fail only in production?
Most often, a required environment variable is missing from the deployed environment. Your USERVOICE_SSO_KEY may sit in local .env.local but was never added in Webflow Cloud, so the signature check throws only in production. Confirm every variable from Step 2 exists in the live environment. Node's crypto module is supported on Webflow Cloud, so it is rarely the cause.
How do I keep the CMS from filling with duplicate ideas?
The Data API creates items rather than upserting by slug, so re-posting the same ideas would append duplicates. Keep a map from each UserVoice id to its Webflow item id in the Key Value Store, then PATCH the existing item on later runs and create only ideas you have not synced before.
Should I sync on a schedule or use webhooks?
Use webhooks for updates and a schedule only as a safety net. Webhooks push each change the instant it happens, so you re-sync a single idea and stay well under the rate limit. A nightly full sync then catches anything a missed webhook left behind.




