How to build a distance calculator with Google Maps in Webflow

How to build a Google Maps distance calculator for service-area sites with Webflow Cloud

Learn how to build a Google Maps distance calculator as a Next.js Route Handler on Webflow Cloud.

How to build a Google Maps distance calculator for service-area sites with Webflow Cloud

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

A Webflow Cloud route that holds the Google key on the server gives service-area visitors a road distance and drive time without exposing the key on the page.

Moving companies and regional installers frequently need to calculate distances to provide accurate quotes. Querying Google Maps directly from the client exposes your API key in the page source, leaving it vulnerable to unauthorized use.

An alternative is to use a server-side route instead. In this guide, we’ll build a Next.js app on Webflow Cloud that holds the key as a secret, sends the visitor's address to the Google Routes API, and returns a road distance and drive time the page can act on.

The app serves the calculator at a mount path such as /tools, and the rest of the site stays in the Designer.

What do you need to build a Google Maps distance calculator in Webflow?

Building the calculator requires five prerequisites and the right plan access:

  • Webflow site plan: A Webflow site with access to Webflow Cloud.
  • Google Cloud project: Billing enabled and permission to create API keys; for client work, create it under the client's own billing account so the key and charges stay theirs.
  • Node.js and npm: Webflow Cloud requires Node.js 22 or later and supports only npm, while the Webflow CLI 2.x requires Node.js 22.13.0 or later, so pnpm and yarn commands produce steps that will not run.
  • Webflow CLI: Install @webflow/webflow-cli, whose 2.x releases require Node.js 22.13.0 or later; the unscoped webflow-cli is an unrelated 2019 tool.
  • Next.js 15 or later: It is the minimum framework version Webflow Cloud supports; after scaffolding, check that the next version in package.json is 15 or later.

With these five prerequisites in place, I set the mount path first because every client fetch must include the path manually. Google setup comes next because the key has to exist before the route can be tested.

6 steps to build a Google Maps distance calculator in Webflow Cloud

Each calculation sends a POST request to /api/distance. The route forwards it to the Routes API with a secret key, and the form calls it through the mount path.

The six steps cover the restricted key, Next.js app, server route, client form, environment variables, and mounted deployment.

1. Create a restricted Google Maps API key

I enable the Routes API and create a key for it. In the Google Cloud console, open the project you want billed, enable the Routes API, then create an API key and copy the value before closing the dialog.

Older tutorials use the Distance Matrix API, but Google moved it to Legacy status, and it can no longer be enabled for new Cloud projects; the Routes API's computeRouteMatrix method is its replacement.

Restrict the key immediately. Under the key's settings, limit it to the Routes API only. The API restriction confines it to the intended service and excludes the Maps JavaScript API and Places. Because this key is only ever used from the server-side route, skip the HTTP referrer restriction, which is a setting for keys used in browser pages.

Google's security guidance prefers an IP address restriction for server keys. Still, Webflow Cloud doesn't document a fixed outbound IP address for you to list, so the practical limit here is the API restriction plus a quota cap.

I treat an unrestricted key as a defect even in a test project, because the key often outlives the project that created it. I now have a key string that works for exactly one Google API, with billing enabled on its project, and I keep it out of the repository because it goes into a Webflow Cloud secret variable later.

2. Scaffold the Next.js app with npm and install the Webflow CLI

I create a fresh Next.js project using the App Router and TypeScript, and I use npm throughout. Once the scaffold finishes, I check the next entry in package.json against the Next.js 15 floor.

I run node --version before installing the CLI and confirm it reports Node.js 22.13.0 or later.

From the folder where I keep client projects, I scaffold the app and install the CLI:

npx create-next-app@15 distance-calculator --typescript --app --no-tailwind --eslint --yes
cd distance-calculator
npm install -g @webflow/webflow-cli@2

The app is now runnable, and the CLI is on your path. Keep the app on the supported runtime by leaving export const runtime = 'edge' out of every file, even though a Webflow docs page still suggests it for API routes.

The directive targets the Next.js edge runtime, which the OpenNext Cloudflare adapter does not support. Leaving it out prevents a broken build. This build needs no Node built-ins at all, which sidesteps the modules Webflow Cloud's Workers runtime does not provide.

I start the dev server with npm run dev and confirm the default page renders on localhost:3000.

3. Write the Route Handler that calls the Routes API

I build the server-side proxy as a single POST handler that validates input, reads the key from the environment, calls Google, and returns only the fields the page needs. The narrow response keeps raw Google payloads away from the client, and the handler filters invalid input before anything reaches Google.

