How to plot Webflow CMS locations on Google Maps in Webflow Cloud

How to plot every CMS location as a Google Maps marker with a Webflow Cloud app

Learn how to build a Webflow Cloud app that reads a CMS Collection server-side and plots every location as a Google Maps marker under the correct mount path.

How to plot every CMS location as a Google Maps marker with a Webflow Cloud app

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

Markers read from the CMS move with the content, and a small Webflow Cloud app on the same site does the reading; one hardcoded pin goes stale the day marketing opens a second office.

Store lists and regional offices usually already live in a CMS Collection with one item per place. When the map reads that Collection, an editor who publishes a new location gets a new pin with no code change.

The map here is a Webflow Cloud app mounted on the same site. A Route Handler reads the Collection's published items through Webflow's Content Delivery API, and a client component draws one marker per item and fits the viewport around all of them.

The build stores each location's latitude and longitude in the CMS as numbers, entered once when an editor creates the item, and the Route Handler skips any item missing either.

What do you need to add Google Maps markers from a CMS Collection in Webflow?

You need six prerequisites:

  • A Webflow site with CMS access: You need access to manage a Collection and its fields in the Webflow CMS, and to publish the content editors add.
  • A Webflow Site API token withCMS:read: Read-only is all the map needs; Webflow recommends a dedicated read-only token for each content-delivery channel. Store it as a server-side secret.
  • A Google Cloud project with a billing account: Google requires billing for the Maps JavaScript API and advanced markers; without it, the map renders darkened and watermarked. You also need an API key, plus a Map ID for the advanced markers the component draws.
  • Node.js 22 or later and npm: The version floors require Node.js 22 or later and Next.js 15 or higher; Webflow Cloud supports npm only.
  • A compatible Next.js project: The app here is scaffolded fresh with create-next-app and uses the App Router and TypeScript.
  • Version control for the application: Keep the application code and its npm lockfile together so the deployed build is reproducible.

With these prerequisites ready, you can model the content, connect both APIs, build the app, and deploy it under the Webflow Cloud mount path.

7 steps to add Google Maps markers from a CMS Collection in Webflow Cloud

A Next.js Route Handler reads the Locations Collection through Webflow's Content Delivery API, while a client component plots one marker per item. Explicit base-path handling keeps the client request working under the Webflow Cloud mount path.

Build the data model and credentials first, then add the server route, map component, and deployed environment in that order.

1. Model the Locations Collection in the Webflow CMS

Create a Collection named Locations and store the coordinates in Number fields, which hold decimals. Numeric coordinates keep address parsing out of the request path.

Add these fields to the Locations Collection:

  • Name: The Route Handler returns it, and the component uses it as the marker's hover title.
  • Address: Stored for display, for example in a future info window; the current component does not render it.
  • Latitude: Decimal degrees, negative for the southern hemisphere. Webflow generates the field slug latitude, which is what the Route Handler reads.
  • Longitude: Decimal degrees, negative for the western hemisphere. Its slug is longitude; an item missing either value is skipped and produces no pin.
  • Slug: Return it from the Route Handler if a marker may later link to that item's Collection page.

The Collection now has every value the Route Handler returns, and every coordinate the map needs to place a marker.

Add at least two items with real coordinates and publish them. Paste coordinates with their full decimal precision; rounding moves the pin. The Collection should show two or more published items, each with both coordinate values filled.

2. Prepare API access and identify the Collection

In Site settings > Apps & integrations > API access, create a Site API token with read-only CMS access. Name the token after the app (for example, locations-map) so whoever inherits the site can tell what it is for, and copy it straight into a password manager or .env.local.

Copy the Locations Collection ID from the Collection's settings panel. The Route Handler needs it to request the right items.

Keep both values out of the repository. The token goes into .env.local for local development and into the Webflow Cloud environment for the deploy, and it never appears in a committed file or a NEXT_PUBLIC_ variable.

For an agency handing the site back to a client, note in the handover who manages the token and where it lives, because if it is replaced, the Webflow Cloud environment needs the new value before the map loads again.

You should now have a read-only token and the Collection ID ready to add to .env.local.

3. Create a restricted Google Maps API key and a Map ID

Create a Google Maps API key and apply its restrictions before use, because the key ships to the browser in the page bundle, where anyone can read it. In the Google Cloud console, add support for the Maps JavaScript API to your project and create an API key.

