It started with a message from the product team.
“Hey, can we add a Lucknow landing page? Same layout as Noida, just different content.”
Sure. Easy enough. I duplicated the component, swapped the city name, changed a few stats, and updated the hero image. Done in twenty minutes. Pushed to staging.
Two weeks later:
“Can we do the same for Agra, Meerut, Varanasi, Kanpur, and Prayagraj?”
I stared at that message for a solid minute.
The Problem Nobody Talks About
At Cladbe, we’re building a real estate CRM for builders and brokers across India. We don’t serve one market — we serve dozens of them, concentrated heavily across Delhi NCR and Uttar Pradesh. Noida, Gurgaon, Ghaziabad, Faridabad, Greater Noida, Lucknow, Agra, Kanpur, Varanasi, Prayagraj, Meerut — each city has its own inventory trends, its own pricing logic, its own builder ecosystem.
The content on the platform needs to reflect that.
For a while, we just hardcoded it. Honestly? I knew from the start that a cities.ts config file was not the right long-term answer. But we were moving fast, trying to get market coverage up, and it was the quickest way to ship. So we made that trade-off consciously — rapid growth over clean architecture.
I don’t regret it for that phase. But all trade-offs eventually come due.
That file grew until it was 900 lines of city names, hero texts, market stats, testimonials, builder logos, and feature callouts. All of it is buried in the codebase.
Every time the product team wanted to change the tagline for the Lucknow page, they had to come to me. Every time a builder in Varanasi wanted their logo updated on the city page, it was a code change, a PR, a deployment.
For quick marketing, such content change system is just not sustainable.
Beyond that, we had a second problem: element-based content variation. Different user segments — brokers vs. builders vs. individual buyers — were landing on the same pages but needed to see different CTAs, different feature highlights, different trust signals. We were handling this with a tangle of props and conditional rendering that had become genuinely painful to reason about.
Something had to give the solution.
What I Considered First
My first instinct was to build a simple internal admin panel. Store content in our existing SQL database, expose an API, slap a basic UI on it. Full control, no third-party dependency, we own everything.
I started scoping it out- Schema design for flexible content blocks, A rich text editor, File uploads to S3-compatible storage, Role-based access so the product team could edit without breaking things, Draft states, Preview modes.... I stopped when I realised I was essentially designing a CMS from scratch. That’s not a weekend project — that’s a product in itself. We’re a small team. Nobody has bandwidth for that.
Option two: a hosted headless CMS.
There are plenty of them. I looked at a few. Some were too opinionated about content modelling. Some had pricing that didn’t make sense for our stage. Some had good developer experience, but the self-hosting story was either nonexistent or poorly documented. A few felt like they were built for marketing teams at large companies, not for developers who need fine-grained control over how data flows into a Next.js app.
I kept hitting the same wall: I needed something where I could define the content schema myself, query it cleanly from the frontend, and not end up dependent on a third-party service going down the night before a client demo.
Option three: open-source, self-hosted, code-first.
This is where things got interesting.
Why We Chose Payload CMS
After evaluating a few open-source options, we landed on Payload CMS. A few things made it stand out:
1. It’s code-first. You define your collections and fields in TypeScript. No clicking around in a GUI to set up your schema. It lives in your repo, it’s versioned, it’s reviewable. That alone made it feel like something built by developers, for developers.
2. It runs as part of your Node.js app. It’s not a separate micro-service you bolt on. You import it, configure it, and it becomes part of your existing backend. One fewer thing to manage in production.
3. The access control model is serious. Field-level, document-level, collection-level — all configurable in code. We could restrict which editors see which city data without writing custom auth middleware.
4. REST and GraphQL out of the box. Our Next.js frontend could query exactly what it needed, nothing more.
5. We could self-host it on our own infrastructure. No vendor dependency for something this core to the platform.
How We Modeled the Content
The core of our setup is two Payload collections: CityPages and ContentBlocks.
CityPages holds everything city-specific: the hero headline, subtext, market stats (average price per sqft, active listings, top builders in that market), and an array of ContentBlock references. Cities like Noida, Gurgaon, and Lucknow each get their own document here, editable by the product team without touching code.
ContentBlocks is where the element-based variation lives. Each block has a segment field — broker, builder, or buyer — and a blockType field — hero, cta, feature-highlight, testimonial. The content editor can mix and match blocks per city per segment without a single line of code changing.
Fetching It in Next.js:
On the frontend, we use Next.js App Router with generateStaticParams for city pages. Each city slug maps to a dynamic route /cities/[slug]. At build time — or on-demand with ISR — we fetch the city page data and its associated content blocks.
The ContentBlockRenderer handles segment-based filtering cleanly:
The depth=2 in the API query is important — it tells Payload to resolve relationship fields one level deep, so you get the full block objects instead of just IDs. Without it, you’ll be doing a second round of fetches for each block, which is not fun to debug at midnight.
Self-Hosting and the Bits Nobody Tells You
We run Payload on a VPS alongside the main Next.js app. The CMS and the API server are the same Node.js process — that’s one of the cleaner things about how Payload is architected. A SQL database handles the data layer, and media uploads go to S3-compatible object storage.
A few things I wish someone had told me before I set this up:
• In early development, wipe and reseed freely. Don’t fight with migrations while you’re still iterating on your schema. Once you go to production, set up proper Payload migrations and treat them seriously. Don’t skip this step — schema drift will catch you.
• Set PAYLOAD_SECRET to something long, random, and permanent. It signs your JWTs. If you rotate it in production, everyone gets logged out instantly. Obvious in hindsight.
• Use depth carefully in your API queries. It’s easy to over-fetch. A depth=3 on a complex collection can return a lot more data than you expect. Profile your queries before they hit production.
• Put the admin UI behind Tailscale or a VPN. Payload’s admin panel is password-protected by default, but there’s no reason to expose it publicly. We use Tailscale so that only team members can even reach the /admin route.
• Use the draft/publish workflow — it’s built in. Content editors can stage changes without anything going live. The product team now prepares city page updates in advance and publishes them on their own schedule. No deployment needed.
What Changed After This
The Slack messages changed.
Instead of “Hey, can you update the Lucknow hero text?”, it’s now “Done, published the Lucknow update.” The product team does it themselves. I don’t touch it.
Adding a new city — say Mathura or Bareilly — takes five minutes of content entry and zero code changes. The page gets generated automatically because we use generateStaticParams to fetch all city slugs at build time, or revalidates on-demand when a new city page gets published.
The conditional rendering mess is gone, too. No more prop drilling segment flags through four component layers. Payload owns that logic now — blocks are tagged by segment, the renderer filters them. It’s clean in a way the old approach never was.
The trade-off we made early on — the 900-line cities.ts file — served its purpose. It got us to market fast across Delhi NCR and UP. But every shortcut has an expiry date, and this one was overdue. Payload CMS was how we paid that debt without burning down what we’d built.
When Does This Make Sense for You
Pull in Payload CMS when:
• You have multi-market or multi-locale content that changes independently across regions
• Your content team needs to make updates without going through engineering
• You need segment or role-based content variation at the component level
• You want full ownership of your content infrastructure — no SaaS dependency, no vendor lock-in
• Your stack is Node.js - based and you want the CMS to live inside your backend, not alongside it as a separate
service
Don’t bother if your content is mostly static and rarely changes, or if you’re a solo developer with no content team to hand things off to. In that case, MDX files and a good i18n setup will take you further with less overhead.