Review Airtable Applications Securely

Build a protected app that reviews Airtable applications without base access.

Introduction

30 Second Summary

Reviewers often need to make one decision from a short list. Sharing the whole workspace gives them far more access than the task requires.

In this project, you will build a protected application review page as a Next.js app on Webflow Cloud. It shows pending rows from Airtable while keeping the database token on the server.

What You'll Build

Picture sending a reviewer one link where they sign in, see only pending applications, and approve or reject each one without opening your Airtable base.

By the end of this project, you'll have:

  • A protected review queue that shows only the pending applications a reviewer needs to handle.
  • A decision flow where one click updates Airtable and removes the completed row from the queue.
  • A live reviewer link protected by a browser sign-in and ready to use without sharing access to your base.
  • Secret Mission: An optional challenge to push your skills further.

Are there any prerequisites?

You'll need Node, a Next.js 15 app, creator access to an Airtable base, and a Webflow site.

Webflow Cloud is included from the free Starter site plan upward, which keeps hosting at $0 on a webflow.io path.

Before We Start

Every later test starts on your computer. It eventually reaches application data through a hosted review page.

This preparation step gives you access to Airtable plus Webflow Cloud. Both accounts must be ready before the project creates or deploys anything.

You'll also prepare Node.js, npm, curl, plus Visual Studio Code for the Next.js work ahead.

In this step, get ready to:
  • Prepare signed-in Airtable plus Webflow Cloud accounts.
  • Verify your local command-line tools.
  • Confirm your code editor is ready.
Set up your service accounts

Airtable holds the application records that your review page reads. Webflow Cloud provides the deployment home that reviewers eventually visit.

You should be able to reach the signed-in home area for each service. This confirms that both accounts are available for the project.

Having trouble signing in?

Complete any email verification requested by the service. Return to the relevant login page after verification.

Still blocked? Help me troubleshoot access to my Airtable or Webflow account.

That clears the first access boundary. Both services are now ready for the project.

Prepare your local toolchain

Node.js runs your Next.js app on your computer. The local server lets you test each route before deployment.

The npm command installs project packages. The curl command sends requests directly to your API routes.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Search for your computer's terminal application.
  • Select the terminal application from the search results.

Before you run the check, do you expect every command to return version details?

  • Check the three command-line tools by running:
node -v
npm -v
curl --version

What do these checks prove?

  • The node -v command reports the installed Node.js version.
  • The npm -v command reports the installed npm version.
  • The curl --version command prints information about the installed curl build.

✔️ I see version details

Your local command-line tools are ready. The terminal can run Node.js projects plus direct API checks.

ⓧ One or more commands are missing

A missing command means its tool still needs to be installed. Complete the relevant installation before repeating the check.

  • Install Node.js from the official Node.js download page if Node.js or npm is missing.
  • Choose the release labelled LTS during the Node.js installation.
  • Install curl from the official curl download page if curl is missing.
  • Close your terminal after completing the installation.
  • Reopen your terminal through system search.
  • Repeat the toolchain check above.

Still missing a command?

A terminal that stayed open during installation may not detect the new command. Close every terminal window before opening a fresh one.

Need another pair of eyes? Help me diagnose my local toolchain check.

Confirm your code editor

Visual Studio Code gives you one place to edit the project files. Opening it now confirms that your local workspace can support the coding steps ahead.

Before you search, do you expect Visual Studio Code to appear as an installed application?

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Search for Visual Studio Code.
  • Select Visual Studio Code from the search results.

✔️ Visual Studio Code opens

Your editor is installed correctly. You should see an editor window ready to open a folder.

ⓧ Visual Studio Code is not installed

Install the editor before continuing. The official download page provides an installer for each supported operating system.

  • Visit the official Visual Studio Code download page.
  • Select the installer for your operating system.
  • Complete the installation.
  • Repeat the system search for Visual Studio Code.
  • Select Visual Studio Code from the search results.

You should now see Visual Studio Code open successfully. This confirms that your local workspace is ready for the Next.js project.

Your accounts are accessible. Your local tools are ready. Next up, you'll create the applications table in Airtable plus the scoped token that protects access to it.

Set Up Airtable Applications

A reviewer should only see pending applications. Direct access to your Airtable base exposes data outside that queue.

This step creates the Applications table that drives the queue. A base-scoped personal access token gives your server the exact record permissions it needs.

In this step, get ready to:
  • Create a dedicated Airtable base for applicant records.
  • Configure an Applications table with three decision statuses.
  • Create a base-scoped token for server-side record access.
Create the project base

An Airtable base holds the application data behind your review page. Giving it a clear name makes the base easy to identify when you scope the token.

  • Return to your Airtable home screen.
  • Click + Create.
  • Select your preferred workspace.
  • Select Build an app on your own.
  • Click the current base name in the top-left corner.
  • Enter Application Review as the new base name.
  • Press Enter to save the name.

You now have a dedicated base called Application Review. This keeps the project data separate from unrelated work.

Build the Applications table

Every application needs one decision state at a time. A single select field keeps those states consistent across every record.

  • Open the menu on the first table tab.
  • Choose the option to rename the table.
  • Enter Applications as the table name.
  • Press Enter to confirm the name.
  • Click the add-field control to the right of the existing columns.
  • Enter Status as the field name.
  • Search for Single select in the field type list.
  • Select Single select.

Why use a single select field?

A single select field allows one value from a fixed list. Your API can filter for Pending without handling spelling variations.

  • Click Add option.
  • Enter Pending as the first option.
  • Click Add option again.
  • Enter Approved as the second option.
  • Click Add option again.
  • Enter Rejected as the third option.
  • Click Create field.

