How to build a real estate listing feed with the Webflow API

How to build a real estate listing feed with the Webflow API

Learn how to build a real estate listing feed on Webflow Cloud that reads published CMS listings and lets buyers filter listings by city and bedrooms.

How to build a real estate listing feed with the Webflow API

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

Turn your Webflow CMS listings into a filterable, server-side JSON feed that buyers and partners can query live.

Brokerage sites can outgrow a static listings grid fast. Editors already keep every property in the Webflow CMS, and buyers want to narrow results by city and bedrooms without scrolling through every card.

In this tutorial, we’ll build a small Next.js app on Webflow Cloud that reads published listings from the Content Delivery API, filters them on the server and returns JSON to a filter page on your site. The same endpoint can feed partner sites, and your Webflow API token never reaches the browser, because only the route talks to Webflow.

What do you need to build a real estate listing feed with the Webflow API in Webflow?

You need five prerequisites to get started:

  • Premium Site plan: Premium includes the CMS, with 20,000 items and 40 Collections, which is where the Listings collection lives (see Premium CMS limits).
  • Listings collection: A Listings collection in the CMS, or permission to create one, with at least two published items to test against.
  • Webflow API token: A site token with the CMS:read scope, kept on the server; the CMS API basics guide covers creating one.
  • Next.js 15 with npm: Next.js 15 or higher, installed with npm, because Webflow Cloud supports only the npm package manager.
  • Project code: The app code with package-lock.json committed, ready to deploy to a Webflow Cloud project on the same site.

Choose the Listings collection's field slugs early, because the Route Handler reads every value by slug. For a few dozen listings, client-side filtering with custom code, as in Webflow's dynamic filtering guide, may be enough.

6 steps to build a real estate listing feed with the Webflow API in Webflow Cloud

The build models the listings in the CMS, adds a helper that pages through published listings, and puts a filtering route and a client page in front of it. Everything after the content model is code you can test on localhost before the single deploy at the end.

1. Model the Listings collection in the CMS

Use a collection named Listings for the values buyers filter on, with these fields and slugs:

Field name Field type Slug the code reads
Name Name name
Slug Slug slug
Price Number price
Bedrooms Number bedrooms
Bathrooms Number bathrooms
City Plain text city
Main image Image main-image
Field name → Field type → Slug the code reads
Name
Name
name
Slug
Slug
slug
Price
Number
price
Bedrooms
Number
bedrooms
Bathrooms
Number
bathrooms
City
Plain text
city
Main image
Image
main-image

The API leaves a field out of fieldData when it has no value, so a listing without a price reaches the route with price undefined. Copy the Listings collection ID from the collection settings, because the items URL needs it.

Add a few listings with different cities and bedroom counts, then publish them. Only published items reach the feed, because the items URL uses the /items/live path.

2. Create the Next.js app and its environment variables

Scaffold the app with npm so the lockfile matches what Webflow Cloud builds with. Leave out lockfiles from other package managers. The flags below pick TypeScript, the App Router and npm and keep files out of a src folder so the import paths in later steps resolve as written.

Pin the scaffold to Next.js 15, because Next.js 16 changes middleware in ways Webflow Cloud does not run.

Run these two commands from the folder where the project should live:

npx create-next-app@15 listing-feed --ts --app --use-npm --no-src-dir
cd listing-feed

The scaffold installs a Next.js 15 release, which meets Webflow Cloud's minimum.

Create .env.local in the project root. The token and items URL carry no NEXT_PUBLIC_ prefix, because Next.js exposes variables with that prefix to browser code.

Only the base path is public, and it stays empty locally because the app runs at the root of localhost.

Paste these three variables into the file, with your token and your collection ID in place of the placeholders.

The items URL points to the Content Delivery API host, api-cdn.webflow.com, which serves published items from a cache, and cached responses don't count against the Data API rate limit:

WEBFLOW_API_TOKEN=replace-with-your-webflow-api-token
WEBFLOW_LISTINGS_ITEMS_URL=https://api-cdn.webflow.com/v2/collections/YOUR_COLLECTION_ID/items/live
NEXT_PUBLIC_BASE_PATH=

Run npm run dev and open http://localhost:3000. You should see the default Next.js starter page, which confirms the app builds before you add any listing code.

3. Write a helper that pages through published listings

Put the Webflow API call in a helper to keep the route readable and the listing types in one place. It requests 100 items at a time, the API maximum, until it has every published listing.

Create lib/webflow.ts:

// lib/webflow.ts
export type WebflowImage = { url: string; alt?: string | null };