Restrict it to websites (HTTP referrers) and add http://localhost:3000/* for local checks, plus every full origin where the deployed app will be served, scheme included and followed by /*, such as https://www.example.com/* and your webflow.io staging domain. Under API restrictions, limit the key to the Maps JavaScript API.

Create a Map ID for JavaScript in the Google Cloud console and copy its value. The client component draws markers with AdvancedMarkerElement, which needs a map created with a Map ID.

I always check the referrer list first, because a key that works on one serving domain and fails on another produces a darkened, watermarked map with no obvious cause until you open the console. The API key should now have the required referrer entries, and the Map ID should be ready to add to .env.local with the key as the two NEXT_PUBLIC_ Google values.

4. Scaffold the Next.js app

Scaffold the app with the App Router and TypeScript, and leave next.config.ts untouched. Webflow Cloud injects the base path at build time, and Webflow's bring-your-own-app page puts it as "No adapter, no base path, no wrangler.json."

Run the scaffold and add the Google Maps type definitions:

npx create-next-app@latest locations-map --typescript --app --eslint --no-tailwind --no-src-dir --import-alias "@/*" --use-npm --yes
cd locations-map
npm install -D @types/google.maps

The types package is a dev dependency that gives TypeScript the google.maps types the component uses.

Run these commands to collect the four configuration values without placing instructional stand-ins in the file:

read -rsp "Webflow API token: " WEBFLOW_API_TOKEN && printf '\n'
read -rp "Webflow Collection ID: " WEBFLOW_COLLECTION_ID
read -rsp "Google Maps API key: " NEXT_PUBLIC_GOOGLE_MAPS_API_KEY && printf '\n'
read -rp "Google Maps Map ID: " NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID
cat > .env.local <<EOF
WEBFLOW_API_TOKEN=$WEBFLOW_API_TOKEN
WEBFLOW_COLLECTION_ID=$WEBFLOW_COLLECTION_ID
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID=$NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID
NEXT_PUBLIC_BASE_PATH=
EOF

WEBFLOW_API_TOKEN and WEBFLOW_COLLECTION_ID hold the read-only token and the Collection ID from step 2. NEXT_PUBLIC_BASE_PATH is deliberately empty locally, because next dev serves the app at the root. On Webflow Cloud, it will hold the mount path.

The two NEXT_PUBLIC_ Google values are public client configuration; the server-side Webflow value has no such prefix and stays server-side. Add .env.local to .gitignore if the scaffold has not already done so. npm run dev now starts a blank Next.js app on localhost with every variable the Route Handler and the map component read.

5. Write the Route Handler that reads the Collection

Put the REST API call in a Route Handler and keep sensitive server-side values on the server, because browser bundles expose any values they contain. Do not add export const runtime = 'edge' to it.

That line is a frequent defect in Webflow Cloud Next.js apps, and it comes from a naming collision. Webflow Cloud runs your app on Cloudflare Workers, an edge platform, and you can describe it as using an Edge runtime.

The Next.js edge directive is a different thing: a per-route runtime target that the OpenNext Cloudflare adapter does not support. A handler with that export ships a broken build. A handler that uses standard fetch needs no runtime export.

Fetching locations from Webflow Content Delivery API

Create app/api/locations/route.ts. It reads published items from Webflow's Content Delivery API, GET https://api-cdn.webflow.com/v2/collections/{collection_id}/items/live, which is the Data API's live-items endpoint on a cached host. It has to do with the page: that endpoint returns 25 items when you pass no limit and 100 at most, so a single request against a 60-office Collection would silently drop 35 pins.

The Webflow field slugs are generated from the field names in step 1, so Latitude becomes latitude; check them in the Collection settings if you named the fields differently.

// app/api/locations/route.ts
type MapLocation = {
  id: string;
  name: string;
  slug: string;
  address: string;
  lat: number;
  lng: number;
};

type CmsItem = { id: string; fieldData: Record<string, unknown> };
type ItemsPage = { items?: CmsItem[]; pagination?: { total: number } };

const PAGE_SIZE = 100; // the endpoint's documented maximum
const CACHE_MS = 5 * 60 * 1000;

// Per-Worker-instance cache, a second layer on top of the CDN host. It is not global:
// each instance keeps its own copy, and a cold instance fetches again.
let cached: { at: number; locations: MapLocation[] } | null = null;

function coordinate(value: unknown, limit: number): number | null {
  if (value === null || value === undefined || value === '') return null;
  const n = typeof value === 'number' ? value : Number(value);
  return Number.isFinite(n) && Math.abs(n) <= limit ? n : null;
}