The Status column should now offer three choices when you open an empty cell.

  • Enter Avery Chen in the first empty record.
  • Enter Jordan Patel in the second empty record.
  • Enter Sam Rivera in the third empty record.
  • Select Pending in Avery Chen's Status cell.
  • Select Pending in Jordan Patel's Status cell.
  • Select Pending in Sam Rivera's Status cell.

You should see three applicant records in the Applications table. Each row should show Pending in the Status column.

Good progress. Your review queue now has consistent records that the app can filter by status.

Create a base-scoped token

The token becomes the server credential that connects your app to Airtable. Its scopes define the actions your server can perform.

Resource access confines those actions to your project base. This boundary protects every other base in your account.

  • Click your browser's address bar while the Application Review base is open.
  • Record the URL segment that starts with app and ends before the next /: your Airtable base ID.

Your server configuration now has its public base identifier. The table name is Applications.

  • Open the Airtable Developer Hub in a new browser tab.
  • Click Create token.
  • Enter application-review-webflow-cloud as the token name.
  • Click + Add a scope.
  • Select data.records:read.
  • Click + Add a scope again.
  • Select data.records:write.
  • Click + Add a base.
  • Select Application Review as the only resource.

You should see exactly two scopes on the token draft. You should see one base under resource access.

How scopes and resources work

  • The data.records:read scope lets your server list pending application records.
  • The data.records:write scope lets your server update an application's decision.
  • The resource selection confines both scopes to Application Review.

The next screen reveals a credential that can change application records. Keep it private because it belongs only in server-side configuration.

  • Click Create token.
  • Copy the full token value before leaving the reveal screen.
  • Store the token in a secure credential manager for the server configuration step.
  • Close the token reveal.

Before you check the token details, which single base do you expect to see under resource access?

  • Review your new token's details from the Developer Hub.

You should see data.records:read in the scopes list. You should also see data.records:write.

You should see only Application Review under resource access. That gives your reviewer app a credential that cannot reach your other bases.

Token setup not matching?

  • Confirm that your signed-in Airtable account can edit records in Application Review.
  • Remove any workspace-wide resource access before selecting only Application Review.
  • Close the token reveal before taking a screenshot if the full credential remains visible.

Still stuck? Help me check my Airtable token scopes and base access.

Your Airtable data boundary is ready. Next up, you'll build the local route that returns only pending applications.

Build the Pending Applications Route

Your Airtable base is ready. Its scoped token can reach only the project base.

The reviewer still needs a narrow view that returns pending applications without exposing the rest of the base. A Next.js Route Handler provides that boundary through server-only environment variables.

In this step, get ready to:
  • Scaffold the local Next.js project.
  • Configure the server-only Airtable settings.
  • Prove the pending applications route works with curl.
Scaffold and run the Next.js app

The scaffold creates the App Router structure that holds your API routes. It also installs the local development server used throughout the build.

The first download can take a few minutes while npm installs the project packages. A busy terminal during this wait means the scaffold is still working.

  • Approve the one-time package download if npx asks for permission after the command starts.
  • Create the application-reviewer app on your Desktop by running these commands:
cd ~/Desktop
npx create-next-app@latest application-reviewer --ts --app --use-npm --yes

What do these commands do?

  • The first command moves your terminal to the Desktop. This gives the project a predictable location.
  • The second command creates a TypeScript project named application-reviewer.
  • The --app option selects the App Router. The --use-npm option installs packages with npm.
  • The --yes option accepts the recommended project defaults.
  • Confirm the final terminal output reports that the application-reviewer project was created successfully.

Good progress. Your local application now has the file structure needed for the protected review service.

Did the scaffold fail?

  • Check that your internet connection is active if the package download stops.
  • Confirm that the terminal is on your Desktop before trying the scaffold command again.
  • Remove an incomplete application-reviewer folder before retrying if the command says that the destination already contains files.

Still blocked? Help me troubleshoot the Next.js scaffold command.

  • Start the local development server from the new project by running these commands:
cd application-reviewer
npm run dev

What do these commands do?

  • The first command moves the terminal into the application-reviewer folder.
  • The second command starts the Next.js development server. The running process keeps this terminal occupied.

You should see the terminal report that the development server is ready. It should also show http://localhost:3000 as the local address.

Does the server stop during startup?

  • Check that your terminal path ends with application-reviewer.
  • Check that the scaffold completed before running the development server.
  • Stop any existing process using the local development port before trying again.

Need another set of eyes? Help me diagnose why the Next.js development server will not start.

Store Airtable credentials on the server

Your live token stays in the local .env.local file so it never enters application code. The default scaffold excludes local environment files from source control.

  • Press Cmd+Space on macOS or the Windows key on Windows to open the system search bar.
  • Type the name of your code editor.
  • Press Enter to open the editor.
  • Use your editor's folder command to select the application-reviewer folder on your Desktop.
  • Create .env.local inside the application-reviewer folder.
  • Add the three server-side settings by copying this template into .env.local:
AIRTABLE_TOKEN=your-airtable-token-here
AIRTABLE_BASE_ID=[[AIRTABLE_BASE_ID="your Airtable base ID"]]
AIRTABLE_TABLE_NAME=Applications