Create app/api/distance/route.ts with this content:

// app/api/distance/route.ts
import { NextResponse } from 'next/server';

const ENDPOINT = 'https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix';
const FIELD_MASK = 'originIndex,destinationIndex,condition,distanceMeters,localizedValues';
const MAX_INPUT_LENGTH = 200;

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);
  const origin = typeof body?.origin === 'string' ? body.origin.trim() : '';
  const destination = typeof body?.destination === 'string' ? body.destination.trim() : '';

  if (!origin || !destination) {
    return NextResponse.json(
      { error: 'origin and destination are required' },
      { status: 400 }
    );
  }

  if (origin.length > MAX_INPUT_LENGTH || destination.length > MAX_INPUT_LENGTH) {
    return NextResponse.json({ error: 'input too long' }, { status: 400 });
  }

  const key = process.env.GOOGLE_MAPS_API_KEY;
  if (!key) {
    return NextResponse.json(
      { error: 'server is missing GOOGLE_MAPS_API_KEY' },
      { status: 500 }
    );
  }

  let upstream: Response;

  try {
    upstream = await fetch(ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Goog-Api-Key': key,
        'X-Goog-FieldMask': FIELD_MASK,
      },
      body: JSON.stringify({
        origins: [{ waypoint: { address: origin } }],
        destinations: [{ waypoint: { address: destination } }],
        travelMode: 'DRIVE',
        units: 'METRIC',
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return NextResponse.json({ error: 'upstream request failed' }, { status: 502 });
  }

  const data = await upstream.json().catch(() => null);

  if (!upstream.ok) {
    return NextResponse.json(
      { error: 'upstream rejected the request', status: data?.error?.status ?? null },
      { status: 502 }
    );
  }

  const element = Array.isArray(data) ? data[0] : null;

  if (!element || element.condition !== 'ROUTE_EXISTS') {
    return NextResponse.json(
      { error: 'no route found', condition: element?.condition ?? null },
      { status: 404 }
    );
  }

  return NextResponse.json({
    distance: element.localizedValues?.distance?.text,
    duration: element.localizedValues?.duration?.text,
    distanceMeters: element.distanceMeters,
  });
}

The route returns the distance and drive time as display text, plus distanceMeters as a number for any fee logic. It returns 400 for bad input and 500 for a missing key. Google failures return a 502 with Google's error code, and a 404 means Google found no drivable route.

The Routes API requires the field mask header and keeps the response to the fields the route reads, and one origin with one destination keeps each submission to a single billed element.

Securing and protecting the endpoint

An origin check and a Google quota cap add another layer of control around the paid route. Anyone can POST to /tools/api/distance directly, even though the key is hidden. The length check limits what each call can carry, while request volume remains unrestricted.

I put a middleware.ts origin check in front of the route because Webflow Cloud cannot run proxy.ts, and set a quota cap or budget alert on the Google Cloud project so request volume stays within a limit I chose.

An origin check is not authentication, because a script can forge the header, so the Google quota cap is the control that actually bounds spend. The key itself never leaves the server; the only public variable is the mount path.

Test it locally with a .env.local file containing GOOGLE_MAPS_API_KEY=your-key, then post {"origin":"Portland, OR","destination":"Seattle, WA"} to localhost:3000/api/distance with curl or your HTTP client.

A JSON body with distance, duration and distanceMeters means the Google side is wired.

4. Build the form component that posts through the mount path

I write the client form so that every fetch is prefixed with the mount path from NEXT_PUBLIC_BASE_PATH. Webflow Cloud serves the app under a path on your site (for example /tools) and leaves client-side fetch calls unchanged. A bare /api/distance resolves against the Webflow site root and misses your app.

Add the component at app/components/DistanceForm.tsx:

// app/components/DistanceForm.tsx
'use client';

import { useState } from 'react';

const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

export default function DistanceForm() {
  const [origin, setOrigin] = useState('');
  const [destination, setDestination] = useState('');
  const [result, setResult] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setLoading(true);
    setResult(null);

    try {
      const response = await fetch(`${BASE_PATH}/api/distance`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ origin, destination }),
      });

      const data = await response.json().catch(() => null);
      setResult(
        response.ok && data
          ? `${data.distance}, about ${data.duration} by road`
          : data?.error ?? 'Something went wrong'
      );
    } catch {
      setResult('Could not reach the distance service');
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Your address
        <input
          value={origin}
          onChange={(e) => setOrigin(e.target.value)}
          required
          maxLength={200}
        />
      </label>
      <label>
        Our location
        <input
          value={destination}
          onChange={(e) => setDestination(e.target.value)}
          required
          maxLength={200}
        />
      </label>
      <button type="submit" disabled={loading}>
        {loading ? 'Calculating…' : 'Calculate distance'}
      </button>
      {result && <p role="status">{result}</p>}
    </form>
  );
}

