How to add live Airtable data to a Webflow Cloud app

Learn how to build a Next.js Route Handler on Webflow Cloud that reads an Airtable table with a scoped token, and skip the edge runtime directive the docs suggest.

How to add live Airtable data to a Webflow Cloud app

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

Your operational data already lives in Airtable. With a small Next.js app on Webflow Cloud, you can read that table on every request, not at build time.

Marketing teams backed by operations often encounter a common challenge: maintaining inventory or event dates across both Airtable and the Webflow CMS leads to data drift when records are manually re-entered.

While embedding an Airtable view using an iframe prevents this desynchronization, it sacrifices your site's custom typography and styling options.

Webflow Cloud changes the shape. You can mount a Next.js app under a path on the same site, keep the Airtable token in a server-side environment variable, and render the records with your own components.

The CMS stays home for content editors to write in Webflow; Airtable stays home for operational data; neither has to be copied into the other. In this guide, we walk through five practical steps to build and deploy a Next.js app on Webflow Cloud that securely reads and renders live Airtable data on every request.

What do you need to add Airtable data in Webflow?

You need five prerequisites:

  • Airtable base: Use the base that holds the table you want to show, and limit the app to that operational data.
  • Personal access token: Your Airtable account must have permission to create a token scoped to read records from that one base.
  • Webflow site: Use the site where you will create and mount the Webflow Cloud environment for the deployed app.
  • Node and Next.js: Install Node.js 22 or later and Next.js 15 or higher, as listed in the bring-your-own-app requirements.
  • npm: Use npm because it is the only supported package manager; pnpm or yarn steps do not work here.

Webflow Cloud is available from the free Starter site plan up, while mounting the app to a custom domain requires Premium or higher. Before building the route, prepare Airtable access, your Webflow site, runtime, framework, and supported package manager.

With those pieces ready, the Airtable credential can stay in Webflow Cloud's environment variables while the browser talks only to your/api/records route.

5 steps to add Airtable data in Webflow Cloud

Adding live Airtable data requires a scoped token, a Next.js app, a server-side fetch module, a rendered page, and a Webflow Cloud environment configured for its mount path.

Complete the five parts in order so the same app works locally and after deployment.

1. Create the Airtable personal access token

Create a personal access token with a single read scope and access to one base. In Airtable, open your account menu, choose Developer hub, then Personal access tokens, and click Create token. Under Scopes, add data.records:read; under Access, select only the base that holds your table.

Airtable moved from account-wide API keys to these tokens, and the scope plus the base grant are what keep a leaked credential from exposing every base you own. Copy the token when Airtable shows it, because it appears once. It begins with pat; keep it out of the repository.

Record the identifiers the app will use:

  • Base ID: The path segment beginning with app, which identifies the base regardless of what anyone renames it to later. Keep it out of the repository.
  • Table ID: The segment beginning with tbl; its display name also works, but the ID survives a rename. Keep it out of the repository.
  • View ID (optional): The segment beginning with viw, useful later if you want Airtable to filter and sort records before they reach your app.
  • Record IDs: These begin with rec and arrive in every API response. They become your React keys later, so copying one now is unnecessary.

You now have a narrowly scoped token plus the stable base and table identifiers the app needs.

2. Scaffold the Next.js app with npm

Scaffold the airtable-on-webflow project with Next.js 15 or higher, TypeScript, the App Router and ESLint.

Generate it with npm, then change into its directory:

npx create-next-app@latest airtable-on-webflow --typescript --eslint --app
cd airtable-on-webflow

Confirm that the project has a package-lock.json and an app/ directory.

Create .env.local in the project root, enter your actual Airtable values, and leave the local base path empty:

  • AIRTABLE_TOKEN: Enter the personal access token Airtable displayed after you created the scoped credential.
  • AIRTABLE_BASE_ID: Enter the actual ID of the Airtable base that holds your table.
  • AIRTABLE_TABLE_ID: Enter the actual ID of the Airtable table the app will read.
  • NEXT_PUBLIC_BASE_PATH: Add the variable with no value for local development at the site root.