What does this configuration hold?

  • The AIRTABLE_TOKEN value authenticates server requests to Airtable. The template uses a plain placeholder because credentials must never be stored as project variables.
  • The AIRTABLE_BASE_ID value identifies the base created earlier.
  • The AIRTABLE_TABLE_NAME value points requests to the Applications table.
  • These names do not use the NEXT_PUBLIC_ prefix. Next.js therefore keeps their values on the server.
  • Replace your-airtable-token-here with the personal access token from the previous step.
  • Save .env.local.
  • Return to the terminal running the development server.
  • Stop the server by pressing Ctrl+C.
  • Reload the local settings by starting the development server again:
npm run dev

Why restart the server?

The new server process loads the values from .env.local into process.env. Your route can now read the Airtable settings without sending them to the browser.

You should see the development server reach its ready state again. Your token should remain absent from the terminal output.

Does the server fail after the restart?

  • Confirm that .env.local sits directly inside application-reviewer.
  • Check each setting for a single name followed by an equals sign.
  • Remove any spaces surrounding the equals signs.

Keep the token private while debugging. Help me check my environment file without sharing the token value.

✔️ Awesome, I've got everything!

Your .env.local file is saved. The development server is running with the local configuration loaded.

ⓧ I'd like to double check the full code

AIRTABLE_TOKEN=your-airtable-token-here
AIRTABLE_BASE_ID=[[AIRTABLE_BASE_ID="your Airtable base ID"]]
AIRTABLE_TABLE_NAME=Applications
Build and verify the pending route

The GET /api/applications route becomes the review queue's read boundary. It sends the token directly to Airtable while returning only the filtered JSON response.

  • Create app/api/applications/route.ts inside the application-reviewer folder using your editor's file-creation control.
  • Build the pending applications handler by copying this code into route.ts:
export async function GET() {
  // Read the Airtable connection values only on the server
  const token = process.env.AIRTABLE_TOKEN
  const baseId = process.env.AIRTABLE_BASE_ID
  const tableName = process.env.AIRTABLE_TABLE_NAME

  if (!token || !baseId || !tableName) {
    return Response.json(
      { error: "Missing Airtable environment variables" },
      { status: 500 }
    )
  }

  // Encode the table and formula before adding them to the request URL
  const formula = encodeURIComponent('{Status}="Pending"')
  const url = `https://api.airtable.com/v0/${baseId}/${encodeURIComponent(tableName)}?filterByFormula=${formula}`

  // Send the token from the server and request the current queue
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  })

  // Forward Airtable's filtered records and response status as JSON
  const data = await response.json()
  return Response.json(data, { status: response.status })
}

What does this route do?

  • The GET() function handles requests sent to /api/applications.
  • The three process.env lookups read your Airtable configuration on the server.
  • The configuration check stops the request if any required value is missing.
  • The filterByFormula query selects records whose Status field equals Pending.
  • The authorization header carries the token from the server to Airtable. The browser never receives the token.
  • The no-store cache setting requests the current queue each time.
  • Save app/api/applications/route.ts.

Before you run the check, do you expect the response to contain the whole table or only the review queue?

  • Open a second terminal panel in your code editor.
  • Request the local pending applications route by running this command:
curl http://localhost:3000/api/applications

What does this command check?

The curl command sends a GET request to your local route. The returned JSON shows exactly what a future review page can receive.

You should see Airtable records whose Status field is Pending. Approved records should be absent. Rejected records should also be absent.

That is the access boundary working. Reviewers can now retrieve the queue without opening the Airtable base.

Does the route return an error?

  • Restart the development server if the response mentions missing environment configuration.
  • Check the token's base access if Airtable rejects the request.
  • Confirm that AIRTABLE_BASE_ID contains the project base ID.
  • Confirm that AIRTABLE_TABLE_NAME matches Applications exactly if the response contains no records.

Still seeing the wrong response? Help me debug the pending applications route without exposing my Airtable token.

✔️ Awesome, I've got everything!

Your route is saved. The curl response contains only pending application records.

ⓧ I'd like to double check the full code

export async function GET() {
  // Read the Airtable connection values only on the server
  const token = process.env.AIRTABLE_TOKEN
  const baseId = process.env.AIRTABLE_BASE_ID
  const tableName = process.env.AIRTABLE_TABLE_NAME

  if (!token || !baseId || !tableName) {
    return Response.json(
      { error: "Missing Airtable environment variables" },
      { status: 500 }
    )
  }

  // Encode the table and formula before adding them to the request URL
  const formula = encodeURIComponent('{Status}="Pending"')
  const url = `https://api.airtable.com/v0/${baseId}/${encodeURIComponent(tableName)}?filterByFormula=${formula}`

  // Send the token from the server and request the current queue
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  })

  // Forward Airtable's filtered records and response status as JSON
  const data = await response.json()
  return Response.json(data, { status: response.status })
}

Your local read route now exposes only the applications waiting for a decision. Next, you will add the write route that records a reviewer's choice.

Add Application Decisions

Your read route has narrowed the queue to pending applications. Reviewers still need a safe way to record a decision without entering Airtable.

A Next.js route handler can receive the selected record ID through its URL. You’ll connect that route to Airtable so an approval changes the matching record.

In this step, get ready to:
  • Create a decision route that accepts Approved or Rejected.
  • Approve a pending application through the local API.
  • Confirm the selected record changes in the Airtable grid.
Build the decision route

A dynamic route uses part of its URL as the record ID. The request body carries the reviewer’s decision as JSON.

  • Switch back to the code editor holding your application-reviewer project.
  • Expand app/api/applications in the Explorer sidebar.
  • Click the New Folder icon beside applications.
  • Enter [recordId] as the folder name.

You should see [recordId] nested inside app/api/applications.

  • Click the New File icon beside [recordId].
  • Enter route.ts as the file name.