export type ListingFields = {
  name: string;
  slug: string;
  price?: number;
  bedrooms?: number;
  bathrooms?: number;
  city?: string;
  'main-image'?: WebflowImage;
};

export type WebflowItem = { id: string; fieldData: ListingFields };

export async function fetchAllLiveListings(
  token: string,
  itemsUrl: string
): Promise<WebflowItem[]> {
  const items: WebflowItem[] = [];
  const limit = 100;
  for (let offset = 0; ; offset += limit) {
    const url = new URL(itemsUrl);
    url.searchParams.set('limit', String(limit));
    url.searchParams.set('offset', String(offset));
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
    });
    if (!res.ok) throw new Error(`Webflow API ${res.status}: ${await res.text()}`);
    const data = (await res.json()) as { items: WebflowItem[]; pagination: { total: number } };
    items.push(...data.items);
    if (data.items.length < limit || items.length >= data.pagination.total) break;
  }
  return items;
}

Every listing field except name and slug is optional in the type, because empty fields are omitted from fieldData.

At 20,000 listings, the loop would make 200 calls in a row and risk Webflow Cloud's 20-second request limit, so this design suits feeds of up to a couple of thousand listings; beyond that, filter on the server with the API's filter[...] parameters instead.

4. Add the Route Handler that filters and shapes the feed

Create the endpoint the browser and any partner will call. Filtering happens here, on the server, so the response carries only matching listings and keeps the payload small on a phone.

Leave out any export const runtime line; the route runs correctly on Webflow Cloud without one, because Webflow Cloud deploys through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime.

This endpoint is public and has no rate limiting. Add a shared key before serving restricted inventory, as shown in the FAQ below.

Add the Route Handler at app/api/listings/route.ts:

// app/api/listings/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { fetchAllLiveListings } from '../../../lib/webflow';

function toNumber(value: string | null): number | undefined {
  if (value === null || value.trim() === '') return undefined;
  const n = Number(value);
  return Number.isFinite(n) ? n : undefined;
}

export async function GET(request: NextRequest) {
  const token = process.env.WEBFLOW_API_TOKEN;
  const itemsUrl = process.env.WEBFLOW_LISTINGS_ITEMS_URL;

  if (!token || !itemsUrl) {
    return NextResponse.json(
      { error: 'Listing feed is not configured' },
      { status: 500 }
    );
  }

  const params = request.nextUrl.searchParams;
  const minPrice = toNumber(params.get('minPrice'));
  const maxPrice = toNumber(params.get('maxPrice'));
  const minBeds = toNumber(params.get('beds'));
  const city = params.get('city')?.trim().toLowerCase();

  try {
    const items = await fetchAllLiveListings(token, itemsUrl);

    const listings = items
      .map(({ id, fieldData }) => ({
        id,
        name: fieldData.name,
        slug: fieldData.slug,
        price: fieldData.price ?? null,
        bedrooms: fieldData.bedrooms ?? null,
        bathrooms: fieldData.bathrooms ?? null,
        city: fieldData.city ?? '',
        image: fieldData['main-image']?.url ?? null,
      }))
      .filter((listing) => {
        if (minPrice !== undefined && (listing.price === null || listing.price < minPrice)) return false;
        if (maxPrice !== undefined && (listing.price === null || listing.price > maxPrice)) return false;
        if (minBeds !== undefined && (listing.bedrooms === null || listing.bedrooms < minBeds)) return false;
        if (city && !listing.city.toLowerCase().includes(city)) return false;
        return true;
      })
      .sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));

    return NextResponse.json({ count: listings.length, listings });
  } catch (error) {
    console.error(error);
    return NextResponse.json(
      { error: 'Could not load listings' },
      { status: 502 }
    );
  }
}

The map call turns each item into a flat object, so consumers read price, city and image directly, and listings without a price sort last.

Webflow Cloud replaces any Cache-Control header with private, no-cache, so caching happens upstream instead: the Content Delivery API keeps each response for up to five minutes, which also means a newly published listing can take that long to appear.

An error thrown by the helper surfaces as a 502.

Test it with the dev server still running:

curl "http://localhost:3000/api/listings?city=austin&beds=3"

Swap in a city from your own listings. You should receive JSON with a count and a listings array containing only the matching listings.

5. Render the feed on a client page that respects the base path

Replace the starter page with a filter form and a results grid. The fetch URL starts with NEXT_PUBLIC_BASE_PATH because Webflow Cloud serves the app under a mount path such as /listings, and client-side requests are not rewritten to include it.

Locally, the variable is empty, so the same code calls /api/listings in development and /listings/api/listings once deployed.