NEXT_PUBLIC_BASE_PATH stays empty locally because next dev serves the app at the root; on Webflow Cloud it becomes the mount path you choose. Confirm that .gitignore excludes .env* files so the token never reaches your repository. Run npm run dev, and http://localhost:3000 should show the default Next.js page with no terminal errors.

3. Write the Route Handler that reads the Airtable table

Put the fetch logic in a module that the Route Handler and the page can both import, so the token is read in exactly one place and pagination is handled once. Every record comes back with an id, a createdTime and a fields object, which the types mirror.

Create lib/airtable.ts so it reads the three variables once and loops until Airtable stops sending an offset:

export type AirtableRecord = {
  id: string;
  createdTime: string;
  fields: Record<string, unknown>;
};

type AirtableResponse = {
  records: AirtableRecord[];
  offset?: string;
};

export async function getRecords(): Promise<AirtableRecord[]> {
  const token = process.env.AIRTABLE_TOKEN;
  const baseId = process.env.AIRTABLE_BASE_ID;
  const tableId = process.env.AIRTABLE_TABLE_ID;

  if (!token || !baseId || !tableId) {
    throw new Error('AIRTABLE_TOKEN, AIRTABLE_BASE_ID and AIRTABLE_TABLE_ID must be set');
  }

  const records: AirtableRecord[] = [];
  let offset: string | undefined;

  do {
    const url = new URL(`https://api.airtable.com/v0/${baseId}/${tableId}`);
    if (offset) url.searchParams.set('offset', offset);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (!res.ok) {
      throw new Error(`Airtable responded with ${res.status}`);
    }

    const data = (await res.json()) as AirtableResponse;
    records.push(...data.records);
    offset = data.offset;
  } while (offset);

  return records;
}

The loop follows Airtable's offset token until a response omits it, so a table larger than one page comes back whole instead of truncated at the first page.

Then add the Route Handler at app/api/records/route.ts:

import { NextResponse } from 'next/server';
import { getRecords } from '@/lib/airtable';

export async function GET() {
  try {
    const records = await getRecords();
    return NextResponse.json({ records });
  } catch {
    return NextResponse.json(
      { error: 'Unable to load records' },
      { status: 502 },
    );
  }
}

Webflow Cloud executes the handler on Cloudflare Workers through the OpenNext Cloudflare adapter. Omit the export const runtime = 'edge' line because the adapter does not support the separate Next.js edge runtime target. Adding that directive breaks the build.

The handler returns 502 for any failure caught while loading the records, without exposing the internal exception message in the public response. Run curl http://localhost:3000/api/records, and you should get a JSON body with a records array.

4. Render the records on a page

Call getRecords() directly from a server component. On Webflow Cloud, this saves an HTTP round trip and avoids building an absolute URL that has to know the mount path.

Replace app/page.tsx with a server component that maps records to your markup.

One line in here does the work the title promises, and leaving it out is the most common way this build disappoints. Next.js statically prerenders a route that awaits a server-side function with no request-time input, which means Airtable is called once, at next build, and every visitor afterward gets that snapshot.

export const dynamic = 'force-dynamic' opts the page into rendering per request, which is what makes the data live rather than merely fresh at deploy:

import { getRecords } from '@/lib/airtable';

// Without this the route is prerendered and Airtable is read once,
// at build time. Editors then change a row and nothing moves.
export const dynamic = 'force-dynamic';

export default async function Page() {
  const records = await getRecords();

  return (
    <main>
      <h1>Inventory</h1>
      <ul>
        {records.map((record) => (
          <li key={record.id}>
            {String(record.fields['Name'] ?? 'Untitled')}
          </li>
        ))}
      </ul>
    </main>
  );
}