The client form sends every request through the mount-path prefix and prints the result, or the handler's error, under the button.

Rendering the distance form component

Then render it from app/page.tsx by swapping the default export for a page that renders the form:

// app/page.tsx
import DistanceForm from './components/DistanceForm';

export default function Page() {
  return <DistanceForm />;
}

The app page now renders the distance form.

The component reads NEXT_PUBLIC_BASE_PATH once at module load, which is how NEXT_PUBLIC_ variables behave in Next.js; they are inlined at build. That works on Webflow Cloud specifically because environment variables there are available during the build step and at runtime. So the value you set in the Webflow Cloud environment gets baked into the client bundle.

The client gets the mount path from this variable, not from next.config: Webflow Cloud sets the framework's own base path at build, but it does not create NEXT_PUBLIC_BASE_PATH for you.

Handling fixed destination locations

For a fixed business location, hard-code the destination in the handler and drop the second input. I often do that on service-area pages because it removes a field the visitor never needed to fill.

Locally, with no NEXT_PUBLIC_BASE_PATH set, the prefix is an empty string and the form posts to /api/distance, which matches your dev server. Submit two real addresses, and you should see the road distance and drive time under the button.

5. Add the secret and the mount path as Webflow Cloud environment variables

Before the first deploy, set two variables in the Webflow Cloud environment, using the Google key as a secret and the mount path as a public value.

In your Webflow Cloud project, open the environment variables for the environment you will deploy to. Decide on the mount path here as well, since the second variable has to match it exactly.

Required environment variables

These are the two variables to add:

Name Value Secret? Why it exists
GOOGLE_MAPS_API_KEY The key from step 1 Yes Read by the Route Handler on every request; never shipped to the browser
NEXT_PUBLIC_BASE_PATH Your mount path, e.g. /tools No Inlined into the client bundle at build so fetch hits the mounted route
Name → Value → Secret? → Why it exists
GOOGLE_MAPS_API_KEY
The key from step 1
Yes
Read by the Route Handler on every request; never shipped to the browser
NEXT_PUBLIC_BASE_PATH
Your mount path, e.g. /tools
No
Inlined into the client bundle at build so fetch hits the mounted route

After saving, the environment carries both values, and the next build will see them. I make two checks before leaving the panel. The mount path value needs a leading slash and no trailing slash, or the fetch URL becomes tools/api/distance or /tools//api/distance, neither of which routes. The key must also be marked secret, because Webflow Cloud redacts variables marked secret from build logs.

Webflow Cloud sets variables per environment, so a staging environment needs its own pair.

You now have the secret key and mount path saved in the environment that will build and run the deployed calculator.

6. Deploy the app and mount it on the Webflow site

I deploy the app to Webflow Cloud and mount it at the path I chose. From the project root, use the Webflow CLI to deploy the app to the Webflow Cloud project and environment that carry the variables. I keep the key out of any committed .env file; the deployed build reads it from the Webflow Cloud environment.

Before handoff, confirm that the client's site plan supports the chosen domain and mount.

Once the deploy finishes, open the mount path on the site in a browser, with the app's own page at /tools and the API at /tools/api/distance. Enter two addresses and submit. Then open the browser's network panel and confirm the request URL includes /tools/api/distance and returns 200 with a JSON body.

The calculator now lives at the app's own page under /tools, served from a route whose key never reaches the browser. To put it on a Designer-built service-area page, link to /tools from that page, or add a custom-code form there that posts to /tools/api/distance on the same site; the key stays server-side either way.

What causes a Google Maps distance calculator on Webflow Cloud to fail?

The main failure categories are unsupported runtime directives, client requests that omit the mount path, and deployed environment variables that differ from local values.

Match the visible browser or build symptom to the corresponding cause and fix.

The Webflow Cloud build fails on the distance route

Cause: The route file contains export const runtime = 'edge', which targets the Next.js edge runtime, and Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support that runtime target.

Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and the bring-your-own-app page describes it that way. The same page also still tells you to add the edge runtime directive to API routes.

