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:
$gte/$lte operators on latitude and longitude, built with the qs library. 'use client' component and refetch on moveend to keep the list and map in sync. You'll need a few basics in place before wiring the backend and frontend together:
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.
Run the following command to create a new Strapi project:
1npx create-strapi@latest backendThe prompts ask about language and database. Select TypeScript and SQLite for local development. Once scaffolding completes:
1cd backend
2npm run developYour first admin account is registered at http://localhost:1337/admin, where the Admin Panel should load without errors.
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.
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.
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.
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.
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:
1{
2 "Monday": "8:00 AM - 6:00 PM",
3 "Tuesday": "8:00 AM - 6:00 PM",
4 "Saturday": "10:00 AM - 4:00 PM",
5 "Sunday": "Closed"
6}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.
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.
1// src/api/business/content-types/business/lifecycles.ts
2
3export default {
4 async beforeCreate(event: any) {
5 const { data } = event.params;
6 if (data.address && !data.latitude && !data.longitude) {
7 const coords = await geocodeAddress(data.address);
8 if (coords) {
9 data.latitude = coords.latitude;
10 data.longitude = coords.longitude;
11 }
12 }
13 },
14
15 async beforeUpdate(event: any) {
16 const { data } = event.params;
17 if (data.address) {
18 const coords = await geocodeAddress(data.address);
19 if (coords) {
20 data.latitude = coords.latitude;
21 data.longitude = coords.longitude;
22 }
23 }
24 },
25};
26
27async function geocodeAddress(
28 address: string
29): Promise<{ latitude: number; longitude: number } | null> {
30 const token = process.env.MAPBOX_ACCESS_TOKEN;
31 const encoded = encodeURIComponent(address);
32 const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encoded}.json?access_token=${token}&limit=1`;
33
34 const response = await fetch(url);
35 if (!response.ok) return null;
36
37 const json = await response.json();
38 const feature = json.features?.[0];
39 if (!feature) return null;
40
41 const [longitude, latitude] = feature.geometry.coordinates;
42 return { latitude, longitude };
43}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.
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.
With a few businesses created in the Admin Panel, confirm the response shape by hitting the REST API:
1GET 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.
Bounding-box queries combine $gte and $lte on latitude and longitude:
1GET /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:
latitude and longitude columns 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:
1import qs from 'qs';
2
3const query = qs.stringify(
4 {
5 filters: {
6 $and: [
7 { latitude: { $gte: 40.70, $lte: 40.80 } },
8 { longitude: { $gte: -74.02, $lte: -73.93 } },
9 ],
10 },
11 },
12 { encodeValuesOnly: true }
13);For the list view, populate only what the UI renders:
1populate[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.
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.
Run the following commands to scaffold the project and install the mapping dependencies:
1npx create-next-app@latest frontend --typescript --app --tailwind
2cd frontend
3npm install mapbox-gl qs
4npm install -D @types/mapbox-gl @types/qsAdd these to .env.local:
1STRAPI_URL=http://localhost:1337
2STRAPI_API_TOKEN=your-read-only-token
3NEXT_PUBLIC_MAPBOX_TOKEN=pk.your-mapbox-public-tokenThis 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.
1// lib/strapi.ts
2import 'server-only';
3import qs from 'qs';
4
5const STRAPI_URL = process.env.STRAPI_URL!;
6const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!;
7
8export interface StrapiCollectionResponse<T> {
9 data: T[];
10 meta: { pagination: { page: number; pageSize: number; pageCount: number; total: number } };
11}
12
13export async function fetchCollection<T>(
14 endpoint: string,
15 params?: string,
16 revalidate: number | false = 60
17): Promise<StrapiCollectionResponse<T>> {
18 const path = params ? `${endpoint}?${params}` : endpoint;
19 return strapiRequest<StrapiCollectionResponse<T>>(path, {
20 next: { revalidate },
21 });
22}
23
24async function strapiRequest<T>(path: string, options?: RequestInit & { next?: { revalidate?: number | false } }): Promise<T> {
25 const url = `${STRAPI_URL}/api${path}`;
26 const res = await fetch(url, {
27 ...options,
28 headers: {
29 Authorization: `Bearer ${STRAPI_API_TOKEN}`,
30 'Content-Type': 'application/json',
31 ...options?.headers,
32 },
33 });
34
35 if (!res.ok) throw new Error(`Strapi error: ${res.status} ${res.statusText}`);
36 return res.json();
37}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.
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.
1// app/page.tsx
2import { fetchCollection } from '@/lib/strapi';
3import type { Business } from '@/types/strapi';
4import { MapShell } from '@/components/MapShell';
5
6export default async function HomePage() {
7 const { data: businesses } = await fetchCollection<Business>(
8 '/businesses',
9 'fields[0]=name&fields[1]=slug&fields[2]=latitude&fields[3]=longitude&populate[categories][fields][0]=name'
10 );
11
12 return <MapShell initialBusinesses={businesses} />;
13}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.
Start by importing Mapbox GL JS and setting the access token at the module level:
1'use client';
2import React, { useEffect, useRef } from 'react';
3import mapboxgl from 'mapbox-gl';
4import 'mapbox-gl/dist/mapbox-gl.css';
5
6mapboxgl.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).
1const mapContainerRef = useRef<HTMLDivElement>(null);
2const mapRef = useRef<mapboxgl.Map | null>(null);
3
4useEffect(() => {
5 if (mapRef.current) return;
6
7 mapRef.current = new mapboxgl.Map({
8 container: mapContainerRef.current!,
9 style: 'mapbox://styles/mapbox/streets-v12',
10 center: [-103.5917, 40.6699],
11 zoom: 3,
12 });
13
14 // ... layers added on 'load' event
15
16 return () => {
17 mapRef.current?.remove();
18 mapRef.current = null;
19 };
20}, []);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.
Convert each business into a GeoJSON Feature, then add the source with clustering enabled:
1mapRef.current.on('load', () => {
2 mapRef.current!.addSource('businesses', {
3 type: 'geojson',
4 data: toFeatureCollection(businesses),
5 cluster: true,
6 clusterMaxZoom: 14,
7 clusterRadius: 50,
8 });
9});The toFeatureCollection helper maps your Strapi data to GeoJSON. Note: GeoJSON coordinates use [longitude, latitude], not [lat, lng].
1function toFeatureCollection(businesses: Business[]): GeoJSON.FeatureCollection {
2 return {
3 type: 'FeatureCollection',
4 features: businesses
5 .filter((b) => b.latitude && b.longitude)
6 .map((b) => ({
7 type: 'Feature',
8 properties: { documentId: b.documentId, name: b.name, slug: b.slug },
9 geometry: { type: 'Point', coordinates: [b.longitude!, b.latitude!] },
10 })),
11 };
12}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.
1// Cluster circles
2mapRef.current!.addLayer({
3 id: 'clusters',
4 type: 'circle',
5 source: 'businesses',
6 filter: ['has', 'point_count'],
7 paint: {
8 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 50, '#f28cb1'],
9 'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
10 },
11});
12
13// Cluster count labels
14mapRef.current!.addLayer({
15 id: 'cluster-count',
16 type: 'symbol',
17 source: 'businesses',
18 filter: ['has', 'point_count'],
19 layout: {
20 'text-field': ['get', 'point_count_abbreviated'],
21 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
22 'text-size': 12,
23 },
24});
25
26// Individual markers
27mapRef.current!.addLayer({
28 id: 'unclustered-point',
29 type: 'circle',
30 source: 'businesses',
31 filter: ['!', ['has', 'point_count']],
32 paint: { 'circle-color': '#11b4da', 'circle-radius': 6, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' },
33});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:
1mapRef.current!.on('click', 'clusters', (e) => {
2 const features = mapRef.current!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
3 const clusterId = features[0].properties!.cluster_id;
4 (mapRef.current!.getSource('businesses') as mapboxgl.GeoJSONSource)
5 .getClusterExpansionZoom(clusterId, (err, zoom) => {
6 if (err) return;
7 mapRef.current!.easeTo({ center: (features[0].geometry as GeoJSON.Point).coordinates as [number, number], zoom: zoom! });
8 });
9});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.
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:
1let debounceTimer: ReturnType<typeof setTimeout>;
2
3mapRef.current!.on('moveend', () => {
4 clearTimeout(debounceTimer);
5 debounceTimer = setTimeout(async () => {
6 const bounds = mapRef.current!.getBounds();
7 const sw = bounds.getSouthWest();
8 const ne = bounds.getNorthEast();
9
10 const res = await fetch(
11 `/api/businesses/in-bounds?minLat=${sw.lat}&maxLat=${ne.lat}&minLng=${sw.lng}&maxLng=${ne.lng}`
12 );
13 const { data } = await res.json();
14
15 (mapRef.current!.getSource('businesses') as mapboxgl.GeoJSONSource)
16 .setData(toFeatureCollection(data));
17
18 onBusinessesChange(data); // lift to parent state
19 }, 300);
20});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:
1// app/api/businesses/in-bounds/route.ts
2import { fetchCollection } from '@/lib/strapi';
3import type { Business } from '@/types/strapi';
4import qs from 'qs';
5import { NextRequest, NextResponse } from 'next/server';
6
7export async function GET(request: NextRequest) {
8 const { searchParams } = request.nextUrl;
9 const minLat = searchParams.get('minLat');
10 const maxLat = searchParams.get('maxLat');
11 const minLng = searchParams.get('minLng');
12 const maxLng = searchParams.get('maxLng');
13
14 const query = qs.stringify(
15 {
16 filters: {
17 $and: [
18 { latitude: { $gte: minLat, $lte: maxLat } },
19 { longitude: { $gte: minLng, $lte: maxLng } },
20 ],
21 },
22 fields: ['name', 'slug', 'latitude', 'longitude'],
23 populate: { categories: { fields: ['name'] } },
24 pagination: { pageSize: 200 },
25 },
26 { encodeValuesOnly: true }
27 );
28
29 const data = await fetchCollection<Business>('/businesses', query, false);
30 return NextResponse.json(data);
31}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.
1// components/MapShell.tsx
2'use client';
3import { useState, useCallback } from 'react';
4import type { Business } from '@/types/strapi';
5
6export function MapShell({ initialBusinesses }: { initialBusinesses: Business[] }) {
7 const [businesses, setBusinesses] = useState(initialBusinesses);
8
9 const handleBusinessClick = useCallback((business: Business, map: mapboxgl.Map) => {
10 map.flyTo({ center: [business.longitude!, business.latitude!], zoom: 14 });
11 }, []);
12
13 return (
14 <div className="flex h-screen">
15 <BusinessList businesses={businesses} onSelect={handleBusinessClick} />
16 <MapView initialBusinesses={initialBusinesses} onBusinessesChange={setBusinesses} />
17 </div>
18 );
19}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.
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.
Use generateStaticParams to pre-render pages at build time:
1// app/business/[slug]/page.tsx
2import { fetchCollection } from '@/lib/strapi';
3import type { Business } from '@/types/strapi';
4
5export async function generateStaticParams() {
6 const { data } = await fetchCollection<Business>('/businesses', 'fields[0]=slug');
7 return data.map((b) => ({ slug: b.slug }));
8}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:
1export default async function BusinessPage({
2 params,
3}: {
4 params: Promise<{ slug: string }>
5}) {
6 const { slug } = await params;
7 // ...
8}Use a slug filter with nested populate for photos, categories, and reviews:
1const query = qs.stringify(
2 {
3 filters: { slug: { $eq: slug } },
4 populate: {
5 photos: { fields: ['url', 'alternativeText'] },
6 categories: { fields: ['name', 'slug'] },
7 reviews: {
8 populate: { author: { fields: ['username'] } },
9 sort: ['createdAt:desc'],
10 },
11 },
12 },
13 { encodeValuesOnly: true }
14);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.
The photo gallery renders as a responsive grid from the populated photos relation:
1{/* Photo gallery */}
2<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
3 {business.photos?.map((photo) => (
4 <img
5 key={photo.documentId}
6 src={`${process.env.STRAPI_URL}${photo.url}`}
7 alt={photo.alternativeText || business.name}
8 className="rounded-lg object-cover aspect-video"
9 />
10 ))}
11</div>
12
13{/* Opening hours */}
14<ul>
15 {Object.entries(business.hours || {}).map(([day, times]) => (
16 <li key={day} className="flex justify-between">
17 <span className="font-medium">{day}</span>
18 <span>{times as string}</span>
19 </li>
20 ))}
21</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:
1{/* Reviews */}
2<section>
3 <h2 className="text-xl font-bold">Reviews</h2>
4 {business.reviews?.map((review) => (
5 <div key={review.documentId} className="border-b py-4">
6 <div className="flex items-center gap-2">
7 <span className="font-medium">{review.author?.username}</span>
8 <span>{'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}</span>
9 </div>
10 <p className="mt-1 text-gray-700">{review.body}</p>
11 </div>
12 ))}
13</section>For the hero map, use the Mapbox Static Images API instead of mounting a full GL JS instance:
1<img
2 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}`}
3 alt={`Map showing location of ${business.name}`}
4/>Note: the Static Images API is not compatible with Mapbox Standard or Standard Satellite styles. Stick with streets-v12 or dark-v11.
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.
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.
A form on the detail page collects a rating (radio group, one through five) and body text.
1'use client';
2import { useState } from 'react';
3import { revalidateBusinessPage } from '@/app/actions/reviews';
4
5export function ReviewForm({ businessDocumentId, slug }: {
6 businessDocumentId: string;
7 slug: string;
8}) {
9 const [rating, setRating] = useState(5);
10 const [body, setBody] = useState('');
11 const [error, setError] = useState('');
12
13 async function handleSubmit(e: React.FormEvent) {
14 e.preventDefault();
15 setError('');
16 // POST to Route Handler shown below
17 }
18
19 return (
20 <form onSubmit={handleSubmit}>
21 <fieldset className="flex gap-2">
22 {[1, 2, 3, 4, 5].map((value) => (
23 <label key={value}>
24 <input
25 type="radio"
26 name="rating"
27 value={value}
28 checked={rating === value}
29 onChange={() => setRating(value)}
30 />
31 {value}
32 </label>
33 ))}
34 </fieldset>
35 <textarea value={body} onChange={(e) => setBody(e.target.value)} required />
36 {error && <p className="text-red-600">{error}</p>}
37 <button type="submit">Submit Review</button>
38 </form>
39 );
40}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:
1const res = await fetch('/api/reviews', {
2 method: 'POST',
3 headers: { 'Content-Type': 'application/json' },
4 body: JSON.stringify({ rating, body, businessDocumentId }),
5});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:
1if (!res.ok) {
2 const error = await res.json();
3 setError(error?.error?.message || 'Submission failed');
4 return;
5}
6setBody('');
7setRating(5);
8await revalidateBusinessPage(slug);Strapi 5 uses documentId (not numeric id) for relation connections. The Route Handler builds the Strapi request body with the connect array:
1body: JSON.stringify({
2 data: {
3 rating,
4 body,
5 business: { connect: [{ documentId: businessDocumentId }] },
6 },
7}),Call revalidatePath from a Server Action so the detail page picks up the new review on the next request:
1'use server';
2import { revalidatePath } from 'next/cache';
3
4export async function revalidateBusinessPage(slug: string) {
5 revalidatePath(`/business/${slug}`);
6}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.
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.
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.
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.
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:
$gte/$lte operators on decimal fields; populate queries fetched related data in a single request per detail page.npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.