The resulting path should be application-reviewer/app/api/applications/[recordId]/route.ts.

  • Fill route.ts with the decision handler by copying the code below:
type Decision = "Approved" | "Rejected";

const allowedDecisions: Decision[] = ["Approved", "Rejected"];

export async function PATCH(request: Request, { params }: { params: Promise<{ recordId: string }> }) {
  // Accept only the two decisions supported by the Status field
  const { decision } = (await request.json()) as { decision: Decision };
  if (!allowedDecisions.includes(decision)) {
    return Response.json({ error: "Invalid decision" }, { status: 400 });
  }

  // Build the record-specific Airtable request from server-only values
  const { recordId } = await params;
  const baseId = process.env.AIRTABLE_BASE_ID;
  const tableName = process.env.AIRTABLE_TABLE_NAME;
  const token = process.env.AIRTABLE_TOKEN;
  const response = await fetch(`https://api.airtable.com/v0/${baseId}/${encodeURIComponent(tableName!)}/${recordId}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ fields: { Status: decision } }),
  });

  // Return Airtable's response with its original status code
  const record = await response.json();
  return Response.json(record, { status: response.status });
}

What does this code do?

  • The Decision type limits decisions to the two final values supported by the Airtable Status field.
  • The PATCH handler rejects any other value before contacting Airtable.
  • The awaited params object supplies the record ID captured by the dynamic folder.
  • The Airtable request updates only Status. Every other field remains unchanged.
  • Save app/api/applications/[recordId]/route.ts.
  • Check the terminal running your local app for a successful compilation.

Your local server should remain running without a TypeScript error. The new route is now ready to receive a decision.

Seeing a route error?

  • Confirm that route.ts sits inside the [recordId] folder.
  • Check that the dynamic folder uses the exact lowercase spelling recordId.
  • Compare each closing brace with the complete file below.

Still stuck? Help me debug my dynamic Next.js PATCH route.

✔️ Awesome, I've got everything!

Your decision route is saved. Keep the local app running for the API test.

ⓧ I'd like to double check the full code

Compare your app/api/applications/[recordId]/route.ts file with this complete version.

type Decision = "Approved" | "Rejected";

const allowedDecisions: Decision[] = ["Approved", "Rejected"];

export async function PATCH(request: Request, { params }: { params: Promise<{ recordId: string }> }) {
  // Accept only the two decisions supported by the Status field
  const { decision } = (await request.json()) as { decision: Decision };
  if (!allowedDecisions.includes(decision)) {
    return Response.json({ error: "Invalid decision" }, { status: 400 });
  }

  // Build the record-specific Airtable request from server-only values
  const { recordId } = await params;
  const baseId = process.env.AIRTABLE_BASE_ID;
  const tableName = process.env.AIRTABLE_TABLE_NAME;
  const token = process.env.AIRTABLE_TOKEN;
  const response = await fetch(`https://api.airtable.com/v0/${baseId}/${encodeURIComponent(tableName!)}/${recordId}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ fields: { Status: decision } }),
  });

  // Return Airtable's response with its original status code
  const record = await response.json();
  return Response.json(record, { status: response.status });
}
Approve a pending application

The decision route needs the Airtable record ID returned by your existing read route. Choose one pending application that you are comfortable using as the test record.

  • Switch back to the terminal you used earlier for local API requests.
  • List the pending applications by running this command:
curl http://localhost:3000/api/applications

What does this command do?

This calls your existing read route. Its response gives you the pending application records that are safe to use for this test.

  • Copy the id value from one pending record into your pending Airtable record ID.

Before you send the decision, do you think the selected record’s status will stay Pending or change to Approved?

  • Send an approval to the selected record by running this command:
curl -X PATCH http://localhost:3000/api/applications/[[RECORD_ID="your pending Airtable record ID"]] -H "Content-Type: application/json" -d '{"decision":"Approved"}'

What does this command do?

  • The -X PATCH option selects the update method handled by your new route.
  • The URL places your selected record ID into the recordId dynamic segment.
  • The request body sends Approved as the decision.

You should receive the updated Airtable record as JSON. Its Status field should contain Approved.

  • Switch back to the Airtable base from earlier.
  • Select the Applications table.
  • Locate the tested record by matching its visible application fields with the curl response.

You should see the record’s Status set to Approved. That confirms your local API can write a reviewer’s decision back to Airtable.

Status still shows Pending?

  • Confirm that the record ID in the request came from the pending application you are checking.
  • Confirm that the request body uses the exact value Approved.
  • Check that your Airtable token still has permission to update records in this base.

Need another pair of eyes? Help me troubleshoot why my PATCH request did not update the Airtable Status field.

Your review API can now read pending applications and record a final decision. Next, you’ll place a password challenge in front of the page and its API routes.

Protect the Reviewer App

Your read route returns pending applications. Your write route records each review decision.

Anyone who reaches the app can currently use those routes. Next.js Middleware closes that gap before any page or API route runs. HTTP Basic authentication uses the browser's credential prompt as the front door.

In this step, get ready to:
  • Store a reviewer password in server-only local configuration.
  • Protect page requests plus API requests with Next.js Middleware.
  • Confirm the browser requests credentials before application content renders.
Store the reviewer password

The reviewer password belongs in .env.local because that file already holds your server-only configuration. This part deserves care because it handles a credential.

  • Switch back to .env.local in your code editor.
  • Add the reviewer password setting by copying the pattern below. Type your own strong password in place of the plain-text example before you save:
REVIEWER_PASSWORD=choose-your-own-strong-password