The effect waits 300 milliseconds after the last keystroke before it fetches, and cancels the previous request whenever a filter changes, so a slow response can never overwrite newer results.

Replace app/page.tsx with the component:

// app/page.tsx
'use client';

import { useEffect, useState } from 'react';

type Listing = {
  id: string;
  name: string;
  slug: string;
  price: number | null;
  bedrooms: number | null;
  bathrooms: number | null;
  city: string;
  image: string | null;
};

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
const currency = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

export default function ListingsPage() {
  const [city, setCity] = useState('');
  const [beds, setBeds] = useState('');
  const [listings, setListings] = useState<Listing[]>([]);
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');

  useEffect(() => {
    const params = new URLSearchParams();
    if (city) params.set('city', city);
    if (beds) params.set('beds', beds);

    const controller = new AbortController();
    const timer = setTimeout(() => {
      setStatus('loading');
      fetch(`${basePath}/api/listings?${params.toString()}`, { signal: controller.signal })
        .then((res) => {
          if (!res.ok) throw new Error(String(res.status));
          return res.json();
        })
        .then((data: { listings: Listing[] }) => {
          setListings(data.listings);
          setStatus('ready');
        })
        .catch((err) => {
          if (err.name !== 'AbortError') setStatus('error');
        });
    }, 300);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [city, beds]);

  return (
    <main style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
      <h1>Available listings</h1>

      <form style={{ display: 'flex', gap: 12, marginBottom: 24 }} onSubmit={(e) => e.preventDefault()}>
        <input
          placeholder="City"
          value={city}
          onChange={(e) => setCity(e.target.value)}
        />
        <select value={beds} onChange={(e) => setBeds(e.target.value)}>
          <option value="">Any beds</option>
          <option value="1">1+ beds</option>
          <option value="2">2+ beds</option>
          <option value="3">3+ beds</option>
          <option value="4">4+ beds</option>
        </select>
      </form>

      {status === 'loading' && <p>Loading listings…</p>}
      {status === 'error' && <p>Listings could not be loaded. Try again shortly.</p>}
      {status === 'ready' && listings.length === 0 && <p>No listings match those filters.</p>}

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 16 }}>
        {listings.map((listing) => (
          <article key={listing.id} style={{ border: '1px solid #ddd', borderRadius: 8, overflow: 'hidden' }}>
            {listing.image && (
              <img src={listing.image} alt={listing.name} style={{ width: '100%', height: 180, objectFit: 'cover' }} />
            )}
            <div style={{ padding: 12 }}>
              <h2 style={{ fontSize: 18, margin: '0 0 4px' }}>{listing.name}</h2>
              <p style={{ margin: 0 }}>{listing.city}</p>
              <p style={{ margin: '4px 0 0' }}>
                {listing.price !== null ? currency.format(listing.price) : 'Price on request'}
                {listing.bedrooms !== null && ` · ${listing.bedrooms} bd`}
                {listing.bathrooms !== null && ` · ${listing.bathrooms} ba`}
              </p>
            </div>
          </article>
        ))}
      </div>
    </main>
  );
}

Each change to the city box or the bedrooms menu triggers a fresh request to your route, and listings without a price render as "Price on request" instead of breaking the card. Reload http://localhost:3000 to see your published listings as cards.

Results should match cities that contain what you type and the bedroom minimum you pick.

6. Deploy the app to Webflow Cloud and mount it on your site

Commit the project with package-lock.json, then create a Webflow Cloud project for the same site and set /listings as the mount path. Follow the setup on Webflow's bring-your-own-app page.

Add WEBFLOW_API_TOKEN, WEBFLOW_LISTINGS_ITEMS_URL and NEXT_PUBLIC_BASE_PATH to the Webflow Cloud environment. Mark the token as secret and set the base path to /listings.

Webflow Cloud makes both secret and non-secret variables available to the build and to the running app. The base path must be present during the build, and the token must be present each time the route runs. Secret values are redacted from build logs.

Once the API helper works locally, trigger a deploy and wait for the build to finish. The site's Premium plan also meets the requirement for mounting the app on a custom domain.

Open https://yourdomain.com/listings to test the grid; check https://yourdomain.com/listings/api/listings separately to confirm the deployed integration returns the JSON feed. The mounted grid and JSON endpoint should now return your published listings.

What causes a Webflow API listing feed to fail?

Failures usually come from the build or the client-side base path; the rest is missing environment variables or listings the feed cannot see.

The Webflow Cloud build fails on the listings route

Cause: The unsupported export const runtime = 'edge' directive breaks the build. The confusion is understandable, because the word "edge" means two things on the same docs page.