export async function GET() {
  if (cached && Date.now() - cached.at < CACHE_MS) {
    return Response.json(cached.locations);
  }

  const token = process.env.WEBFLOW_API_TOKEN;
  const collectionId = process.env.WEBFLOW_COLLECTION_ID;
  if (!token || !collectionId) {
    return Response.json({ error: 'Locations are not configured' }, { status: 500 });
  }

  const locations: MapLocation[] = [];
  let offset = 0;
  let total = Infinity;

  while (offset < total) {
    // /items/live returns published items only, so drafts never become pins.
    // The Content Delivery API host serves cached published items with effectively no rate limit.
    const url = new URL(`https://api-cdn.webflow.com/v2/collections/${collectionId}/items/live`);
    url.searchParams.set('offset', String(offset));
    url.searchParams.set('limit', String(PAGE_SIZE));

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    });
    if (!res.ok) {
      console.error('Webflow CMS request failed', res.status);
      // Serve the last good copy rather than an empty map, if there is one.
      if (cached) return Response.json(cached.locations);
      return Response.json({ error: 'Locations are unavailable' }, { status: 502 });
    }

    const page = (await res.json()) as ItemsPage;
    const items = page.items ?? [];
    for (const item of items) {
      const f = item.fieldData;
      const lat = coordinate(f.latitude, 90);
      const lng = coordinate(f.longitude, 180);
      if (lat === null || lng === null) continue; // skip an incomplete item, keep the rest
      locations.push({
        id: item.id,
        name: String(f.name ?? ''),
        slug: String(f.slug ?? ''),
        address: String(f.address ?? ''),
        lat,
        lng,
      });
    }

    total = page.pagination?.total ?? offset + items.length;
    if (items.length === 0) break;
    offset += items.length;
  }

  cached = { at: Date.now(), locations };
  return Response.json(locations);
}

The handler reads WEBFLOW_API_TOKEN and WEBFLOW_COLLECTION_ID, returns a 500 if either is missing, and returns a JSON array of MapLocation objects. Mapping fieldData to name, slug, address, lat and lng at this boundary means the client component never depends on the CMS response shape.

The coordinate check rejects any value that is not a usable number within range, so an item missing its latitude is skipped rather than dropped at 0, 0 off the coast of Africa, and the remaining items still return. Return upstream failures as an error response and keep the token and raw authorization details out of the browser.

This location's route is intentionally public so every visitor can retrieve the marker data. The example has no visitor authentication or per-user authorization, and it has no rate limiting. The host choice matters more than it looks.

Rate limits and caching for CMS requests

On the standard api.webflow.com host, every map view would be a Data API call, and Starter and Basic sites get 60 of those a minute, so a busy page would start failing. Webflow's rate-limit docs say that "for cached requests to the content delivery API, there are effectively no rate limits," which is why the handler uses api-cdn.webflow.com; the in-memory cache is a second layer that also serves the last good copy if a request fails.

A Cache-Control header cannot do either job, because Webflow Cloud replaces it with private, no-cache. Do not return private location fields through this route. If the data should not be public, add authentication and authorization, then throttle requests before deployment.

Run npm run dev and open http://localhost:3000/api/locations. You should see a JSON array with one object per location returned by the handler, each carrying name, slug, address, lat, and lng.

6. Build the map component that draws the markers

Build the client component with the Webflow Cloud mount path included in its locations request. A fetch to /api/locations works under next dev and returns a 404 once the app is mounted at /map, since the real handler now lives at /map/api/locations.

Reading process.env.NEXT_PUBLIC_BASE_PATH in the component fixes that in both environments because Webflow Cloud does not rewrite client-side requests.

Save this as app/components/LocationsMap.tsx; it loads the Maps JavaScript API once and places one advanced marker per item:

"use client";

import { useEffect, useRef, useState } from "react";

type MapLocation = {
  id: string;
  name: string;
  slug: string;
  address: string;
  lat: number;
  lng: number;
};

declare global {
  interface Window {
    __onGoogleMapsReady?: () => void;
  }
}

let mapsPromise: Promise<void> | null = null;