Why Keep This Server-Side?

The REVIEWER_PASSWORD key gives your middleware a server-side value to compare against each request. The browser never receives this environment value.

Your password stays beside the Airtable credentials in .env.local. Keep that file out of source control.

  • Save .env.local.
  • Return to the development server terminal from earlier.

You'll see the development server recompile. Your local app becomes reachable again at the same address.

Server Not Picking Up the Password?

  • Confirm the key is spelled exactly as REVIEWER_PASSWORD.
  • Check that the line contains one equals sign between the key and your password.
  • Restart the development server with the same command you used earlier if it stays idle.

Still stuck? Help me check why my Next.js app is not reading REVIEWER_PASSWORD from .env.local.

Add request protection

A request reaches middleware.ts before it reaches your page or either API route. The middleware checks the browser credentials against the password you just stored.

  • Create middleware.ts inside the application-reviewer folder by adding the code below. Place the file beside the existing app folder:
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  // Read browser credentials before any page or API route runs
  const authorization = request.headers.get("authorization");

  if (authorization?.startsWith("Basic ")) {
    // Decode the username and password supplied by the browser
    const encodedCredentials = authorization.slice(6);
    const decodedCredentials = atob(encodedCredentials);
    const [username, ...passwordParts] = decodedCredentials.split(":");
    const password = passwordParts.join(":");
    if (username === "reviewer" && password === process.env.REVIEWER_PASSWORD) {
      return NextResponse.next();
    }
  }

  // Ask the browser to show its built-in credential prompt
  return new NextResponse("Authentication required", {
    status: 401,
    headers: {
      "WWW-Authenticate": 'Basic realm="Reviewer App"',
    },
  });
}

export const config = {
  // Protect pages and API routes while allowing framework assets to load
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

What Does This Code Do?

  • The middleware() function reads the request's authorization header before the requested route runs.
  • The atob() call decodes the browser credentials into a username plus password.
  • The password comparison uses REVIEWER_PASSWORD from .env.local. A valid request continues through NextResponse.next().
  • The matcher covers the review page plus every API route. It excludes only the framework assets required to render the page.
  • An invalid request receives a 401 response with a WWW-Authenticate header. That response triggers the browser's credential prompt.
  • Save middleware.ts.
  • Return to the development server terminal to watch the new file compile.

You'll see the server finish a fresh compile without TypeScript errors. The password boundary is now active for incoming requests.

Seeing a Middleware Error?

  • Confirm middleware.ts sits beside the app folder inside application-reviewer.
  • Compare every brace plus quote with the full-file reference below.
  • Keep the middleware file free of a runtime declaration so the later Webflow Cloud build can use it.

Need another pair of eyes? Help me debug the compile error in my middleware.ts file.

✔️ Awesome, I've got everything!

Your middleware.ts file is saved beside the app folder. Your development server compiles it successfully.

ⓧ I'd like to double check the full code

Compare your complete middleware.ts file with this reference.

import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  // Read browser credentials before any page or API route runs
  const authorization = request.headers.get("authorization");

  if (authorization?.startsWith("Basic ")) {
    // Decode the username and password supplied by the browser
    const encodedCredentials = authorization.slice(6);
    const decodedCredentials = atob(encodedCredentials);
    const [username, ...passwordParts] = decodedCredentials.split(":");
    const password = passwordParts.join(":");
    if (username === "reviewer" && password === process.env.REVIEWER_PASSWORD) {
      return NextResponse.next();
    }
  }

  // Ask the browser to show its built-in credential prompt
  return new NextResponse("Authentication required", {
    status: 401,
    headers: {
      "WWW-Authenticate": 'Basic realm="Reviewer App"',
    },
  });
}