Webflow's bring-your-own-app page says "Webflow Cloud deploys your app using the Edge runtime, enabling fast, globally distributed hosting," which describes the platform (Cloudflare Workers). That same page also tells you to add the edge directive to API routes, which is a Next.js runtime target. Reading the platform sentence, a developer reasonably concludes the directive is required.

Fix: Delete the runtime export from app/api/listings/route.ts and from any other route that carries it, then redeploy. If a teammate or an AI coding assistant adds the line back while following the docs, search the repository for runtime = before each deploy.

The page renders, but the browser shows a 404 for /api/listings

Cause: Webflow Cloud injects the app's /listings base path at build time, but browser fetch calls are not rewritten. The bring-your-own-app page states: "Client-side fetch calls must manually include the base path to correctly reach your endpoints."

Fix: Set NEXT_PUBLIC_BASE_PATH to /listings in the Webflow Cloud environment, then redeploy. A changed value requires a new build, because Next.js inlines it. Webflow's configuration docs say Webflow Cloud applies the mount path as the base path when it builds, so leave basePath out of next.config.

The deployed feed returns "Listing feed is not configured" while localhost works

Cause: The route returns a 500 when WEBFLOW_API_TOKEN or WEBFLOW_LISTINGS_ITEMS_URL is undefined. Locally, both come from .env.local; in Webflow Cloud, configure them for the deployed environment.

Fix: Add any missing variables with their exact names, mark the token as secret, then redeploy. For a 502, check the logs: a 401 or 403 from Webflow means the token is wrong or lacks CMS:read, and a 404 means the collection ID in the URL is wrong.

The feed returns count 0 even though listings appear in the CMS panel

Cause: The listings may not be published, since the /items/live URL returns published items only, or a field slug may differ from the table in step 1. If a slug differs, the route reads that value as missing, and any filter on it drops the listing. A newly published listing can also take up to five minutes to clear the delivery host's cache.

Fix: Call your route with no query string first. If that returns zero, publish the listings and check the item URL's collection ID. If it returns listings but a filter removes them, compare each field's slug in the CMS settings with the table in step 1.

What you can build next with the Webflow API and Webflow Cloud

Saved searches and a map view can both reuse the same helper, as can a syndication feed for portals that expect their own format. If your inventory comes from an MLS rather than your own editors, the MLS's IDX rules govern how you display it, so read about adding IDX listings first.

For server-side filtering on larger collections, Webflow's developer docs document the filter and sort parameters on the live items endpoint.

Frequently asked questions

Can I put the listing feed behind a login for real estate agents?

Yes. Gate it with middleware.ts; Webflow Cloud runs Edge runtime middleware on the Workers runtime, and Node.js runtime middleware isn't supported. Watch for Next.js 16, which renames middleware to proxy: a proxy.ts file runs on the Node runtime, cannot opt into Edge, and will not run on Webflow Cloud. Keep the file named middleware.ts.

Can I cache the listings in a JSON file between requests?

Not on disk. Webflow's Node.js compatibility page lists fs as unsupported, with no file system access, so any file cache fails once deployed. The Content Delivery API already caches responses, and a Key Value Store binding covers anything you compute yourself.

Can I generate an ETag so partner portals skip unchanged feeds?

You can hash the response body with crypto.subtle.digest('MD5', ...), a Cloudflare extension available on the Workers runtime. I recommend treating the result purely as a change checksum. Cloudflare's own note says "MD5 is considered a weak algorithm. Do not rely upon MD5 for security," so never use that hash to sign or authenticate a feed request from a partner.

How do I protect a partner feed with a shared key?

Compare the key from a request header against a secret environment variable using Workers' timingSafeEqual. That function exists on the deployed Workers runtime but not under local next dev, so one route can pass in production and throw on your laptop. Feature-check for it, and allow a plain comparison only in development, never in the deployed build.

Can I run separate staging and production listing feeds?

Yes. Use separate Webflow Cloud environments and point staging's WEBFLOW_LISTINGS_ITEMS_URL at a staging site's collection, so test listings never reach buyers. Mark the token as secret in each environment.


Last Updated
October 30, 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

Lancez-vous gratuitement

Essayez Webflow aussi longtemps que vous le souhaitez avec notre forfait Starter gratuit. Souscrivez à un forfait Site payant pour publier, héberger et débloquer des fonctionnalités supplémentaires.

Get started — it’s free
Voir la démo

Essayez Webflow aussi longtemps que vous le souhaitez avec notre forfait Starter gratuit. Souscrivez à un forfait Site payant pour publier, héberger et débloquer des fonctionnalités supplémentaires.