I Generate 30 Local Landing Pages From One JSON File in Next.js. Code Included.
You sell a service in more than one city. So you want a page for each: web design in Noida, web design in Gurgaon, web design in Dubai. And you are staring...
Garvit Sharma
19 June 2026 ยท 8 MIN READ
I Generate 30 Local Landing Pages From One JSON File in Next.js. Code Included.
webight.comOn this page
You sell a service in more than one city. So you want a page for each: web design in Noida, web design in Gurgaon, web design in Dubai. And you are staring at the same problem everyone hits. Do you hand-build 30 near-identical pages and keep them in sync forever, or do you skip local pages entirely and lose every "service + city" search to a competitor who didn't skip?
There is a third option. One [city] route, one JSON file, 30 static pages generated at build time. I run this on Webight and on a couple of client sites. The whole thing is maybe 90 lines of code.
But there is a trap inside it that will get your pages deindexed if you are lazy, and I will show you exactly where it sits.
The one route that becomes thirty pages
The mechanism is generateStaticParams. You give Next.js a list of params, it builds one static HTML page for each entry at build time, and serves them from the edge like any other static file. No server rendering per request. No database call when a visitor lands. Just plain HTML that was generated once, during next build.
So the plan is one folder, app/web-design/[city]/, that turns into /web-design/noida, /web-design/gurgaon, and so on. The source of truth for the list is a JSON file.
Here is the JSON shape I use. The important part is that each city carries its own real content, not a name to interpolate into a template.
// src/data/locations.json
[
{
"slug": "noida",
"city": "Noida",
"region": "Uttar Pradesh",
"intro": "Most Noida businesses I see are running a free template their cousin set up in 2021. It loads in 6 seconds on mobile and ranks for nothing.",
"localProjects": [
{
"name": "Sector 62 dental clinic",
"result": "appointment page went from a 41 to a 78 on mobile PageSpeed, calls picked up the same week"
}
],
"faqs": [
{
"q": "Do you work with businesses based in Noida in person?",
"a": "Yes. We are in Noida, so kickoff calls can happen face to face if you prefer that over a video call."
},
{
"q": "How fast can a Noida small business get a site live?",
"a": "A landing page from us ships in about 5 working days once we have your content and brand assets."
}
]
},
{
"slug": "gurgaon",
"city": "Gurgaon",
"region": "Haryana",
"intro": "Gurgaon is full of startups with funded budgets and SaaS-looking sites that say nothing. The bar is high and the copy is usually empty.",
"localProjects": [
{
"name": "Cyber Hub cafe ordering page",
"result": "WhatsApp ordering link buried three taps deep, we moved it to the hero, daily orders climbed"
}
],
"faqs": [
{
"q": "Can you match the design quality Gurgaon SaaS clients expect?",
"a": "Yes. Most of our work is for product and B2B clients, so polished and fast is the default, not an upsell."
},
{
"q": "Do you handle Gurgaon e-commerce builds?",
"a": "We build storefronts on Next.js with Razorpay or Stripe wired in, depending on whether you sell in India or abroad."
}
]
}
]
Two entries shown so you see the pattern. In production my file has more, and each one is written by hand with its own facts. That hand-written part is the entire game, and I will come back to it.
The page.tsx that reads the file and builds every city
This is the complete route file. Copy it, point it at your JSON, and it works.
// src/app/web-design/[city]/page.tsx
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import locations from "@/data/locations.json";
type Location = {
slug: string;
city: string;
region: string;
intro: string;
localProjects: { name: string; result: string }[];
faqs: { q: string; a: string }[];
};
const data = locations as Location[];
export function generateStaticParams() {
return data.map((loc) => ({ city: loc.slug }));
}
function getCity(slug: string) {
return data.find((loc) => loc.slug === slug);
}
export default async function CityPage({
params,
}: {
params: Promise<{ city: string }>;
}) {
const { city } = await params;
const loc = getCity(city);
if (!loc) {
notFound();
}
return (
<main>
<h1>Web design in {loc.city}, {loc.region}</h1>
<p>{loc.intro}</p>
<section>
<h2>Recent work near {loc.city}</h2>
<ul>
{loc.localProjects.map((p) => (
<li key={p.name}>
<strong>{p.name}:</strong> {p.result}
</li>
))}
</ul>
</section>
<section>
<h2>Questions from {loc.city} clients</h2>
{loc.faqs.map((f) => (
<div key={f.q}>
<h3>{f.q}</h3>
<p>{f.a}</p>
</div>
))}
</section>
</main>
);
}
A few things doing real work here. generateStaticParams returns one object per city, so Next builds one static page per entry. params is a Promise in Next.js 15, so you await it. And notFound() means if someone visits /web-design/random-typo, they get a real 404 instead of a blank shell, which matters because you do not want garbage URLs sitting in Google's index.
Unique metadata per city, the part most people skip
Here is where local pages live or die. The title and description in your <head> are the single biggest on-page signal for a "service in city" search. If all 30 pages share one generic title, you have told Google the pages are interchangeable. So generate the metadata per city, from the same JSON.
Add this to the same page.tsx:
export async function generateMetadata({
params,
}: {
params: Promise<{ city: string }>;
}): Promise<Metadata> {
const { city } = await params;
const loc = getCity(city);
if (!loc) {
return { title: "Page not found" };
}
const title = `Web design in ${loc.city} | Fast Next.js sites from Webight`;
const description = `${loc.intro.slice(0, 150)}`;
const url = `https://www.webight.com/web-design/${loc.slug}`;
return {
title,
description,
alternates: {
canonical: url,
},
openGraph: {
title,
description,
url,
type: "website",
},
};
}
Notice the canonical. Each city points its canonical at its own URL. I learned this one the hard way: in June I audited Webight and found my canonicals pointing at the non-www version while the server was 307-redirecting to www. The canonical was contradicting the redirect on every page. For a single-page site that is one bug. On 30 generated pages, one canonical mistake in your template multiplies into 30 bugs, all identical, all from the same three lines. So get it right once in the template and check the output.
Speaking of checking the output, after a build:
npm run build
npx serve .next # or deploy and curl production
curl -s https://www.webight.com/web-design/gurgaon | grep -i '<title>'
curl -s https://www.webight.com/web-design/noida | grep -i '<title>'
Two different titles printed means it worked. Two identical titles means your interpolation is broken and you just shipped 30 clones. Thirty seconds to check. Do it every build.
The trap: thin pages are spam, and Google knows the pattern exactly
Now the warning, because this is the whole reason I wrote the post instead of just dropping the code.
Google has a name for what you are about to build if you do it lazily. They call it doorway pages. The definition is pages created to rank for many similar searches that all funnel the user to the same place, with no real content difference between them. "Web design in Noida," "web design in Gurgaon," "web design in Faridabad," same paragraph with the city name swapped. That is a textbook doorway page, and it is a documented spam policy, not a vague guideline. Sites have lost rankings across their whole domain for this, not only on the thin pages themselves.
See, the moment you make intro a string template like We do great web design in ${city} for ${city} businesses, you have built a spam generator with good intentions. The code is clean. The output is garbage. Google's crawler reads the same content thirty times with one noun changing and treats the set as manipulation.
The fix is not technical. It is in the JSON, and it is work. Every city entry has to carry something genuinely its own:
- A local project or result that only happened in that city. Not invented, real. If you have not done work in a city, you do not get a project line for it, and that is fine.
- FAQs that answer questions a person in that specific city would actually ask. In-person kickoff in your home city. Currency and payment gateway for a Gulf city. Timezone overlap for a US city.
- An intro that says something true and specific about that market, the way the Noida and Gurgaon intros above are clearly written by someone who has seen both.
So my honest rule: I do not generate a page for a city until I have at least one real local thing to put on it. I would rather ship 8 strong city pages than 30 hollow ones. Eight pages that each say something true will outrank thirty that say nothing, every time, and they will not put your whole domain at risk.
There is a psychology angle that makes this click. A page that mentions a Sector 62 clinic or a Cyber Hub cafe reads as written by a local to a human, and it reads as non-duplicate to a crawler. The exact same detail buys you trust with both audiences at once. Generic copy loses both. That is why the lazy version does not only rank lower. It actively hurts you.
What you actually saved
Run the numbers on the boring part. Thirty hand-built pages means thirty files to keep in sync. Change your phone number, your offer, your nav, and you are editing thirty files or you forget three of them and they drift. With this setup, the layout lives in one page.tsx. The content lives in one JSON. Add a city, you add a JSON object and rebuild. The structure is fixed in one place and the words are unique in another, which is exactly the split you want: build a city page in about 3 minutes once the research is done, not an afternoon.
That 3 minutes is the easy part though. The research behind each entry is the part that earns the ranking, and no flag or function does it for you.
So go look at your own local pages, if you have them. Open two of them side by side and read them as a stranger from that city would. If the only difference between the two is a place name you find-and-replaced, you did not build 30 landing pages. You built one page and lied about it 29 times, and Google can read. Write the JSON like a local or do not write the page at all.
If you set this up and want a second pair of eyes on whether your city pages read as real or as doorways, send me two URLs, I will tell you straight.
We build custom platforms, websites, and automation.
The two people who build it are the two you talk to, and every price is on the page.

Garvit Sharma
Full-stack developer and co-founder of Webight, a two-person web and AI studio in India. He writes these from real client work. More about us.
Keep reading
// RELATEDEvery Post on This Blog Is an SEO Experiment. I'll Publish the Ranking Data Monthly, Wins or Losses.
webight.comEvery Post on This Blog Is an SEO Experiment. I'll Publish the Ranking Data Monthly, Wins or Losses.
6 MIN READ
Google Search Console Reports on WhatsApp Every Monday. n8n Plus 199 Rupees a Month.
webight.comGoogle Search Console Reports on WhatsApp Every Monday. n8n Plus 199 Rupees a Month.
9 MIN READ
I Built an n8n Workflow That Audits Any Website's SEO and Emails the Report. Full JSON Inside.
webight.comI Built an n8n Workflow That Audits Any Website's SEO and Emails the Report. Full JSON Inside.
11 MIN READ