export const config = {
  // Protect pages and API routes while allowing framework assets to load
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
Verify the browser challenge

Browsers can remember Basic authentication credentials for an active session. A private browsing window gives this check a fresh request with no saved credentials.

Before you load the local app, do you expect any application content to appear before the credential prompt?

  • Open a new private browsing window.
  • Enter the same local application address you used earlier.

You'll see the browser request a username plus password before it renders application content. That prompt proves the middleware intercepted the request first.

  • Enter reviewer in the username field.
  • Enter the reviewer password you saved in .env.local.
  • Submit the credential prompt.

That boundary is doing its job. Your application content now renders only after the correct reviewer credentials pass through middleware.

Prompt Keeps Coming Back?

  • Confirm the username is exactly reviewer.
  • Retype the password exactly as it appears after REVIEWER_PASSWORD= in .env.local.
  • Restart the development server if you changed the password after the last compile.

Still locked out? Help me diagnose why my reviewer credentials are being rejected.

Your reviewer boundary now covers the page plus its APIs. Next up, you'll build the queue interface that lets a reviewer approve or reject each pending application.

Build the Review Interface

Your password gate now protects your Next.js application. However, a reviewer still needs a focused page that keeps direct Airtable access out of reach.

In this step, you'll turn the protected application into a pending review queue. The page loads the records returned by GET /api/applications.

Each decision travels through the protected write route. A successful update removes the decided record from the visible queue.

In this step, get ready to:
  • Display pending application records on the protected page.
  • Connect Approve and Reject buttons to the write route.
  • Verify each decision in the browser and Airtable.
Display pending applications

The page needs browser state because the records arrive after it loads. A Client Component can fetch the protected route with useEffect and store the returned queue with useState.

  • In your code editor's file sidebar, select app/page.tsx inside the application-reviewer folder.
  • Replace the existing contents of app/page.tsx by copying this code:
'use client'

import { useState, useEffect } from 'react'

type Application = { id: string; fields: Record<string, unknown> }

export default function Home() {
  const [applications, setApplications] = useState<Application[]>([])

  useEffect(() => {
    // Load the pending queue when the reviewer arrives.
    fetch('/api/applications')
      .then((response) => response.json())
      .then((data) => setApplications(Array.isArray(data) ? data : data.records ?? []))
  }, [])

  return (
    <main>
      <h1>Pending applications</h1>
      {applications.length === 0 && <p>No pending applications.</p>}
      {applications.map((application) => (
        <article key={application.id}>
          {Object.entries(application.fields).map(([label, value]) => (
            <p key={label}><strong>{label}:</strong> {String(value)}</p>
          ))}
        </article>
      ))}
    </main>
  )
}

What Does This Code Do?

  • The 'use client' directive allows this page to use browser state and lifecycle logic.
  • The useEffect hook requests the pending records after the page loads.
  • The applications state holds the current review queue.
  • The Object.entries call renders every field returned for an application. The interface adapts to the fields already stored in the table.
  • Save app/page.tsx.
  • Return to the authenticated local app from the previous step.

You'll see the Pending applications heading with one section for each pending record. Only No pending applications. appears when the protected read route returns an empty queue.

Don't See the Pending Queue?

  • Confirm the local development server is still running in the terminal from earlier.
  • Refresh the authenticated page if the browser retained an older version of the interface.
  • Check that the existing read route still returns pending records when accessed with the reviewer credentials.

Still stuck? Help me debug why app/page.tsx is not displaying records from GET /api/applications.

Add approve and reject actions

A decision changes server data. The page must wait for the write route to confirm success before hiding the record.

This order keeps failed updates visible to the reviewer. The queue always reflects decisions that Airtable accepted.

  • In app/page.tsx, find the closing }, []) line in useEffect.
  • Insert the decision function below useEffect by copying this code:
  async function decide(id: string, decision: 'Approved' | 'Rejected') {
    // Wait for Airtable to accept the decision before hiding the record.
    const response = await fetch(`/api/applications/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ decision }),
    })
    if (!response.ok) return
    setApplications((items) => items.filter((item) => item.id !== id))
  }

Why Wait for the Response?

The request includes the selected record ID in the route. Its body carries either Approved or Rejected as the decision.

The state filter runs only after the route reports success. A rejected update remains visible for another attempt.

  • Save app/page.tsx.
  • Return to the authenticated local app.

The pending queue still renders without a compilation failure. This confirms the new decision helper is valid before you connect it to the interface.

  • In app/page.tsx, find the </article> line inside the application map.
  • Insert the decision buttons above that line by copying this code:
          {/* Send the record ID with the reviewer's decision. */}
          <button onClick={() => decide(application.id, 'Approved')}>
            Approve
          </button>
          <button onClick={() => decide(application.id, 'Rejected')}>
            Reject
          </button>

How Do the Buttons Work?

  • Each onClick handler passes the current record's id to decide.
  • The Approve button sends the Approved decision.
  • The Reject button sends the Rejected decision.
  • Save app/page.tsx.
  • Return to the authenticated local app.

You'll see Approve and Reject beneath every pending application.

Don't See the Decision Buttons?

  • Confirm both buttons sit inside the <article> block before its closing tag.
  • Check that the handler name is exactly decide in the function and both buttons.
  • Compare your file with the complete version below if the page reports a compilation problem.

Need a hand? Help me fix the Approve and Reject buttons in my Next.js review page.

✔️ Awesome, I've got everything!

Your protected page now shows pending applications with both decision buttons. Make sure app/page.tsx is saved.

ⓧ I'd like to double check the full code

Compare your app/page.tsx file with this complete version.

'use client'

import { useState, useEffect } from 'react'

type Application = { id: string; fields: Record<string, unknown> }

export default function Home() {
  const [applications, setApplications] = useState<Application[]>([])

  useEffect(() => {
    // Load the pending queue when the reviewer arrives.
    fetch('/api/applications')
      .then((response) => response.json())
      .then((data) => setApplications(Array.isArray(data) ? data : data.records ?? []))
  }, [])

  async function decide(id: string, decision: 'Approved' | 'Rejected') {
    // Wait for Airtable to accept the decision before hiding the record.
    const response = await fetch(`/api/applications/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ decision }),
    })
    if (!response.ok) return
    setApplications((items) => items.filter((item) => item.id !== id))
  }

  return (
    <main>
      <h1>Pending applications</h1>
      {applications.length === 0 && <p>No pending applications.</p>}
      {applications.map((application) => (
        <article key={application.id}>
          {Object.entries(application.fields).map(([label, value]) => (
            <p key={label}><strong>{label}:</strong> {String(value)}</p>
          ))}
          {/* Send the record ID with the reviewer's decision. */}
          <button onClick={() => decide(application.id, 'Approved')}>
            Approve
          </button>
          <button onClick={() => decide(application.id, 'Rejected')}>
            Reject
          </button>
        </article>
      ))}
    </main>
  )
}
Verify the reviewer workflow

The interface now spans the protected read route and the protected write route. A complete check needs visible evidence in both the browser and the Airtable grid.

Before you click a decision, which two places should change if the full review path works?

  • Click Approve or Reject on one pending application.

You'll see the selected application leave the pending list after the write request succeeds.

  • Switch back to the Applications table in Airtable from earlier.
  • Locate the record you decided through the review page.

You'll see its Status set to the decision you clicked. That's the complete reviewer loop working through the protected interface.

