Reviewers need to see applications and decide on them. They do not need a seat in your Airtable base, and with this build, you can keep it that way.
When applicants submit an Airtable form or trigger an automation, they land in a row with their name, email, reason for joining, and a status set to Pending. Problems arise when multiple people review these entries.
One person approves a record in the grid, another replies via email, and nobody knows which applications were actually decided. Reviewers rarely need full access to Airtable, and giving it to them creates unnecessary risk.
This app is a small Next.js project mounted on your Webflow site at a path such as /members. It shows only Pending records, lets a reviewer approve or reject each one with a note, and writes the decision back to Airtable through a PATCH.
The Airtable token stays server-side, the review page sits behind HTTP basic auth, and reviewers open it on the same domain as your marketing site.
What do you need to review membership applications with Airtable in Webflow Cloud?
You need five prerequisites.
Prepare these five items before starting the app:
- Airtable base with creator access: Creator access lets you add fields to the Applications table and create a personal access token scoped to that base alone.
- Webflow site: The site provides the mount point where reviewers open the app on the same domain as your marketing site.
- Deployable Next.js project: The app needs to be a Next.js project that Webflow Cloud can build and deploy.
- Node.js with npm: The Webflow Cloud docs require Node.js 22 or later and Next.js 15 or higher; Webflow Cloud supports only npm.
- Next.js 15, pinned deliberately: Scaffold with
npx create-next-app@latest --typescript --eslint --appand then pin Next.js to the 15 line. This matters more than a usual version note, because the auth gate in step 5 is amiddleware.tsfile: Next.js 16 deprecates that convention in favor ofproxy.ts, and proxy runs on the Node.js runtime, which Webflow Cloud does not support for middleware.
Webflow Cloud is included from the free Starter site plan upward, although mounting the app on a custom domain requires Premium or higher. The local toolchain must also match what Webflow Cloud builds with.
With the base, site, project, and supported toolchain ready, you can build the review queue in order.
7 steps to review membership applications with Airtable in Webflow Cloud
The finished app keeps every Airtable call and the token on the server, while only the decision controls execute in the browser through protected routes.
The build combines a fetch-based Airtable client, two Route Handlers, basic-auth middleware, and one server-rendered page mounted on your Webflow site.
1. Shape the Applications table in Airtable
Create a table called Applications, then configure the Status field with Pending, Approved, and Rejected as its three exact single-select options. Set Pending as the default for new records. Spelling and capitalization matter because the app writes those strings back through the API, and Airtable rejects values that don't match an existing option.
Alongside Status, add Name (single line text), Email (email), Reason (long text), Reviewer note (long text), Reviewed at (a date field that supports time), and Created (a created time field). Created gives the queue a stable sort, so the oldest application always sits at the top.
While the table is open, copy the two identifiers the code needs out of the browser's address bar.
The URL of an open table carries both in this shape:
https://airtable.com/appXXXXXXXXXXXXXX/tblXXXXXXXXXXXXXX/viwXXXXXXXXXXXXXX
The segment beginning with app is the base ID, and the segment beginning with tbl is the table ID. I use the table ID so renaming the Airtable tab does not break requests. Add two or three test rows with Status left at Pending.
You should now have a table where every new record defaults to Pending, and a base ID and table ID saved somewhere you can reach from the terminal.
2. Create a scoped Airtable personal access token
Open the personal access token page, choose Create token, and name it something a successor will recognize, such as membership-review-webflow-cloud. Add only the data.records:read and data.records:write scopes, then limit Access to the Applications base.
Airtable shows the token once; paste it into a password manager before you close the dialog. It begins with pat.
The narrow scope is a defensible choice. A token with schema scopes or access to every base turns a leaked environment variable into a workspace-wide incident; scoped to records on one base, the worst case is edited application rows, and you can revoke the token from the same page.
If you are building this for a client, create the token from an account the client owns. Airtable tokens belong to the user who creates them, so a token minted from an agency login stops working the day that login loses access to the base.
You now have a PAT-prefixed token that can read and write records in one base and nothing else.
3. Scaffold the Next.js app and write the Airtable client
Accept TypeScript and the App Router, and decline the src/ directory prompt so the @/ alias resolves to the project root when you scaffold with a specifically pinned create-next-app release verified to generate a supported Next.js project.
Webflow Cloud supports only npm, so keep npm as the package manager for every install in this project. The client itself is plain fetch against the Airtable Web API. I prefer fetch here because the deployed runtime is Cloudflare Workers, and a dependency-free client has nothing that can reach for a Node built-in at import time.
The list function follows Airtable's offset token until the response stops returning one.
Create lib/airtable.ts with the list and update calls:
const API = "https://api.airtable.com/v0";
export type ApplicationFields = {
Name: string;
Email: string;
Reason?: string;
Status: "Pending" | "Approved" | "Rejected";
"Reviewer note"?: string;
"Reviewed at"?: string;
};
export type AirtableRecord<T> = { id: string; createdTime: string; fields: T };
function config() {
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("Missing AIRTABLE_TOKEN, AIRTABLE_BASE_ID or AIRTABLE_TABLE_ID");
}
return { token, url: `${API}/${baseId}/${tableId}` };
}
export async function listPending(): Promise<AirtableRecord<ApplicationFields>[]> {
const { token, url } = config();
const records: AirtableRecord<ApplicationFields>[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({
filterByFormula: "{Status} = 'Pending'",
"sort[0][field]": "Created",
"sort[0][direction]": "asc",
});
if (offset) params.set("offset", offset);
const res = await fetch(`${url}?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Airtable list failed: ${res.status} ${await res.text()}`);
}
const body = (await res.json()) as {
records: AirtableRecord<ApplicationFields>[];
offset?: string;
};
records.push(...body.records);
offset = body.offset;
} while (offset);
return records;
}
export async function decide(
recordId: string,
status: "Approved" | "Rejected",
note: string
) {
const { token, url } = config();
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
records: [
{
id: recordId,
fields: {
Status: status,
"Reviewer note": note,
"Reviewed at": new Date().toISOString(),
},
},
],
}),
});
if (!res.ok) {
throw new Error(`Airtable update failed: ${res.status} ${await res.text()}`);
}
return (await res.json()) as { records: AirtableRecord<ApplicationFields>[] };
}
The cache: "no-store" on the list call is what keeps a reviewer from seeing the row they just decided after a refresh. Next, give the local server the values it needs.
Your .env.local should define AIRTABLE_TOKEN, AIRTABLE_BASE_ID, AIRTABLE_TABLE_ID, NEXT_PUBLIC_BASE_PATH, REVIEW_USER, and REVIEW_PASSWORD. Set the three AIRTABLE_ variables to the token and IDs you saved, and choose local values for the review username and password.
NEXT_PUBLIC_BASE_PATH stays empty on your machine because next dev serves from the root. Running npx tsc --noEmit should finish with no errors, and the project is ready for routes.
4. Add the review Route Handlers
Create the GET endpoint at app/api/applications/route.ts to return the pending list as JSON, and create the POST endpoint under app/api/applications/[id]/decision/ to record a decision on one record. Leave both files without a runtime export.
The list handler is short; save it as app/api/applications/route.ts:
import { NextResponse } from "next/server";
import { listPending } from "@/lib/airtable";
export async function GET() {
const records = await listPending();
return NextResponse.json({ records });
}
That endpoint exists mainly for debugging and for any future automation that wants the queue as JSON. The decision handler does the real work, and it validates status on the server.
A browser can send any string, so the check that the value is Approved or Rejected is what stops a tampered request from writing an unexpected option into the Status field.
Put the decision handler at app/api/applications/[id]/decision/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { decide } from "@/lib/airtable";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = (await req.json()) as { status?: string; note?: string };
if (!id.startsWith("rec")) {
return NextResponse.json({ error: "Invalid record id" }, { status: 400 });
}
if (body.status !== "Approved" && body.status !== "Rejected") {
return NextResponse.json(
{ error: "status must be Approved or Rejected" },
{ status: 400 }
);
}
const note = (body.note ?? "").slice(0, 500);
const result = await decide(id, body.status, note);
return NextResponse.json(result);
}
params is a Promise in the supported Route Handler signature, hence the await. Start the dev server with npm run dev and open http://localhost:3000/api/applications; you should see a JSON object whose records array contains your Pending test rows in Created order.
5. Protect the review routes with middleware.ts
Create middleware.ts at the project root so an unauthenticated request to /review or /api/applications receives a 401 response from HTTP basic auth. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime, so Route Handlers and pages must not use export const runtime = 'edge'.
Webflow Cloud middleware runs on the Edge runtime, according to Webflow's framework customization docs, and needs no runtime directive. Node.js runtime middleware is unsupported. The newer proxy replacement runs on the Node runtime and cannot opt into Edge, which prevents a proxy.ts rename from running there.
Drop this in as middleware.ts beside package.json:
import { NextRequest, NextResponse } from "next/server";
export const config = {
matcher: ["/review/:path*", "/review", "/api/applications/:path*", "/api/applications"],
};
function safeEqual(a: string, b: string) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
export function middleware(req: NextRequest) {
const user = process.env.REVIEW_USER ?? "";
const password = process.env.REVIEW_PASSWORD ?? "";
const expected = `Basic ${btoa(`${user}:${password}`)}`;
const provided = req.headers.get("authorization") ?? "";
if (password && safeEqual(provided, expected)) {
return NextResponse.next();
}
return new NextResponse("Authentication required", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="Membership review"' },
});
}
The matcher includes the API and the page, so both are subject to the same authentication check. The hand-rolled safeEqual is also deliberate: timingSafeEqual exists on the deployed Workers runtime. Local next dev lacks it, so a comparison that works in production throws on your laptop. A bitwise loop behaves the same in both places.
With the dev server running, curl -i http://localhost:3000/api/applications should return a 401 with a WWW-Authenticate header, and adding -u with the local review username and password should return the JSON list.
Repeat the unauthenticated check against /members/api/applications after deployment to confirm the mounted API also returns 401.
This compact example leaves both API routes without an application-level rate limiter. The decision POST also lacks CSRF or same-origin validation and doesn't check that a record is still Pending before updating it.
Before using it as a production approval system, throttle repeated requests and reject cross-site state-changing requests. Add a Pending status check so submitting a known record ID cannot overwrite an earlier decision.
You should now see protected review and API routes reject unauthenticated requests, while valid review credentials return the pending application data.
6. Build the review page
Build the review page so the server component calls listPending directly, while only the two decision buttons execute in the browser. This keeps the client code limited to the buttons and sidesteps the mount-path problem for server requests, because only browser requests need to know where the app is mounted.
The page lives at app/review/page.tsx and renders the queue:
import { listPending } from "@/lib/airtable";
import { DecisionButtons } from "./decision-buttons";
export const dynamic = "force-dynamic";
export default async function ReviewPage() {
const records = await listPending();
return (
<main style={{ maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
<h1>Pending applications ({records.length})</h1>
{records.length === 0 && <p>Nothing is waiting for review.</p>}
<ul style={{ listStyle: "none", padding: 0 }}>
{records.map((r) => (
<li key={r.id} style={{ borderBottom: "1px solid #ddd", padding: "1rem 0" }}>
<strong>{r.fields.Name}</strong> ({r.fields.Email})
<p>{r.fields.Reason}</p>
<DecisionButtons id={r.id} />
</li>
))}
</ul>
</main>
);
}
Only the buttons run in the browser because they need onClick and therefore must be client components. The client component must include the mount path in its fetch URL. Webflow Cloud injects basePath at build time and doesn't rewrite client-side fetch calls, so the URL the browser requests must include the mount path.
NEXT_PUBLIC_BASE_PATH is inlined during the build, and Webflow Cloud makes environment variables available to the build process, so setting it once in the environment is enough.
Save the client component next to the page as app/review/decision-buttons.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
export function DecisionButtons({ id }: { id: string }) {
const router = useRouter();
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
async function submit(status: "Approved" | "Rejected") {
setBusy(true);
try {
const res = await fetch(`${BASE}/api/applications/${id}/decision`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status, note }),
});
if (res.ok) {
router.refresh();
} else {
alert(`Update failed with status ${res.status}`);
}
} catch {
alert("Update failed because the request could not be completed");
} finally {
setBusy(false);
}
}
return (
<div>
<textarea
placeholder="Reviewer note (optional)"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
style={{ width: "100%", marginBottom: "0.5rem" }}
/>
<button disabled={busy} onClick={() => submit("Approved")}>
Approve
</button>{" "}
<button disabled={busy} onClick={() => submit("Rejected")}>
Reject
</button>
</div>
);
}
router.refresh() re-runs the server component, so a decided row drops out of the list without a full page load. Open http://localhost:3000/review, enter the local credentials at the browser prompt, and approve one test row.
It should vanish from the page, and the Airtable grid should show that record with Status set to Approved and Reviewed at filled with the current timestamp.
7. Deploy to Webflow Cloud and set the environment variables
Set NEXT_PUBLIC_BASE_PATH during the first deployment build so that Webflow Cloud can inline it in the client bundle. Create and configure a Webflow Cloud project and environment with the mount path /members.
Use the same six variable names from .env.local. For this environment, set NEXT_PUBLIC_BASE_PATH to /members, replace the three AIRTABLE_ placeholders with the token and IDs from Airtable, and replace the local review credentials with the production username and a long random password from a password manager. Mark AIRTABLE_TOKEN and REVIEW_PASSWORD as secret; leave the other four non-secret.
Webflow's environment variables docs state that both secret and non-secret variables are available to the build process and the deployed app at runtime, and that secrets are redacted from build logs. Hence, the token never lands in output a collaborator can read.
Start the deploy and watch the build log finish. Then open the deployed app at /members/review: the browser should prompt for the review credentials, and the queue should list the same Pending rows you saw locally.
If the site is on Premium or higher and already has a custom domain, the same path answers there too.
What causes the Airtable review queue on Webflow Cloud to fail?
Failures during deployment or review usually come from Airtable permissions, a mismatched Webflow Cloud mount path, an unsupported runtime directive, or Status options and field types that do not match the request.
Start with the exact string shown in the browser, build log, or response body, then match it to the symptoms below.
Airtable returns 403 INVALID_PERMISSIONS_OR_MODEL_NOT_FOUND only from the deployed app
Cause: Airtable uses the same 403 code for a token that lacks access to the base and for a base or table ID that does not exist. When the request works with .env.local and fails after deploy, the deployed environment may contain a token whose Access list omits the Applications base, an incorrect table ID, or variables assigned to a different environment.
Fix: Open the environment that actually served the failing request and compare its three AIRTABLE_ values character by character against the table URL and the token's Access section on the Airtable token page.
If the token was scoped to the wrong base, edit its access so the value in Webflow Cloud stays valid. Redeploy after any change and confirm with a curl -u call to /members/api/applications on the deployed domain that the JSON list comes back.
Clicking Approve returns the site's 404 page instead of JSON
Cause: The browser is sending the decision POST to the site root instead of the mounted Webflow Cloud app. Server requests can call listPending directly, but Webflow Cloud does not rewrite a client-side fetch to include the mount path.
If NEXT_PUBLIC_BASE_PATH was empty or different during the deployment build, the client bundle requests /api/applications/ instead of the API under /members, so the request hits the site's 404 page instead of the Route Handler.
Fix: Check that NEXT_PUBLIC_BASE_PATH in the Webflow Cloud environment matches the mount path exactly, with the leading slash and no trailing slash, then redeploy so the value is inlined in the client bundle. Open the browser's network panel, click Approve again, and confirm that the POST URL begins with /members/api/.
A successful request should return JSON, refresh the server component, and remove the selected row from the queue.
The Webflow Cloud build fails after adding export const runtime = 'edge'
Cause: Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime for Route Handlers and pages. Middleware runs on the Edge runtime without a runtime directive, but adding export const runtime = 'edge' to a page or handler makes the build target an unsupported configuration.
Renaming the middleware to proxy.ts does not solve the failure because the replacement runs on the Node runtime and cannot opt into Edge.
Fix: Search the repo for runtime = and delete every match from Route Handlers and pages. Keep the authentication file named middleware.ts at the project root and leave it without a runtime export. Trigger another deployment and watch the build log finish.
Then request /members/review and /members/api/applications; both should reach the deployed app, and unauthenticated requests should receive the basic-auth challenge instead of a build or runtime failure.
Airtable returns 422 INVALID_MULTIPLE_CHOICE_OPTIONS when a reviewer approves
Cause: The string the decision handler wrote to Status does not match an existing single-select option in the table. Airtable matches option names exactly. Renaming an option to "Accepted" or typing "approved" in lowercase causes every approval request to fail. Rejection requests continue to work when Rejected remains unchanged.
A related failure behaves differently and is worth knowing, because it is quieter. If Reviewed at was created as a text field rather than a date field, writing an ISO timestamp to it does not error at all: the string is accepted and stored as text. No 422s, the review appears to work, and the column simply stops behaving like a date when you sort or filter on it.
Fix: Open the Status field settings in Airtable and make the option names Pending, Approved, and Rejected with that exact capitalization, or change the union type and the validation check in the decision handler to match whatever the table uses.
Avoid sending typecast: true in the PATCH body as a shortcut: it makes Airtable create any unknown option on the fly, which is exactly how a controlled Status field ends up with five spellings of the same state.
Verify Reviewed at is a date field that supports time, then approve a test row again.
What you can build next with Airtable and Webflow
Once every decision lands in one Status field, the workaround gets simpler. An Airtable automation can email the applicant the moment Status changes, and the same Route Handler pattern can accept the Webflow application form's POST, so intake and review share a single table.
For deeper customization beyond what the Route Handler pattern handles natively, Webflow's developer docs cover the CMS APIs for publishing approved members into a collection on the site.
Frequently asked questions
What happens if a reviewer submits a note longer than 500 characters?
The server stores only the first 500 characters because the decision handler applies slice(0, 500) before calling Airtable. The browser doesn't currently set a textarea length limit, so that reviewers can type more than that without a warning. Add matching client-side validation if you want the interface to show the limit before submission.
Can I hide applicant email addresses from reviewers?
Yes. Remove ({r.fields.Email}) from app/review/page.tsx. The queue then displays the name, reason, and controls, while the Airtable record and server-side list call remain unchanged. If reviewers need another identifier, render an existing field instead. Keep the decision component's record ID prop because the POST route uses that ID to update the correct application.
Can I require reviewers to enter a note?
Yes. Add a server-side check after parsing the request body and return a 400 response when the note is empty. Add the same check to the client before fetch so reviewers get immediate feedback, but keep the server validation because a browser request can be tampered with. The existing 500-character slice can remain in place.
How do I replace basic auth with individual reviewer accounts?
You can swap the middleware body for an auth provider such as Clerk or the Auth0 integration. Keep the middleware configuration protecting the review page and application API. Pass the signed-in user's name to the decision handler, add a Reviewer field to the table, and write to it so Airtable shows which individual made each decision.
What happens if Airtable rate-limits the app on a busy review day?
The client alerts on any non-successful response, keeping the failed decision in the queue. Airtable limits requests to 5 per second per base and requires a 30-second wait after a 429 error. While each decision uses one PATCH, frequent pagination of a large pending queue during page loads is more likely to trigger limits than concurrent decisions from a small review team.




