Visitor-created rows can live in a Supabase table behind a Webflow Cloud route on your own domain, while Webflow's CMS continues to hold published content.
A Webflow site can hold the collection items and page copy your team publishes. The records a visitor produces while using the site are a different kind of data. A waitlist signup with a referral code, or a vote on a roadmap item: these change by the minute and belong to the visitor.
Editors continue to own published site content. Visitor-created records need database constraints and access rules.
The build is a Next.js app deployed on Webflow Cloud and mounted at a path on your site's domain. Inside it, one Route Handler reads and writes a Supabase table using the service role key.
What do you need to use Supabase as a database backend in Webflow?
You need a Webflow site with Webflow Cloud, a Supabase project, a compatible Node.js and Next.js setup, and npm. Webflow Cloud starts on the free Starter site plan; custom-domain mounting requires Premium or higher.
Prepare these requirements before creating the database schema or application:
- Webflow site with Webflow Cloud: Included from the free Starter site plan up; mounting the app on a custom domain needs Premium or higher. Step 7 pastes a script into a Webflow page and publishing custom code needs a paid Site plan or a Core, Growth, Agency or Freelancer Workspace, so a free Starter site can run the route but not the page that calls it.
- Supabase project: Any project works here. Copy its project URL and its service role key from Settings > API Keys in the Supabase dashboard. Note that Supabase is deprecating the
anonandservice_rolekeys by the end of 2026 in favor of publishable (sb_publishable_...) and secret (sb_secret_...) keys.
The secret key is the drop-in replacement for the service role key used here, and both systems work side by side today, so a project started now can switch without rewriting the route.
- Node.js and Next.js: The bring-your-own-app requirements set Next.js 15 or higher and Node.js 22 or later. Astro and Vite apps also deploy, but this build assumes the App Router.
- npm: The only package manager Webflow Cloud supports. Use npm for installation and commit the
package-lock.jsonit produces.
With these components ready, the build hinges on creating the locked-down table before deploying the app and connecting the Webflow page.
7 steps to use Supabase as a database backend in Webflow Cloud
Build the integration in seven stages, starting with the protected Supabase table and ending with a Webflow page that reads and writes records through the mounted route.
Set up Supabase before building and deploying the app. Add the page integration after the mount path is live, since the page script needs a deployed URL.
1. Create the Supabase table and turn on Row Level Security
Create a committed SQL file that keeps the database schema next to the app that uses it. In the Supabase dashboard, open your project, choose the SQL Editor, enter a statement in the editor, and use the Run control to create a small entries table and turn on Row Level Security (RLS) in the same transaction.
Turning on RLS with no policies attached means the public anon key can read and write nothing, which is the state you want when the only caller is a server-side route. Supabase's own docs cover policy syntax if you later want controlled anon access.
Run this statement:
create table public.entries (
id bigint generated always as identity primary key,
title text not null,
created_at timestamptz not null default now()
);
alter table public.entries enable row level security;
After it runs, open the Table Editor and confirm entries appear with zero rows and RLS shown as active.
Then, in the Supabase dashboard, open your project, choose API settings, and copy the project URL and service role key values. The service role key bypasses RLS entirely, which is exactly why it belongs only in Webflow Cloud's secret storage and never in a NEXT_PUBLIC_ variable or a Designer embed.
I keep it out of the local .env.local until the route exists, so nothing leaks while the repo is still being set up. You should now have an empty, locked-down table and the two credentials the route will need.
2. Scaffold the Next.js app with npm
Run create-next-app from the Next.js 15 release line with the App Router and TypeScript, accept the defaults for the rest, and then install the Supabase client library into the new project. The generated lockfile gives Webflow Cloud a reproducible npm install.
Use these two commands for the setup:
npx create-next-app@15 webflow-supabase --typescript --app --no-src-dir --import-alias "@/*" --use-npm
cd webflow-supabase && npm install @supabase/supabase-js
Leave next.config alone. Webflow Cloud's bring-your-own-app page puts it as "No adapter, no base path, no wrangler.json," and says plainly: "Don't set basePath/assetPrefix (Next.js) or base/build.assetsPrefix (Astro) in your framework config." The platform injects the base path at build time, so a hand-written value fights that injection.
Save the project in your version-control workflow. Confirm that package-lock.json is at the root and @supabase/supabase-js appears under dependencies. Then run npm run dev and check that the default page loads on localhost:3000.
You should now have a working local Next.js app with the Supabase client installed through npm.
3. Create the Webflow Cloud project and mount path
Configure the Webflow Cloud environment for the site that will host the app. In Webflow Cloud, open the site, choose the project's environment, and enter a mount such as /app in the mount path field. The mount becomes the prefix for every route in the deployed app, so the Route Handler answers at /app/api/entries.
The mount path is what the base path injected at build time resolves to. Changing it later means changing every hardcoded path in your page script, so pick one you can live with before anything ships.
Confirm the configured environment and mount path by opening its URL. You should now have a Webflow Cloud project whose deployed routes resolve beneath the selected path.
4. Add a server-only Supabase client
Create a helper that reads two environment variables and throws immediately if either value is missing. Building the client inside a function surfaces a missing variable as a clear error when the route runs and prevents a failed import during the build.
Use the exact unprefixed variable names shown below.
Save this helper as lib/supabase.ts:
import { createClient } from '@supabase/supabase-js';
export function supabaseAdmin() {
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
throw new Error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set');
}
return createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
Turning off persistSession and autoRefreshToken is appropriate here because the service role key is a server credential with no user session to persist or refresh. Add SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to a local .env.local for development and confirm that the file is listed in .gitignore, which create-next-app does by default.
The importable supabaseAdmin() should now return a working client locally and fail loudly anywhere the credentials are absent.
5. Write the Route Handler without a runtime export
Create a plain App Router file with a GET for reading and a POST for writing, and leave out any runtime export. Body validation should stop a bad payload at a 400 before it reaches Postgres.
The directive the bring-your-own-app page recommends is the one line that guarantees a failed build on Webflow Cloud, so the absence is deliberate. The route is public on your domain the moment it deploys.
Save this complete file at app/api/entries/route.ts:
import { NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
export async function GET() {
const supabase = supabaseAdmin();
const { data, error } = await supabase
.from('entries')
.select('id, title, created_at')
.order('created_at', { ascending: false })
.limit(50);
if (error) {
console.error('entries select failed', error);
return NextResponse.json({ error: 'Could not load entries' }, { status: 500 });
}
return NextResponse.json({ entries: data });
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title || title.length > 200) {
return NextResponse.json({ error: 'title is required (max 200 chars)' }, { status: 400 });
}
const supabase = supabaseAdmin();
const { data, error } = await supabase
.from('entries')
.insert({ title })
.select('id, title, created_at')
.single();
if (error) {
console.error('entries insert failed', error);
return NextResponse.json({ error: 'Could not save entry' }, { status: 500 });
}
return NextResponse.json({ entry: data }, { status: 201 });
}
The .select().single() after the insert returns the created row, so the page can render it without a second request.
The sample endpoint requires more controls for production. GET and POST are unauthenticated, and POST performs no caller authorization. The route also has no rate limit, request quota, or spend cap.
Before production, require a Supabase Auth session checked in middleware.ts, or a per-form token, and authorize the caller before the insert. Add a rate limit or request quota and an enforceable spend or usage cap where available.
If you cannot enforce those controls, keep POST disabled and avoid exposing an open insert endpoint on a paid database. An open insert endpoint is a liability, and on an agency build it is the client's liability.
Run npm run dev and open http://localhost:3000/api/entries; you should get {"entries":[]}. Send a POST with {"title":"first"}, then request the GET again. The route should return 201 with the created row locally, reject an empty or oversized title with a 400, and show the insert in the Supabase Table Editor.
6. Set the environment variables in Webflow Cloud and deploy
In Webflow Cloud, open the app's environment, go to the environment variables section, add the three variables below before the first deploy, and turn on secret control for the service role key. Secrets remain available to the deployed app, though they appear redacted in build logs.
Both secret and non-secret variables are available to the build process and to the deployed app at runtime, so there is no reason to bake a credential into the repo for the build's sake.
Configure these three variables:
| Variable | Type | Value |
|---|---|---|
SUPABASE_URL |
Non-secret | The project URL from Settings > API Keys |
SUPABASE_SERVICE_ROLE_KEY |
Secret | The service role key; bypasses RLS, server-only |
NEXT_PUBLIC_BASE_PATH |
Non-secret | The mount path you chose for the app, such as /app |
| Variable → Type → Value |
|---|
SUPABASE_URL |
| Non-secret |
| The project URL from Settings > API Keys |
SUPABASE_SERVICE_ROLE_KEY |
| Secret |
| The service role key; bypasses RLS, server-only |
NEXT_PUBLIC_BASE_PATH |
| Non-secret |
The mount path you chose for the app, such as /app |
The third variable is here for later rather than for this build: nothing in the app reads it yet, because the only caller is the Webflow page script, which cannot see it and hardcodes the path instead.
Add it now because the platform handles the mount path for server-side routing but leaves client-side fetch alone, so the first React component you add inside the app will need it.
Any fetch inside a React component in the Next.js app should read process.env.NEXT_PUBLIC_BASE_PATH and prefix its URL with it, or the browser will ask the Webflow site for a path only the app knows. Save the variables, then trigger a deploy and watch the build log; the secret appears redacted.
When the deploy finishes, open https://your-domain.com/app/api/entries (substituting your domain and mount path) and you should see the same JSON the local server returned, now served from Webflow Cloud on the site's own domain.
7. Call the route from a Webflow page
Add a container and form to the Webflow page, then use the literal mount path configured for the deployed app. The published page cannot read environment variables from the app. The script fetches /app/api/entries, renders the titles, and posts a new title.
The request is same-origin, since the app is mounted on the same domain as the page, so the browser sends it without a preflight, and Supabase never sees the visitor's request directly.
Use this page code:
<div id="entries"></div>
<form id="entry-form">
<input name="title" maxlength="200" required />
<button type="submit">Add</button>
</form>
<script>
const BASE = '/app';
const list = document.getElementById('entries');
async function readJson(res) {
let data;
try {
data = await res.json();
} catch {
throw new Error('The server returned an invalid response');
}
if (!res.ok) {
throw new Error(data?.error || `Request failed with status ${res.status}`);
}
return data;
}
async function load() {
try {
const res = await fetch(`${BASE}/api/entries`);
const { entries } = await readJson(res);
if (!Array.isArray(entries)) {
throw new Error('The server returned an invalid entries list');
}
list.replaceChildren(
...entries.map((e) => {
const p = document.createElement('p');
p.textContent = e.title;
return p;
})
);
} catch (error) {
list.textContent = error instanceof Error ? error.message : 'Unable to load entries';
}
}
document.getElementById('entry-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const title = new FormData(ev.target).get('title');
try {
const res = await fetch(`${BASE}/api/entries`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const { entry } = await readJson(res);
if (!entry) {
throw new Error('The server did not return the created entry');
}
ev.target.reset();
await load();
} catch (error) {
list.textContent = error instanceof Error ? error.message : 'Unable to add the entry';
}
});
load();
</script>
Setting text with textContent is deliberate because visitors write these titles. This method prevents a title containing markup from executing on your page.
Change BASE if you mounted the app somewhere other than /app. Publish the site, open the page, and you should see the row you inserted locally along with any you add through the form, each one visible in the Supabase Table Editor after a refresh.
The page now reads and writes a Postgres table on your own domain while the Designer still owns the layout around it.
What causes a Supabase backend on Webflow Cloud to fail?
Most failures fall into four categories: an unsupported runtime export, a missing mount-path prefix, incorrect deployed credentials, or a package manager configuration Webflow Cloud doesn't support.
Match the visible symptom to its cause and apply the corresponding fix.
The Webflow Cloud build fails at the adapter step on an API route
Cause: An export const runtime = 'edge' line in the route causes this failure. The word "edge" names two different things on Webflow Cloud, and the bring-your-own-app page uses both. Webflow Cloud runs on Cloudflare Workers, an edge platform, and the docs describe the deployment as running on the Edge runtime in that platform sense.
The Next.js edge runtime target is a separate Next.js feature, and the OpenNext Cloudflare adapter Webflow Cloud deploys through doesn't support it. A developer who reads the platform sentence, then follows the same page's instruction to add the directive to API routes, is doing what the docs say and shipping a build that cannot complete.
Fix: Remove the runtime export from every route and layout in the repo, then grep for runtime before each deploy in case a teammate or an AI coding agent adds it back. If the build still fails, check next.config for a hand-added output or basePath value, which the current Webflow Cloud Next.js guidance says you should not set, and remove it.
The next build should pass the adapter step.
The Webflow page gets a 404 from /api/entries while the route works at the mount path
Cause: A fetch to /api/entries asks the Webflow site itself for that URL, so the response is the site's 404 page. The same failure happens in a React component in the Next.js app if it hardcodes the bare path. Webflow Cloud mounts the application beneath its configured prefix rather than at the site's root, so browser requests must include that prefix.
Fix: Use the literal mounted URL in the Webflow page. Inside the Next.js app, build URLs from process.env.NEXT_PUBLIC_BASE_PATH, with the variable set in each Webflow Cloud environment to that environment's mount path.
Confirm the change in the browser's network panel: the failing request shows the bare path and a response from the site's 404 page, while the working one shows the mounted path and JSON from the app. The corrected request should resolve to the Route Handler instead of the site's 404 page.
The deployed route returns 500, but npm run dev returns rows
Cause: The credentials may exist in .env.local and nowhere else. That file is gitignored, so the deploy never sees it, and the deployed app reads variables from the Webflow Cloud environment. supabaseAdmin() throws on the missing variable and the Route Handler returns a 500.
A quieter version of the same symptom is an empty entries array on every request. That response indicates that the deployed variable holds the anon key, and RLS with no policies returns zero rows without an error.
Fix: Open the app's Webflow Cloud environment variables and confirm that SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and NEXT_PUBLIC_BASE_PATH are present.
If the array is empty, compare the key you pasted with the service role key in Supabase's API settings; the two keys look similar at a glance, and copying the wrong one is the usual cause. Once the variables are correct, run the deploy again and request the route.
The first GET should return the rows you inserted locally, since both environments point at the same Supabase project.
The project does not install consistently on Webflow Cloud
Cause: This mismatch can start with a pnpm or Yarn command or lockfile that moves the project away from the required npm setup. The same mismatch can happen when a local pnpm add @supabase/supabase-js changes a project originally scaffolded with npm.
Webflow Cloud supports npm for this workflow, so another package manager's lockfile or package metadata prevents the repository from matching the expected installation path.
Fix: Delete the non-npm lockfile and node_modules, run npm install to regenerate package-lock.json, and commit the result. If package.json has a packageManager field pointing at pnpm or Yarn, remove it or set it to npm.
Then add the npm requirement to the README; I have seen the other package manager return when nobody wrote that down. Re-run the deploy using npm. The repository should now contain one npm lockfile and install consistently on Webflow Cloud.
What you can build next with Supabase and Webflow
Once one Route Handler is reading and writing Postgres on your domain, the rest of a real backend follows the same shape: a Supabase Auth login with the session checked in middleware.ts, or a table of per-visitor state that your pages load on arrival.
Your marketing team keeps publishing in the Designer while the data your visitors create lives in a database you control, all on one domain.
Explore the Supabase integration for more ways to connect the two platforms.
Frequently asked questions
Can you call Supabase directly from a Webflow page with the anon key?
Yes. The anon key is public by design, so you can place it in a page when Row Level Security policies strictly limit each anonymous visitor's access. Direct access suits read-only public data. Do not expose the service role key, and use a server route whenever writes require private credentials, validation, or authorization.
Should Supabase Auth use middleware.ts or proxy on Webflow Cloud?
Use middleware.ts for the Supabase Auth session check. Webflow Cloud runs middleware on the Workers runtime, where Edge runtime middleware works but Node.js runtime middleware does not. Next.js 16 renames middleware to proxy, and proxy runs on Node with no way to opt into Edge, so keeping the old filename is not a workaround: pin the app to Next.js 15. Within 15, stay at or above 15.2.3, because earlier 15.x releases carry GHSA-f82v-jwr5-mffw, a critical bypass of exactly this kind of middleware gate.
Can one query combine Webflow CMS content with Supabase records?
Yes. Your Route Handler can read published CMS content through Webflow's Data API and combine it with data queried from Supabase before returning a response. This arrangement lets editors continue using collections, publishing, and localization through Webflow Localize, while visitor-specific records remain in Postgres.
What should you test beyond npm run dev before launch?
Test the mounted production URLs and any APIs the Route Handler uses. Local development uses the Node server, credentials from .env.local, and no injected base path. The deployed app runs on Cloudflare Workers, reads the Webflow Cloud environment, and receives its base path during the build, so avoid relying on Node-only APIs.