function loadGoogleMaps(key: string): Promise<void> {
  if (mapsPromise) return mapsPromise;
  mapsPromise = new Promise((resolve, reject) => {
    window.__onGoogleMapsReady = () => resolve();
    const script = document.createElement("script");
    script.src =
      `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}` +
      `&libraries=marker&loading=async&callback=__onGoogleMapsReady`;
    script.async = true;
    script.onerror = () => reject(new Error("Google Maps failed to load"));
    document.head.appendChild(script);
  });
  return mapsPromise;
}

export default function LocationsMap() {
  const mapRef = useRef<HTMLDivElement>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
    const key = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
    const mapId = process.env.NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID;

    if (!key || !mapId) {
      setError("Google Maps is not configured");
      return;
    }

    let cancelled = false;

    async function render() {
      const [locations] = await Promise.all([
        fetch(`${basePath}/api/locations`).then((res) => {
          if (!res.ok) {
            throw new Error(`Locations request failed: ${res.status}`);
          }
          return res.json() as Promise<MapLocation[]>;
        }),
        loadGoogleMaps(key as string),
      ]);

      if (cancelled || !mapRef.current) return;

      const map = new google.maps.Map(mapRef.current, {
        center: { lat: 0, lng: 0 },
        zoom: 2,
        mapId,
      });

      const bounds = new google.maps.LatLngBounds();

      for (const location of locations) {
        const position = { lat: location.lat, lng: location.lng };
        new google.maps.marker.AdvancedMarkerElement({
          map,
          position,
          title: location.name,
        });
        bounds.extend(position);
      }

      if (locations.length === 1) {
        map.setCenter(bounds.getCenter());
        map.setZoom(14);
      } else if (locations.length > 1) {
        map.fitBounds(bounds);
      }
    }

    render().catch((err) => {
      setError(err instanceof Error ? err.message : "Map failed to load");
    });

    return () => {
      cancelled = true;
    };
  }, []);

  if (error) {
    return <p role="alert">{error}</p>;
  }

  return <div ref={mapRef} style={{ width: "100%", height: "70vh" }} />;
}

The module-level mapsPromise prevents a second script tag if the effect runs more than once, and the named callback in the script URL resolves that promise, so the component waits for the loader before creating the map.

Bounds fitting handles the case editors care about: add a location in a new country, and the map zooms out to include it without anyone changing a default center in code.

Render it from the app's root page by replacing app/page.tsx:

import LocationsMap from "@/app/components/LocationsMap";

export default function Page() {
  return (
    <main>
      <h1>Our locations</h1>
      <LocationsMap />
    </main>
  );
}

Reload localhost, and you should see a map with one pin per location returned by the Route Handler and the viewport framed around all of them.

7. Deploy to Webflow Cloud with the environment variables

Configure the Webflow Cloud environment and set its mount path to /map.

Set the five environment variables on that environment before the first deploy. Next.js inlines NEXT_PUBLIC_ values into the client bundle when it builds, so a deploy that runs without them produces a page that reports "Google Maps is not configured" until you add them and redeploy.

Webflow Cloud makes both secret and non-secret variables available at build time and at runtime, so a single set covers the handler and the component.

Setting up environment variables on Webflow Cloud

Add WEBFLOW_API_TOKEN, WEBFLOW_COLLECTION_ID, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID, and NEXT_PUBLIC_BASE_PATH to the deployed environment. Mark WEBFLOW_API_TOKEN as secret so Webflow Cloud redacts its value from build logs.

Set NEXT_PUBLIC_BASE_PATH to /map, matching the mount path exactly with the leading slash and no trailing slash. The two Google values and the base path are public by design.

On the variables screen, check two things: the token is marked as secret, and the mount path reads /map.

Deploy the application. Once it completes, open the deployed /map URL (or the custom domain path on Premium or higher), and the same map you saw on localhost should render on the site's own domain, pins and all. Open the browser's network panel and confirm the locations request went to /map/api/locations and returned the JSON array.

What causes Google Maps markers from the Webflow CMS to fail?

Google Maps markers usually fail because the mounted route returns 404, the build contains an unsupported runtime export, deployed API values are missing, or the Google key rejects the serving domain.

Match the visible browser or build symptom to the cause, inspect the named configuration, and verify the correction with the expected response.

The page shows 'Locations request failed: 404' on Webflow Cloud, but the map loads on localhost

Cause: The client component omitted the Webflow Cloud mount path from the locations request. Under next dev, there is no base path, so /api/locations resolves correctly and hides the defect until deployment.

Once the app is mounted at /map, the Route Handler lives at /map/api/locations. Webflow Cloud doesn't rewrite that browser-side request automatically, leaving the component requesting a route that doesn't exist.