Did the Record Stay in the Queue?

  • Check the terminal running the local app for a failed write request.
  • Confirm the button passes application.id into decide.
  • Set an existing test record's Status to Pending in Airtable if the page has no records to test.

Still stuck? Help me trace why a review decision is not updating Airtable or leaving the pending list.

Your reviewer can now process pending applications without entering the base. Next, you'll deploy this protected workflow to Webflow Cloud and test it on the live URL.

Deploy to Webflow Cloud

Your protected review queue already reads pending applications from Airtable. It also writes decisions back from your machine.

Webflow Cloud gives the Next.js app a live /review path for a reviewer. The deployment keeps the Airtable token on the server behind the password challenge.

In this step, get ready to:
  • Prepare the Webflow CLI for deployment.
  • Mount the application at /review with its server environment variables.
  • Authenticate on the live page and approve a test application.
Prepare the deployment target

A Webflow Cloud environment connects your application to one live mount path. The Webflow CLI creates that deployment from the local application-reviewer workspace.

  • Create a second terminal tab beside the terminal that is running your local application.
  • Return to the application-reviewer folder from earlier in the second terminal tab.
  • Check whether the Webflow CLI is available by running this command:
webflow --version

What does this command do?

The command asks the Webflow CLI to print its installed version. A version number confirms that the deployment command is available in this terminal.

✔️ I see a version number

The Webflow CLI is ready. You can use it to create the Webflow Cloud deployment from your local project.

ⓧ Command not found

  • Install the Webflow CLI globally by running this command:
npm install -g @webflow/webflow-cli

What does this command do?

The npm command installs the Webflow CLI as a global command. Global installation makes webflow available from the application-reviewer folder.

Your terminal should return to its prompt without reporting a failed installation. That gives you the deployment command needed for the next action.

Installation blocked?

  • Confirm that npm still works in this terminal.
  • Check that your network permits downloads from the npm package registry.

Still stuck? Help me troubleshoot the Webflow CLI installation. You can also ask the Webflow community.

The first deployment opens an interactive wizard. It can also open your browser so you can authorize the CLI with your signed-in Webflow account.

The first build can take a few minutes while Webflow Cloud prepares the application. A quiet terminal during part of that build is expected.

  • Choose Existing site when the wizard asks for the deployment target.
  • Select the Webflow site that should host the review page.
  • Enter application-reviewer if the wizard requests an app name.
  • Enter /review as the mount path.
  • Confirm Next.js if the wizard asks you to verify the detected framework.
  • Start the Webflow Cloud deployment by running this command:
webflow cloud deploy

What does this command do?

The command sends the local application to Webflow Cloud. Webflow Cloud detects Next.js before building the application for the selected site.

The mount path places the application under /review on the Webflow site. This keeps the reviewer interface separate from the site's other pages.

You should see the deployment finish successfully. Webflow Cloud now has an environment for the application at the selected mount path.

  • Record the live review address here: https://your-site.webflow.io/review.

Deployment did not finish?

  • Complete the browser authorization if the CLI is waiting for access to your Webflow account.
  • Open the deployment log in Webflow Cloud if the build stops.
  • Locate the first failed build step because later messages often describe the same failure again.

Need a hand? Help me troubleshoot my Webflow Cloud deployment. You can also ask the Webflow community.

Add server environment variables

Environment variables give the deployed application the same configuration it uses locally. Webflow Cloud provides them to the application during the build and at runtime.

  • Return to the Webflow Cloud browser page from the deployment.
  • Select the application-reviewer app to open its environments dashboard.
  • Select the environment mounted at /review.
  • Open Environment Variables.

This is the sensitive part. Webflow Cloud masks values marked as secrets in its dashboard and build logs.

  • Create AIRTABLE_TOKEN as a Secret variable using its matching value from .env.local.
  • Create AIRTABLE_BASE_ID as a standard variable using its matching value from .env.local.
  • Create AIRTABLE_TABLE_NAME as a standard variable using its matching value from .env.local.
  • Create REVIEWER_PASSWORD as a Secret variable using its matching value from .env.local.

Why configure these values again?

The deployed app cannot read the .env.local file on your computer. Its Webflow Cloud environment needs a separate copy of each server value.

The Airtable token remains outside the browser bundle. The review page reaches Airtable through the server routes.

  • Start a new build by clicking Deploy latest build in the environment dashboard.

The new deployment should finish successfully after a few minutes. This build includes the four server environment variables.

Build failing after configuration?

  • Confirm that all four environment variable names match .env.local exactly.
  • Confirm that the variables belong to the environment mounted at /review.
  • Start another deployment after correcting any missing variable.

Still seeing a failed build? Help me inspect my Webflow Cloud environment configuration.

Verify the live review flow

The live check must prove the full reviewer journey. It covers the password boundary before confirming that a decision reaches Airtable.

Before you visit the live path, do you expect application data to appear before or after the password challenge?

  • Open a private window in your browser.
  • Visit https://your-site.webflow.io/review.

You should see the browser password challenge before any application content renders. This confirms that the live /review path remains protected.

  • Enter the same reviewer credentials you used for the local test.

You should see the pending applications from Airtable after authentication. Approved records and rejected records should remain absent.

Before you approve a test application, what two places do you expect to change?

  • Click Approve for one test application.

The selected application should leave the live pending list after the request succeeds.

  • Switch back to the Airtable grid from earlier.
  • Refresh the Applications table.

You should see the same record with its Status set to Approved. The live reviewer action has reached Airtable without exposing the base to the reviewer.

