Location-aware directories need three things working together: geo-queries that filter by map viewport, a reactive map that updates as users explore, and authenticated user contributions like reviews.
Coordinating these across a headless CMS, a React framework, and a mapping library involves real architectural decisions about where data lives, how it flows between server and client, and which component owns each piece of state.
The finished app: a Next.js page with a paginated business list on the left, a Mapbox GL JS map with clustered markers on the right, and a detail view per business that renders photos, hours, and user reviews.
Strapi 5 holds businesses, categories, photos, and reviews. Next.js 16 fetches and renders everything with Server Components. Mapbox GL JS handles the map, and viewport queries drive listing updates as users pan and zoom.
In brief:
- Model businesses with decimal lat/lng fields and auto-geocode addresses through a Strapi lifecycle hook that calls the Mapbox Geocoding API.
- Filter by bounding box using Strapi's
$gte/$lteoperators on latitude and longitude, built with theqslibrary. - Render clustered markers in a
'use client'component and refetch onmoveendto keep the list and map in sync. - Submit authenticated reviews via Strapi's Users and Permissions plugin and revalidate the detail page with a Server Action.
Prerequisites
You'll need a few basics in place before wiring the backend and frontend together:
- Node.js v20, v22, or v24 (LTS)
- A Mapbox account with a public access token from account.mapbox.com
- Basic familiarity with React Server Components and the Strapi 5 Admin Panel
- Two terminal windows open (one for Strapi, one for Next.js)
How to Set Up Strapi 5 for Your Business Directory
This section covers scaffolding the Strapi project, generating an API token for the Next.js frontend, and configuring role-based access so the right collections are publicly readable.
Scaffold the Project with create-strapi
Run the following command to create a new Strapi project:
npx create-strapi@latest backendThe prompts ask about language and database. Select TypeScript and SQLite for local development. Once scaffolding completes:
cd backend
npm run developYour first admin account is registered at http://localhost:1337/admin, where the Admin Panel should load without errors.
Create an API token for the Next.js client
Navigate to Settings → API Tokens → Create new API Token. The type should be Read-only. Copy the token and save it for the frontend .env.local file.
This token authenticates requests from Next.js to the Strapi REST API. Strapi 5 uses a flat response format where fields sit directly on the data object, with no .attributes wrapper. Keep that in mind when typing your frontend responses.
One other change from Strapi v4: every entry now carries a documentId, a string identifier used in API responses and recommended for Content API calls. The auto-incrementing numeric id still exists on each record, but documentId is what you use when fetching a single entry, connecting relations, or building frontend links. All the code in this tutorial references documentId for those purposes.
Enable public read access on the right collections
Open Settings → Users and Permissions → Roles → Public. After creating the Business and Category Collection Types in the next section, come back here and enable find and findOne on both.
Reviews stay restricted to the Authenticated role. Public users can read them through the business populate, but only logged-in users can create them.
How to Model Business, Location, and Review Data
The data model spans three Collection Types: Business holds the core listing data with coordinates, Category provides filterable groupings, and Review stores authenticated user feedback tied to a specific business.
Define the Business Content-Type
Open the Content-Type Builder and create a new Collection Type called Business with these fields:
| Field | Type | Notes |
|---|---|---|
name | Text | Required |
slug | UID | Target field: name |
description | Rich Text (Blocks) | |
address | Text | |
phone | Text | |
website | Text | |
priceTier | Enumeration | inexpensive, moderate, expensive, very_expensive |
status | Enumeration | active, pending, closed |
hours | JSON | Stores structured hours per day |
photos | Media (multiple) | Cover image is the first entry |
latitude | Number (decimal) | |
longitude | Number (decimal) |
Strapi has no native geo field, so two decimal columns are the cleanest path. These are scalar fields, returned by default without populate.
Add Category and Review Content-Types
Category: name (Text), slug (UID from name), icon (Text). Set up a many-to-many relation between Business and Category.
Review: rating (Integer, min 1, max 5), body (Text), business (relation, many-to-one → Business), author (relation, many-to-one → Users and Permissions user).
Add an inverse reviews relation on Business so you can populate them later. The hours JSON field stores a simple object with day names as keys and time ranges as values:
{
"Monday": "8:00 AM - 6:00 PM",
"Tuesday": "8:00 AM - 6:00 PM",
"Saturday": "10:00 AM - 4:00 PM",
"Sunday": "Closed"
}Storing hours as JSON avoids creating a separate Content-Type for something that rarely needs relational queries. The field is scalar, so Strapi returns it by default without any populate parameter.
Geocode addresses with a lifecycle hook
Drop this file at src/api/business/content-types/business/lifecycles.ts. On beforeCreate and beforeUpdate, if an address is present but coordinates are missing, the hook calls Mapbox Geocoding API and sets latitude/longitude automatically.
// src/api/business/content-types/business/lifecycles.ts
export default {
async beforeCreate(event: any) {
const { data } = event.params;
if (data.address && !data.latitude && !data.longitude) {
const coords = await geocodeAddress(data.address);
if (coords) {
data.latitude = coords.latitude;
data.longitude = coords.longitude;
}
}
},
async beforeUpdate(event: any) {
const { data } = event.params;
if (data.address) {
const coords = await geocodeAddress(data.address);
if (coords) {
data.latitude = coords.latitude;
data.longitude = coords.longitude;
}
}
},
};
async function geocodeAddress(
address: string
): Promise<{ latitude: number; longitude: number } | null> {
const token = process.env.MAPBOX_ACCESS_TOKEN;
const encoded = encodeURIComponent(address);
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encoded}.json?access_token=${token}&limit=1`;
const response = await fetch(url);
if (!response.ok) return null;
const json = await response.json();
const feature = json.features?.[0];
if (!feature) return null;
const [longitude, latitude] = feature.geometry.coordinates;
return { latitude, longitude };
}Add MAPBOX_ACCESS_TOKEN to your Strapi env file. If you prefer an alternative approach, consider using middleware instead. Document Service middleware is recommended over lifecycle hooks for Draft & Publish scenarios, which can trigger lifecycle hooks twice.
How to Expose Businesses Through the REST API
With the content model in place, the next step is confirming the response shape, building bounding-box queries, and controlling which relations get populated on each request.
Test the find endpoint
With a few businesses created in the Admin Panel, confirm the response shape by hitting the REST API:
GET http://localhost:1337/api/businesses?populate=*Strapi 5 returns a flat response: documentId and all attributes sit at the top level of each data item. No .attributes wrapper.
Filter by category, rating, and bounding box
Bounding-box queries combine $gte and $lte on latitude and longitude:
GET /api/businesses?filters[latitude][$gte]=40.70&filters[latitude][$lte]=40.80&filters[longitude][$gte]=-74.02&filters[longitude][$lte]=-73.93&filters[categories][slug][$eq]=coffeeStrapi filters by exact bounding box, not radius. The map sends a rectangle, not a circle. If you need radius-based search, refine client-side after the box query or write a custom controller.
A couple of details matter here:
- Entries with null latitude or longitude, from failed geocoding or missing addresses, will not appear in bounding-box results.
- If queries return fewer results than expected, confirm coordinates in the Admin Panel.
- As your directory grows past a few hundred businesses, adding database indexes on the
latitudeandlongitudecolumns in PostgreSQL helps avoid full table scans on every bounding-box filter.
Use the qs library to build these queries programmatically. The encodeValuesOnly: true option keeps bracket characters in keys like filters[latitude][$gte] unencoded and is commonly used to produce Strapi-style query strings. The populate guide covers the full operator reference, including nested relation filters:
import qs from 'qs';
const query = qs.stringify(
{
filters: {
$and: [
{ latitude: { $gte: 40.70, $lte: 40.80 } },
{ longitude: { $gte: -74.02, $lte: -73.93 } },
],
},
},
{ encodeValuesOnly: true }
);Populate relations efficiently
For the list view, populate only what the UI renders:
populate[photos][fields][0]=url&populate[categories][fields][0]=nameIt helps to avoid populate=* in production. It pulls every relation, media file, and nested object. A route-based middleware can centralize default population so the frontend stays clean.
How to Set Up the Next.js 16 Frontend
The frontend is a Next.js 16 App Router project that fetches data from Strapi through a typed server-side fetcher and renders the map in a Client Component.
Initialize the Next.js Project
Run the following commands to scaffold the project and install the mapping dependencies:
npx create-next-app@latest frontend --typescript --app --tailwind
cd frontend
npm install mapbox-gl qs
npm install -D @types/mapbox-gl @types/qsAdd these to .env.local:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-read-only-token
NEXT_PUBLIC_MAPBOX_TOKEN=pk.your-mapbox-public-tokenCreate a typed Strapi fetcher
This server-only module wraps fetch, attaches the API token, and returns typed responses. The import 'server-only' directive causes a build-time error if a Client Component ever imports it, which helps keep your token out of the browser.
// lib/strapi.ts
import 'server-only';
import qs from 'qs';
const STRAPI_URL = process.env.STRAPI_URL!;
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!;
export interface StrapiCollectionResponse<T> {
data: T[];
meta: { pagination: { page: number; pageSize: number; pageCount: number; total: number } };
}
export async function fetchCollection<T>(
endpoint: string,
params?: string,
revalidate: number | false = 60
): Promise<StrapiCollectionResponse<T>> {
const path = params ? `${endpoint}?${params}` : endpoint;
return strapiRequest<StrapiCollectionResponse<T>>(path, {
next: { revalidate },
});
}
async function strapiRequest<T>(path: string, options?: RequestInit & { next?: { revalidate?: number | false } }): Promise<T> {
const url = `${STRAPI_URL}/api${path}`;
const res = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) throw new Error(`Strapi error: ${res.status} ${res.statusText}`);
return res.json();
}The explicit next: { revalidate: 60 } opts into Incremental Static Regeneration (ISR) so business listings stay fresh without hammering Strapi on every request. Since Next.js 15, fetch is no longer cached by default, and Next.js 16 keeps that behavior, so the explicit revalidate flag opts back in. For deeper patterns, see the Strapi fetch guide.
Fetch businesses in a Server Component
app/page.tsx is async. It fetches the first page of businesses and passes them to a Client Component map. The import boundary is clear: 'use client' goes on the map component, not the page. Strapi's Next.js integration makes this pairing straightforward. For the broader Next.js + Strapi 5 setup, Next.js Strapi setup covers the fundamentals.
// app/page.tsx
import { fetchCollection } from '@/lib/strapi';
import type { Business } from '@/types/strapi';
import { MapShell } from '@/components/MapShell';
export default async function HomePage() {
const { data: businesses } = await fetchCollection<Business>(
'/businesses',
'fields[0]=name&fields[1]=slug&fields[2]=latitude&fields[3]=longitude&populate[categories][fields][0]=name'
);
return <MapShell initialBusinesses={businesses} />;
}How to Render the Mapbox Map with Markers and Clustering
Every code block in this section runs inside a single 'use client' component that owns the map instance, the GeoJSON source, the three rendering layers, and the moveend handler.
Initialize the Map in a Client Component
Start by importing Mapbox GL JS and setting the access token at the module level:
'use client';
import React, { useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!;Use useRef for the container div and the map instance. All setup goes inside useEffect with an empty dependency array. The if (mapRef.current) return guard prevents double-initialization in React Strict Mode (Next.js 16 ships React 19.2).
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
useEffect(() => {
if (mapRef.current) return;
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current!,
style: 'mapbox://styles/mapbox/streets-v12',
center: [-103.5917, 40.6699],
zoom: 3,
});
// ... layers added on 'load' event
return () => {
mapRef.current?.remove();
mapRef.current = null;
};
}, []);The mapbox-gl/dist/mapbox-gl.css import should be included, as Mapbox GL JS requires its CSS and missing it may cause the map to display incorrectly, including issues with controls styling. If the map renders as a grey box, this CSS import is likely missing, or the container div has no explicit height.
Add a GeoJSON source backed by your businesses
Convert each business into a GeoJSON Feature, then add the source with clustering enabled:
mapRef.current.on('load', () => {
mapRef.current!.addSource('businesses', {
type: 'geojson',
data: toFeatureCollection(businesses),
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
});
});The toFeatureCollection helper maps your Strapi data to GeoJSON. Note: GeoJSON coordinates use [longitude, latitude], not [lat, lng].
function toFeatureCollection(businesses: Business[]): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: businesses
.filter((b) => b.latitude && b.longitude)
.map((b) => ({
type: 'Feature',
properties: { documentId: b.documentId, name: b.name, slug: b.slug },
geometry: { type: 'Point', coordinates: [b.longitude!, b.latitude!] },
})),
};
}Render three layers: clusters, cluster counts, and individual markers
point_count and point_count_abbreviated are added automatically by Mapbox GL JS when cluster: true is set. You can filter on cluster-related properties such as point_count or cluster to separate clusters from individual points.
// Cluster circles
mapRef.current!.addLayer({
id: 'clusters',
type: 'circle',
source: 'businesses',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 50, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
},
});
// Cluster count labels
mapRef.current!.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'businesses',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12,
},
});
// Individual markers
mapRef.current!.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'businesses',
filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': '#11b4da', 'circle-radius': 6, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' },
});Handle click interactions on clusters and markers
Clicking a cluster should zoom into it so its children become visible. The getClusterExpansionZoom method returns the zoom level at which that cluster breaks apart, and easeTo animates the camera there:
mapRef.current!.on('click', 'clusters', (e) => {
const features = mapRef.current!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties!.cluster_id;
(mapRef.current!.getSource('businesses') as mapboxgl.GeoJSONSource)
.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
mapRef.current!.easeTo({ center: (features[0].geometry as GeoJSON.Point).coordinates as [number, number], zoom: zoom! });
});
});Clicking an unclustered point can either open a Mapbox popup with the business name or navigate directly to the detail page using router.push(/business/${slug}). Set the pointer cursor on hover for both layers so users know the markers are interactive.
Refetch businesses when the map moves
On moveend, read the map bounds, send them to a Next.js Route Handler, and update the source. The 300ms debounce keeps request volume manageable during rapid panning:
let debounceTimer: ReturnType<typeof setTimeout>;
mapRef.current!.on('moveend', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const bounds = mapRef.current!.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const res = await fetch(
`/api/businesses/in-bounds?minLat=${sw.lat}&maxLat=${ne.lat}&minLng=${sw.lng}&maxLng=${ne.lng}`
);
const { data } = await res.json();
(mapRef.current!.getSource('businesses') as mapboxgl.GeoJSONSource)
.setData(toFeatureCollection(data));
onBusinessesChange(data); // lift to parent state
}, 300);
});The Route Handler at app/api/businesses/in-bounds/route.ts receives the bounding-box parameters and queries Strapi with the same filter pattern from the earlier section:
// app/api/businesses/in-bounds/route.ts
import { fetchCollection } from '@/lib/strapi';
import type { Business } from '@/types/strapi';
import qs from 'qs';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const minLat = searchParams.get('minLat');
const maxLat = searchParams.get('maxLat');
const minLng = searchParams.get('minLng');
const maxLng = searchParams.get('maxLng');
const query = qs.stringify(
{
filters: {
$and: [
{ latitude: { $gte: minLat, $lte: maxLat } },
{ longitude: { $gte: minLng, $lte: maxLng } },
],
},
fields: ['name', 'slug', 'latitude', 'longitude'],
populate: { categories: { fields: ['name'] } },
pagination: { pageSize: 200 },
},
{ encodeValuesOnly: true }
);
const data = await fetchCollection<Business>('/businesses', query, false);
return NextResponse.json(data);
}Sync the listing panel with the map
The MapShell component owns the shared state that both the sidebar list and the map read from. initialBusinesses from the Server Component seeds the first render, then client-side fetches from the moveend handler take over as the user pans and zooms.
// components/MapShell.tsx
'use client';
import { useState, useCallback } from 'react';
import type { Business } from '@/types/strapi';
export function MapShell({ initialBusinesses }: { initialBusinesses: Business[] }) {
const [businesses, setBusinesses] = useState(initialBusinesses);
const handleBusinessClick = useCallback((business: Business, map: mapboxgl.Map) => {
map.flyTo({ center: [business.longitude!, business.latitude!], zoom: 14 });
}, []);
return (
<div className="flex h-screen">
<BusinessList businesses={businesses} onSelect={handleBusinessClick} />
<MapView initialBusinesses={initialBusinesses} onBusinessesChange={setBusinesses} />
</div>
);
}The map's moveend callback passes fetched businesses to setBusinesses, which re-renders the sidebar list. Clicking a list item calls map.flyTo() to center that business on the map. Both directions of interaction flow through the same businesses state array, so the sidebar and map markers always reflect identical data.
How to Build the Business Detail Page
Each business gets its own page generated from the slug field. The detail page fetches a single business with deep populate for photos, categories, and reviews, then renders the full listing view with a static map hero.
Generate dynamic routes from the slug
Use generateStaticParams to pre-render pages at build time:
// app/business/[slug]/page.tsx
import { fetchCollection } from '@/lib/strapi';
import type { Business } from '@/types/strapi';
export async function generateStaticParams() {
const { data } = await fetchCollection<Business>('/businesses', 'fields[0]=slug');
return data.map((b) => ({ slug: b.slug }));
}Use documentId for Strapi API calls and relations; if you want a public-facing key, implement a slug field for that purpose. For a large directory, you might prefer to fetch only slugs for the most popular categories at build time and let Next.js generate the rest on-demand with dynamicParams.
If the slug filter returns an empty array, the page should call notFound() from next/navigation so Next.js serves a proper 404 page instead of rendering with undefined data. Check businesses.length immediately after the fetch and bail out before accessing businesses[0].
Note that Next.js 16 makes params asynchronous, as documented in the async params migration:
export default async function BusinessPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
// ...
}Fetch a single business with deep populate
Use a slug filter with nested populate for photos, categories, and reviews:
const query = qs.stringify(
{
filters: { slug: { $eq: slug } },
populate: {
photos: { fields: ['url', 'alternativeText'] },
categories: { fields: ['name', 'slug'] },
reviews: {
populate: { author: { fields: ['username'] } },
sort: ['createdAt:desc'],
},
},
},
{ encodeValuesOnly: true }
);Nested pagination on populated relations is not supported in the REST API, so you can't limit how many reviews come back inside a single populate. To cap the number of returned relations globally, set rest.maxLimit in ./config/api.js. The populate and filtering guide linked earlier documents syntax for nested populate queries, including some deeper relations.
Render Photos, Hours, Reviews, and a Small Static Map
The photo gallery renders as a responsive grid from the populated photos relation:
{/* Photo gallery */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{business.photos?.map((photo) => (
<img
key={photo.documentId}
src={`${process.env.STRAPI_URL}${photo.url}`}
alt={photo.alternativeText || business.name}
className="rounded-lg object-cover aspect-video"
/>
))}
</div>
{/* Opening hours */}
<ul>
{Object.entries(business.hours || {}).map(([day, times]) => (
<li key={day} className="flex justify-between">
<span className="font-medium">{day}</span>
<span>{times as string}</span>
</li>
))}
</ul>Reviews come pre-sorted from the Strapi query (createdAt:desc), so no client-side sorting is needed. Render them directly from the populated relation:
{/* Reviews */}
<section>
<h2 className="text-xl font-bold">Reviews</h2>
{business.reviews?.map((review) => (
<div key={review.documentId} className="border-b py-4">
<div className="flex items-center gap-2">
<span className="font-medium">{review.author?.username}</span>
<span>{'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}</span>
</div>
<p className="mt-1 text-gray-700">{review.body}</p>
</div>
))}
</section>For the hero map, use the Mapbox Static Images API instead of mounting a full GL JS instance:
<img
src={`https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/pin-l+FF0000(${business.longitude},${business.latitude})/${business.longitude},${business.latitude},14/600x400?access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`}
alt={`Map showing location of ${business.name}`}
/>Note: the Static Images API is not compatible with Mapbox Standard or Standard Satellite styles. Stick with streets-v12 or dark-v11.
How to Let Users Submit Reviews
Review submission requires authenticated users, a Client Component form, a Next.js Route Handler that proxies the request to Strapi, and a Server Action to revalidate the detail page after a successful write.
Enable Users and Permissions for review writes
In the Admin Panel, go to Settings → Users and Permissions Plugin → Roles → Authenticated. Expand the Review Content-Type and enable create. Public stays read-only. If review POST requests return 403, the Authenticated role is missing create permission on the Review Content-Type.
For the full registration and login flow, the auth tutorial part 1 and part 2 cover the complete pattern. This section assumes login already works.
Submit a review from a Client Component
A form on the detail page collects a rating (radio group, one through five) and body text.
'use client';
import { useState } from 'react';
import { revalidateBusinessPage } from '@/app/actions/reviews';
export function ReviewForm({ businessDocumentId, slug }: {
businessDocumentId: string;
slug: string;
}) {
const [rating, setRating] = useState(5);
const [body, setBody] = useState('');
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
// POST to Route Handler shown below
}
return (
<form onSubmit={handleSubmit}>
<fieldset className="flex gap-2">
{[1, 2, 3, 4, 5].map((value) => (
<label key={value}>
<input
type="radio"
name="rating"
value={value}
checked={rating === value}
onChange={() => setRating(value)}
/>
{value}
</label>
))}
</fieldset>
<textarea value={body} onChange={(e) => setBody(e.target.value)} required />
{error && <p className="text-red-600">{error}</p>}
<button type="submit">Submit Review</button>
</form>
);
}On submit, POST to a Next.js Route Handler that forwards the request to Strapi server-side. This keeps the Strapi URL and JSON Web Token (JWT) handling off the client:
const res = await fetch('/api/reviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating, body, businessDocumentId }),
});The Route Handler at app/api/reviews/route.ts receives the payload and forwards it to Strapi with the user's JWT, keeping STRAPI_URL server-side only.
After the fetch completes, check the response and handle both outcomes. If submission fails, display the error message inline. On success, clear the form and revalidate the page so the new review appears:
if (!res.ok) {
const error = await res.json();
setError(error?.error?.message || 'Submission failed');
return;
}
setBody('');
setRating(5);
await revalidateBusinessPage(slug);Strapi 5 uses documentId (not numeric id) for relation connections. The Route Handler builds the Strapi request body with the connect array:
body: JSON.stringify({
data: {
rating,
body,
business: { connect: [{ documentId: businessDocumentId }] },
},
}),Revalidate the detail page after a successful submission
Call revalidatePath from a Server Action so the detail page picks up the new review on the next request:
'use server';
import { revalidatePath } from 'next/cache';
export async function revalidateBusinessPage(slug: string) {
revalidatePath(`/business/${slug}`);
}One caveat: in some Strapi 5 versions and scenarios, Review entries created via the REST API for a Draft & Publish content-type may be published by default unless you explicitly use ?status=draft, and related issues have been reported in Strapi's GitHub tracker.
If you need moderation so reviews go to draft first, use Strapi's Draft & Publish workflow so new entries remain drafts by default rather than relying on a custom controller to set publishedAt: null.
How to Deploy and What to Build Next
With the app running locally, production deployment is a matter of pointing both services at the right environment variables and choosing where each one lives.
Deploy Strapi and Next.js
You can push the Strapi project to Strapi Cloud or any Node-friendly host. Strapi Cloud provides a pre-configured PostgreSQL database by default, so you don't need to configure DATABASE_* variables unless you're connecting an external database.
For self-hosted deployments, switch from SQLite to PostgreSQL by setting the DATABASE_CLIENT=postgres environment variable along with host, port, name, username, and password, and set NODE_ENV=production.
Deploy Next.js to Vercel. Set STRAPI_URL to your production Strapi domain, not localhost, STRAPI_API_TOKEN (no NEXT_PUBLIC_ prefix, server-side only), and NEXT_PUBLIC_MAPBOX_TOKEN in your project's environment variables. The Next.js + Strapi 5 setup guide linked earlier covers the full deployment walkthrough.
Where to take this next
Full-text search. Strapi's built-in filters handle exact and substring matches, but a dedicated search engine like Meilisearch or Typesense gives you typo tolerance, faceted filtering, and ranked results. The Strapi Marketplace currently lists a community plugin for Meilisearch, but not for Typesense.
Image optimization. Switch from the local upload provider to Cloudinary or S3 via a Strapi upload provider plugin. This offloads image transformations and CDN delivery, cutting load times on photo-heavy listing pages.
Review moderation. Use Strapi's Draft and Publish workflow so newly submitted reviews require admin approval before going public. Pair it with an email notification plugin so moderators get alerted when new reviews arrive.
From Bounding Box Queries to a Production-Ready Directory
You wired three layers together in this build: Strapi 5 owns the data model, geocoding lifecycle, and REST filters; Next.js 16 handles server rendering, route handlers, and ISR caching; Mapbox GL JS drives viewport-based discovery with clustered markers. Any Content-Type with coordinates, whether events, properties, or service areas, plugs into the same bounding-box filter and GeoJSON pipeline.
How Strapi Powered This:
- The Content-Type Builder modeled businesses, categories, and reviews without writing a schema file;
- Lifecycle hooks auto-geocoded addresses on create and update;
- REST API filters handled bounding-box queries with
$gte/$lteoperators on decimal fields; - Strapi 5's flat response format simplified frontend typing;
- The Users and Permissions plugin restricted review writes to authenticated users while keeping reads public;
- Nested
populatequeries fetched related data in a single request per detail page.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.