That directive selects a Next.js build target. A developer can read the platform sentence, conclude that the directive is required, follow the docs faithfully, and end up with a build that cannot run.

Fix: I delete the runtime export from app/api/distance/route.ts and from any other route or page where it was added, then deploy again.

The calculator works under next dev, but the live form gets a 404 from the API route

Cause: The client fetch is going to /api/distance on the Webflow site root and missing /tools/api/distance on the mounted app. Webflow Cloud injects the mount path into the app at build time but leaves client-side fetch calls unchanged.

Webflow's bring-your-own-app docs say directly that client-side fetch calls must manually include the base path. Locally, there is no mount path, so an empty NEXT_PUBLIC_BASE_PATH matches the local root and the issue only appears after deployment.

The 404 most likely comes from the Webflow site rather than your app. Its response body usually has Webflow's 404 format and lacks the handler's JSON.

Fix: I first confirm NEXT_PUBLIC_BASE_PATH is set in the Webflow Cloud environment, matches the mount path character for character with a leading slash and no trailing slash, and was present before the most recent build.

Because NEXT_PUBLIC_ values are inlined at build time, adding the variable after a deploy changes nothing until you deploy again. Then verify in the browser's network panel that the request URL carries the prefix.

If an earlier build of the app set basePath in next.config, remove it. The bring-your-own-app docs say Webflow Cloud sets it at build from the mount path and overwrites whatever you commit.

Google rejects the key in production while the same key worked from a local .env file

Cause: The deployed environment contains a different key from the one that works locally. The handler returns its own 500 when the variable is missing entirely, so a Google error code in the status field of a 502 body means a key was sent, and Google rejected it.

Webflow Cloud sets variables per environment, so a key added to one environment is invisible to another. The deployed app reads the Webflow Cloud environment, while local development reads your .env.local file.

A key restricted by HTTP referrer is another likely cause, since that restriction is meant for browser keys.

Fix: If the route answers 500 with "server is missing GOOGLE_MAPS_API_KEY", the variable is absent from the deployed environment; the same check below applies to both errors.

I start by opening the Webflow Cloud environment you deployed to and confirming the variable name is spelled exactly GOOGLE_MAPS_API_KEY and marked secret; the handler reads that name and nothing else.

If the name and value are right, open the key in the Google Cloud console and set its API restriction to the Routes API only. Leave the HTTP referrer application restriction unset for this server-side key. Also confirm billing is still active on the Google project.

Deploy once more after any change to the environment, then re-test the live form with the network panel open so you can read the status field in the handler's 502 body, or condition in its 404 body.

What you can build next with Google Maps and Webflow

The distanceMeters value can set a delivery fee or hide the quote form outside a service radius. A matrix call with several origins can also rank your locations by drive time, which pairs well with CMS map markers for a store locator.

For deeper customization beyond a single Route Handler, the Webflow Cloud docs cover mounting and configuring a full Next.js app.

Frequently asked questions

Can I rate-limit or protect the distance route with Next.js middleware on Webflow Cloud?

Yes, with middleware.ts on the Workers runtime. The Next.js 16 proxy.ts rename runs on Node, so it never executes. I recommend using middleware.ts for an origin header check in front of the paid route. For a per-IP limit, plan on an external store because counters held in a single Worker's memory are unreliable.

Can I build this with a custom-code embed instead of a Webflow Cloud app?

You can use an embed for a quick internal prototype that calls Google directly. For a client site, use the Webflow Cloud route. The embed ships the key in the page source, where Google-side restrictions provide the protection. The route holds the key as a secret that never reaches the browser.

Can I use pnpm or yarn on Webflow Cloud?

No. Webflow Cloud supports npm as its sole package manager. If you scaffolded with pnpm or yarn, delete node_modules and any pnpm-lock.yaml or yarn.lock, run npm install to generate package-lock.json, and check that package.json scripts do not call pnpm or yarn. Commit the lockfile and script changes before deploying.

Can I store each calculated distance in the Webflow CMS?

Yes. You can have the same Route Handler write the origin, distance, and duration to a CMS collection through Webflow's REST API alongside the response, with Webflow credentials stored as a second secret. Keep that write behind the same input validation, and add it only once the calculator is stable on the live domain.

Does the calculator count against my site plan's Webflow Cloud limits?

Yes. Each request to the route runs under Webflow Cloud's runtime limits, including a 20-second request timeout and 128 MB of memory, and one Google call sits well inside them. Monthly request allowances depend on the site plan, so check yours before putting the calculator on a high-traffic page.


Last Updated
October 9, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com 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.