Live approval not working?

  • Confirm that the live URL ends with the configured /review mount path.
  • Confirm that REVIEWER_PASSWORD exists in the same Webflow Cloud environment as the deployment.
  • Confirm that the three AIRTABLE_ values match the working local configuration.

Need help tracing the request? Help me debug the live review approval flow.

That completes the reviewer handoff. Your live page now protects the queue before writing approved decisions back to Airtable.

Secret mission

Audit a Two-Reviewer Handoff

A two-session audit tests the live review queue from both sides. You will prove that an unauthenticated reviewer is blocked. You will confirm that a decision reaches the other session after a refresh.

Clean Up Your Resources

Clean Up Your Resources

Your live webflow.io deployment uses Webflow Cloud, which is included from the free Starter site plan upward. Choose whether to keep the app active, pause its access, or remove its deployment and local files.

Resources you used:

  • Webflow Cloud deployment for application-reviewer mounted at /review with its server-side environment variables.
  • A scoped Airtable personal access token with permission to read records and update records in the project base.
  • Local application-reviewer project folder containing the Next.js application and application-reviewer/.env.local configuration.
  • Local development process serving the protected application reviewer app.

Keep Your Application Records

The Applications table contains the decisions recorded through your review page. Cleanup removes the app's access while preserving those Airtable records.

Keep everything running

No action is needed. Choose this option while reviewers still need the live application.

  • Keep the application-reviewer deployment mounted at /review.
  • Keep the Airtable personal access token active while the app needs to read or update application records.
  • Preserve the local application-reviewer folder for future development.
  • Keep application-reviewer/.env.local outside source control so its secrets remain server-side.

Rotate Shared Test Passwords

If anyone outside the intended reviewer group saw your test password, replace the REVIEWER_PASSWORD value in application-reviewer/.env.local.

Set the same private value in the Webflow Cloud server-side environment configuration. A fresh password closes access left over from testing.

Pause - I'll come back to this later

Shut down the local process to free its port. Revoke the Airtable token to pause the deployed app's access while keeping your project files and deployment available.

  • Return to the terminal running the local application reviewer app.
  • Press Ctrl+C to stop the local development process.
  • Refresh the local reviewer URL to confirm that it no longer responds.
  • Return to the Airtable personal access token management page from earlier.
  • Select the token scoped to the base containing the Applications table.
  • Revoke the token from its token details.
  • Refresh the live /review page to confirm that it can no longer load pending applications.

That failed data request confirms the paused deployment can no longer access your Airtable records.

  • Create a replacement base-scoped token with the same record read and update permissions when you resume.
  • Replace AIRTABLE_TOKEN in application-reviewer/.env.local with the replacement token.
  • Replace AIRTABLE_TOKEN in the Webflow Cloud server-side environment configuration with the replacement token.
  • Restart the local development process using the same method from earlier.

Delete - I don't want to use this again

Remove the project resources if you no longer need the reviewer app. Your tested application records remain intact inside the Airtable Applications table.

Stop local access
  • Return to the terminal running the local application reviewer app.
  • Press Ctrl+C to stop the local development process.
  • Refresh the local reviewer URL to confirm that it no longer responds.
Revoke Airtable access
  • Return to the Airtable personal access token management page from earlier.
  • Select the token scoped to the project base.
  • Revoke the token from its token details.
  • Refresh the live /review page to confirm that it can no longer fetch application records.

Your deployed app has lost its ability to read or update the Airtable base.

Remove the deployment
  • Return to the Webflow Cloud project containing the deployed application-reviewer app.
  • Select the app mounted at /review.
  • Use the app's deployment settings to delete the deployment.
  • Visit the former /review path to confirm that it no longer serves the reviewer interface.

The live reviewer interface and its server-side environment configuration are no longer available through Webflow Cloud.

Delete the local project

The final deletion removes application-reviewer/.env.local with the project folder. Confirm that you no longer need its local values before continuing.

macOS

  • Remove the local application-reviewer folder from the terminal where you stopped the app by running:
cd ..
rm -rf application-reviewer

The first command returns to the folder above the project. The second command permanently removes application-reviewer with its local configuration.

  • Confirm the project folder is gone by running:
ls

You should no longer see application-reviewer in the folder listing.

Windows

  • Remove the local application-reviewer folder from the Command Prompt where you stopped the app by running:
cd ..
rmdir /s /q application-reviewer

The first command returns to the folder above the project. The second command permanently removes application-reviewer with its local configuration.

  • Confirm the project folder is gone by running:
dir

You should no longer see application-reviewer in the folder listing.

Linux

  • Remove the local application-reviewer folder from the terminal where you stopped the app by running:
cd ..
rm -rf application-reviewer

The first command returns to the folder above the project. The second command permanently removes application-reviewer with its local configuration.

  • Confirm the project folder is gone by running:
ls

You should no longer see application-reviewer in the folder listing.

Nice Work!

Nice Work!

You did it! You built a protected Next.js review queue that lets a reviewer decide Airtable applications through Webflow Cloud. The reviewer can work without direct access to the base.

You've learned how to:

  • Kept a scoped Airtable token inside server-only environment variables. Returned only applications with a Pending status through a pending-only API route.
  • Built Approve and Reject controls that update the matching Airtable record. Removed each decided application from the visible list after the update succeeded.
  • Protected the review page and both API routes with a reviewer password. Deployed the complete workflow at /review. Confirmed that a live approval changed the matching Airtable status.
  • Completed an optional Secret Mission to push the protected reviewer workflow further.

Ready to quiz yourself?