fields is keyed by your Airtable column names exactly as written, spaces and capitals included, so record.fields['Name'] matches a column titled Name. Narrow each value before rendering: Airtable returns attachments as arrays of objects and linked records as arrays of record IDs, and a bare object inside JSX throws at render time. Empty cells are omitted from fields entirely, which is why the fallback to 'Untitled' is there.

A client component such as a filter or search box sometimes needs the route. Webflow Cloud injects basePath at build time and leaves client-side fetch calls unchanged, so you prefix the URL yourself.

Use this one-liner under both next dev and the deployed mount path:

const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/records`);

The NEXT_PUBLIC_ prefix makes that variable available in the browser bundle. AIRTABLE_TOKEN stays on the server because it lacks the prefix. Reload http://localhost:3000 and you should see one list item per Airtable record, pulled live from the base.

5. Deploy the app to Webflow Cloud

Create a Webflow Cloud environment for the app and give it a mount path, for example,/inventory. The mount path is the value your client code reads, so it goes into the environment as a variable alongside the Airtable values.

In the environment's settings, add these four variables:

Variable Value Secret
AIRTABLE_TOKEN The pat... token you created in Airtable Yes
AIRTABLE_BASE_ID The app... base ID No
AIRTABLE_TABLE_ID The tbl... table ID No
NEXT_PUBLIC_BASE_PATH The mount path, such as /inventory No
Variable → Value → Secret
AIRTABLE_TOKEN
The pat... token you created in Airtable
Yes
AIRTABLE_BASE_ID
The app... base ID
No
AIRTABLE_TABLE_ID
The tbl... table ID
No
NEXT_PUBLIC_BASE_PATH
The mount path, such as /inventory
No

Webflow's environment variables documentation states that "Both secret and non-secret environment variables are available to your application's build process—for example, for auth-framework configuration—and remain available to the deployed application at runtime."

That single fact is why one set of values is enough: NEXT_PUBLIC_BASE_PATH is inlined into the client bundle during the build, and AIRTABLE_TOKEN is readable by the Worker at request time. Secrets are redacted from build logs, so mark the token as one.

Deploy the environment and wait for the build to finish. Open the configured mount path, /inventory, and you should see the same list you saw locally.

What causes an Airtable fetch to fail on Webflow Cloud?

Airtable fetches usually fail because of an unsupported runtime directive, an incorrect mount-path URL, mismatched deployed credentials, or enough concurrent requests to trigger Airtable's rate limit.

Match the visible symptom to the cause and fix below before changing the fetch implementation.

The Webflow Cloud build fails during deploy while the same route compiles locally

Cause: Webflow Cloud runs the Route Handler on Cloudflare Workers through the OpenNext Cloudflare adapter, which does not support the separate Next.js edge runtime target. Local compilation can succeed even when export const runtime = 'edge' later breaks the Webflow Cloud build.

The same runtime distinction governs middleware. Webflow's framework customization page says middleware uses the Edge runtime on Workers. A Next 16-style proxy.ts runs on the Node runtime, cannot opt into Edge, and produces a file that never executes.

Fix: Search every Route Handler and page for export const runtime = 'edge' and remove every occurrence. If you later put authentication in front of /api/records, keep the middleware.ts filename instead of replacing it with proxy.ts.

The route should then build for the Worker runtime used by Webflow Cloud.

/api/records returns JSON locally, but the browser gets a 404 on Webflow Cloud

Cause: A URL beginning with /api/records goes to the site's root instead of the mounted app because client-side fetch calls are left unchanged. Under next dev the mount path is empty, so identical code works locally, and the problem surfaces only after deployment.

Inspect the failed request URL to confirm whether it includes the configured mount path, such as /inventory/api/records.

Fix: Prefix the client-side URL with process.env.NEXT_PUBLIC_BASE_PATH ?? '' and verify that the environment variable matches the mount path. Avoid reading basePath from next.config; the Next.js customization page says, "You don't need to add an adapter, a base path, or an output mode," and the value is unavailable for your code to import.

A server component can call getRecords() directly, eliminating the client fetch for that component.

Airtable returns 401 or 403 on Webflow Cloud, but the same code works under npm run dev

Cause: .env.local stays on your machine, so a successful local request does not confirm that the deployed Worker has the same token, base ID, table ID, or mount path.

Use the response and its origin to isolate the deployed problem:

Signal Likely cause
Missing token on the Worker getRecords() throws before the fetch, and the route returns 502 with the generic error response.
Airtable 401 A deployed variable usually contains a typo or stale value.
Airtable 403 The deployed token lacks the required permission for this base.
Airtable 404 The base or table ID is wrong.
Webflow hosting 404 The request has a routing problem.
Signal → Likely cause
Missing token on the Worker
getRecords() throws before the fetch, and the route returns 502 with the generic error response.
Airtable 401
A deployed variable usually contains a typo or stale value.
Airtable 403
The deployed token lacks the required permission for this base.
Airtable 404
The base or table ID is wrong.
Webflow hosting 404
The request has a routing problem.

Fix: Open the environment's variables, confirm all four exist under the exact names the code reads, and redeploy. In Airtable, open the token in Developer Hub and confirm that it has the data.records:read scope and access only to the required base, then regenerate it if you cannot confirm the stored value.

A temporary log of res.status inside getRecords(), never the token itself, confirms which Airtable response you received.

Airtable starts returning 429 once the page gets real traffic

Cause: Every render of the server component and every hit on the Route Handler calls Airtable, and the pagination loop multiplies that: a table spanning several pages costs several requests per render.

Airtable limits the API to 5 requests per second per base, with a separate ceiling of 50 requests per second across all traffic for a given token. Exceed it, and you get a 429, after which you must wait 30 seconds before requests succeed again.

I have seen this bite on launch day, when a newsletter link turns a quiet internal page into a burst of concurrent renders that all reach Airtable at once. The traffic pattern triggers the limit.

Fix: Enforce an application-level request or concurrency limit before the Airtable call. Browser or shared caching may reduce repeat requests, but direct, cache-bypassing traffic still needs a rate limit.

Also request less: pass a view query parameter so Airtable filters and sorts server-side, and a fields[] parameter so only the columns you render come back.

For a public, busy page, consider serving synchronized data from a CMS instead of calling Airtable on every request; then you can call Airtable on a schedule you control.

What you can build next with Airtable and Webflow

When you need to show records that change hourly and never need them to appear in search results, I use the live Route Handler pattern. Once Airtable records render inside your site's own design, the practical question becomes which data belongs where.

Anything editors should manage inside Webflow, index for SEO, or translate belongs in the CMS. Webflow's composable CMS exposes REST APIs for deeper integrations.

For deeper customization beyond what one Route Handler fetch handles, start with the Webflow Cloud and CMS API sections of Webflow's developer docs.

Frequently asked questions

Can a second environment point at a different Airtable base?

Yes. Give the staging environment its own AIRTABLE_BASE_ID and AIRTABLE_TABLE_ID while the production environment keeps the live values. Each environment also has its own mount path, so NEXT_PUBLIC_BASE_PATH changes with it. The token can differ too; use a separate credential for each base to keep their access grants distinct between environments.

What happens on the page when someone deletes a record in Airtable?

With export const dynamic = 'force-dynamic' on the page, the server component calls getRecords() on every request, so the deleted row disappears on the next load. Without it the route is prerendered at build time and the row persists until you redeploy, which is the single most common reason this setup looks broken. A browser that already fetched /api/records can continue showing the old response until the client fetches the route again. The displayed result therefore depends on when that browser requests fresh data.

What happens when someone renames a column in the Airtable base?

Renamed columns change field keys, so record.fields['Name'] returns undefined without an error, and your fallback text appears instead of real data. Referencing the table by its tbl ID protects the request URL from a table rename. Agree on column names with the base owner before you ship.


Last Updated
September 19, 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.