Fix: Set NEXT_PUBLIC_BASE_PATH to /map in the deployed environment, keep it empty in .env.local, and build the request as fetch(${basePath}/api/locations). Redeploy because Next.js inlines the public value during the build.

In the browser's network panel, inspect the request URL and status. It should include the /map prefix, return a 200 response, and contain the JSON array of locations.

The Webflow Cloud build fails even though next dev runs the Route Handler without errors

Cause: The Route Handler contains export const runtime = 'edge'. This line often comes from reading “Edge runtime” in platform guidance as an instruction for the route file. Webflow Cloud runs the application on Cloudflare Workers, but the Next.js edge export is a separate per-route target that the OpenNext Cloudflare adapter does not support.

Local development can run the handler without revealing the deployment incompatibility, so the first clear symptom appears in the Webflow Cloud build log.

Fix: Delete the runtime export from app/api/locations/route.ts and from any other route or page where it was added, then commit and redeploy. A handler using standard fetch needs no runtime export.

If a teammate also renamed middleware.ts to proxy.ts, revert that change; Node.js runtime middleware is not supported on Webflow Cloud, and proxy cannot opt into the edge middleware that is. The corrected deployment should complete without the runtime-target error.

The page shows 'Locations request failed: 500' or '502' while localhost returns data

Cause: The deployed Route Handler is missing a value it needs, or its upstream API request failed. .env.local is read by next dev and is not part of the deployed environment. With the handler's error handling, a missing WEBFLOW_API_TOKEN or WEBFLOW_COLLECTION_ID returns a 500 response, while an upstream API failure can return a 502.

Open /map/api/locations directly, or inspect the response in the network panel, to read the handler's error body and distinguish missing configuration from an upstream failure.

Fix: Open the environment's variables in Webflow Cloud and check that WEBFLOW_API_TOKEN and WEBFLOW_COLLECTION_ID both exist, that the names are spelled exactly as the handler reads them, and that they are set on the environment you actually deployed.

If the token has been replaced or lost its CMS read access, paste a new one into the environment and redeploy. A 200 from /map/api/locations with the JSON array confirms the correction.

The map renders darkened and watermarked, and the console reports RefererNotAllowedMapError

Cause: The Google key's HTTP referrer restriction does not include the domain serving the app. The browser receives the key and loads the Maps JavaScript API, but Google rejects the request because the referrer doesn't match an allowed website entry. This often appears only after deployment because the localhost entry and deployed domain are separate referrers.

A key can therefore work at http://localhost:3000 while producing a darkened, watermarked map on the Webflow-hosted or custom domain.

Fix: Add the missing serving domain to the key's website restrictions in the Google Cloud console. Include /* after the domain so the entry covers every path, including /map. Add each domain that can serve the deployed application rather than assuming one entry covers another hostname.

A custom-domain entry only matters once the site is on Premium or higher and the domain is connected. Reload the page and confirm that the console error is gone and the markers render.

What you can build next with Google Maps and Webflow

Explore the Google Maps integration for your next build, and if the same locations need to reach another system, the paged read in step 5 is the core of the CMS sync pattern. For deeper customization beyond what this handler does, Webflow's developer docs cover Webflow's development platform and Cloud guidance.

Frequently asked questions

Can I show CMS markers on a map without a Webflow Cloud app?

Yes, and for a small list it is simpler. Bind the Number fields into custom attributes on a Collection List, then read them with a page script that initializes the map; Webflow's Google Maps integration page also points to a Marketplace app for the no-code version. Two limits apply. A Collection List shows 100 items by default and publishing the script needs a paid Site plan or a Core, Growth, Agency or Freelancer Workspace. The Webflow Cloud app earns its place beyond a few hundred locations, or when you need server-side filtering.

Do editors have to type latitude and longitude by hand?

No, but check the terms of whatever fills them in. Google's Geocoding API is a separately billed service, and its terms let you cache the coordinates it returns for at most 30 days and not share them across end users, so storing its results permanently in the CMS for every visitor does not comply. Either have editors copy coordinates from a source they may store, or use a geocoder whose terms allow permanent storage.

How can I verify that a newly published location reached the map?

You can verify the publication before checking the map: open /map/api/locations and look for the item's slug and coordinates in the returned JSON. If the item is absent, check its published state and coordinate fields. If it is present, the CMS read succeeded so you can focus on the browser component.


Last Updated
October 2, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.