Search “sedot WC Bekasi”, “service AC Depok” or “jasa pindahan Surabaya” on your phone. In many cities the first page is a mix of marketplace listings, directories, old blogspot pages and one or two real businesses with a site nobody has touched since 2019. The people searching aren’t browsing. They have a full septic tank or a broken AC, and they’ll message the first number that looks legitimate.

Rank and rent turns that into monthly income. You build a small, useful website for one service in one city, get it ranking, and send every enquiry to a local contractor who pays you rent: a flat monthly fee, a fee per lead, or both. You own the site and the domain. If the contractor stops paying, you change one phone number and the leads go to their competitor the next day.

It’s an old SEO model, and it’s legal. The grey parts are Google’s spam policies and Business Profile rules, and that’s where people get burned. This guide covers:

  • picking a niche and city that can actually rank
  • a Python + Gemini script that drafts pages only from facts you collected, and flags any price you didn’t supply
  • a free Cloudflare Worker that counts WhatsApp clicks per page, so you can prove leads to a tenant
  • pricing, real costs, realistic timelines, and the risks

How the money works

ModelHow it worksWhen to use it
Pay per leadContractor pays for each enquiry (a WhatsApp chat)First 1–2 months, while they test you
Flat monthly rentFixed fee, all leads includedOnce the site sends steady volume
HybridSmall base fee + per-lead fee or % of closed jobsHigh-ticket services (renovation, waterproofing, CCTV)

US rank-and-rent operators commonly report $500–$2,000 a month per site in forums and courses. Treat those numbers as self-reported marketing, not a benchmark, and they don’t carry over to Indonesia anyway. Price from the contractor’s own numbers instead:

If a sedot WC job brings a contractor Rp 400,000 profit and they close 1 in 3 enquiries, each lead is worth about Rp 130,000 to them. Charging Rp 25,000–40,000 per lead is an easy yes.

At 40 leads a month, that’s Rp 1–1.6 million per site. That’s my pricing logic, not a market survey. Call three contractors in your niche, ask what a typical job costs, and do the same maths before you quote.

The money is real, but it’s slow. Expect 3–6 months before a new site ranks for local terms, and some sites never do. This is a portfolio game: build three to five sites, keep the ones that rank, rent those out.

What you need

ItemCostNotes
Domain~$10.50/year for a .com at Cloudflare Registrar (at-cost pricing).id and .web.id are sold by local registrars; prices vary, so compare before buying
Hosting: Cloudflare Pages$0Free plan: unlimited sites, 500 builds/month
Lead tracker: Cloudflare Workers + D1$0Free plan: 100,000 Worker requests/day; D1 allows 100,000 row writes/day
Hugo$0Static site generator, one binary
Python 3.10+, google-genai, pyyaml, pydantic$0Page drafting script
Gemini API key$0 to startaistudio.google.com
Google Keyword Planner$0Needs a Google Ads account. Without ad spend it shows volume ranges, which is enough
Google Search Console$0Indexing and query data
Node.js 18+$0For wrangler, Cloudflare’s CLI

Gemini pricing (check ai.google.dev/pricing before relying on it): Gemini 3.5 Flash-Lite paid tier is $0.30 per 1M input tokens and $2.50 per 1M output tokens. A 10-page site is roughly 15,000 input and 20,000 output tokens, so about $0.055 per site. The free tier also handles this volume.

Step 1: Pick a niche and a city that can rank

Good rank-and-rent niches have three things: urgent need, jobs worth at least a few hundred thousand rupiah, and contractors who don’t do marketing. Examples: sedot WC, service AC, pest control, waterproofing/bocor atap, tukang kunci, pindahan, CCTV installation, sumur bor.

Then check whether the search results are beatable. Search in an incognito window, with your location set to the target city:

  1. Who’s in the top 10? Marketplaces (OLX, Shopee), directories, Facebook posts and thin one-page sites are good signs. Ten strong local business sites with reviews and real photos is a bad sign. Pick another city.
  2. Do people search it? In Keyword Planner, enter “[service] [city]” and its variants. A range of 100–1,000 monthly searches for the main term is plenty for one site.
  3. Are there at least three contractors? You need someone to rent to, and someone to switch to if they stop paying. Search Google Maps for the service and look for businesses with reviews but no website.

Pick satellite cities over Jakarta. “Service AC Cikarang” is much easier to rank for than “service AC Jakarta”, and a contractor there gets the same value from each job.

Step 2: Collect real facts before you write anything

This step keeps you on the right side of Google. Message two or three contractors as a potential customer and write down what they tell you. Save it as site.yaml:

service: Sedot WC
city: Bekasi
brand: SedotWC Bekasi 24 Jam
areas: [Bekasi Timur, Bekasi Barat, Bekasi Utara, Tambun, Cibitung, Jatiasih]
prices:
  - "Sedot WC rumah tangga, tangki biasa: Rp 450.000 - Rp 650.000"
  - "Biaya tambahan selang lebih dari 30 meter: Rp 50.000"
facts:
  - "Truk vakum kapasitas 3.000 sampai 4.000 liter"
  - "Bisa datang hari yang sama untuk order sebelum jam 2 siang"
  - "Septic tank sebaiknya disedot tiap 2-3 tahun untuk rumah 4 orang"
faq_from_customers:
  - "Berapa lama proses sedot WC?"
  - "Apakah harus bongkar keramik?"
  - "Bisa malam hari?"
pages:
  - slug: _index
    topic: "Jasa sedot WC di Bekasi, harga, area layanan, cara order"
  - slug: harga-sedot-wc-bekasi
    topic: "Rincian harga sedot WC Bekasi dan apa yang mempengaruhi harga"
  - slug: tanda-septic-tank-penuh
    topic: "Tanda septic tank penuh vs WC mampet biasa, kapan perlu disedot"
  - slug: faq
    topic: "Pertanyaan yang sering ditanyakan pelanggan"

Four to ten pages is the right size. Not 200 pages, one per kelurahan. That pattern is exactly what Google calls doorway pages (more in the risk section).

Step 3: Draft the pages with Gemini (fact-checked)

mkdir ranksite && cd ranksite
python3 -m venv .venv && source .venv/bin/activate
pip install google-genai pyyaml pydantic
export GEMINI_API_KEY="your-key"
hugo new site site

Open site/hugo.toml and set baseURL = "https://yourdomain.com/". Hugo builds the sitemap and canonical URLs from it, and the default example.org would end up in the sitemap you submit to Google.

Save this as draft_pages.py:

import os, re, sys
from pathlib import Path

import yaml
from google import genai
from google.genai import errors, types
from pydantic import BaseModel

MODEL = os.environ.get("PAGE_MODEL", "gemini-3.5-flash-lite")
PRICE_IN, PRICE_OUT = 0.30, 2.50   # USD per 1M tokens, Gemini 3.5 Flash-Lite paid tier
OUT = Path("site/content")

class Page(BaseModel):
    title: str
    description: str
    body_markdown: str

client = genai.Client()            # reads GEMINI_API_KEY

def numbers(text):
    """Every number with 2+ digits, separators removed: 'Rp 450.000' -> '450000'."""
    return {re.sub(r"[.,]", "", n) for n in re.findall(r"\d[\d.,]*\d", text)}

def prompt_for(cfg, page):
    facts = "\n".join(f"- {x}" for x in cfg["prices"] + cfg["facts"])
    faq = "\n".join(f"- {x}" for x in cfg["faq_from_customers"])
    return f"""Write one page in Bahasa Indonesia for a local service website.
Service: {cfg['service']} in {cfg['city']}. Brand: {cfg['brand']}.
Areas served: {', '.join(cfg['areas'])}.
Page topic: {page['topic']}

Use ONLY these facts. Never invent prices, numbers, guarantees, years of experience,
certifications, reviews or customer names. If a fact is missing, leave it out.
{facts}

Real customer questions you may answer:
{faq}

Rules:
- Plain, helpful language. Short paragraphs. Use ## subheadings.
- 350-600 words. No keyword stuffing; mention the city naturally a few times.
- End with one sentence inviting the reader to chat on WhatsApp. Do not write a phone number.
- description: max 150 characters."""

def draft(cfg, page):
    for attempt in range(3):
        try:
            resp = client.models.generate_content(
                model=MODEL,
                contents=prompt_for(cfg, page),
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=Page,
                    thinking_config=types.ThinkingConfig(thinking_level="minimal"),
                ),
            )
            if resp.parsed:
                return resp.parsed, resp.usage_metadata
        except errors.APIError as e:
            print(f"  API error {e.code} (attempt {attempt + 1}), retrying", file=sys.stderr)
    sys.exit(f"Page {page['slug']} failed 3 times")

def main():
    cfg = yaml.safe_load(Path(sys.argv[1] if len(sys.argv) > 1 else "site.yaml").read_text())
    allowed = numbers(" ".join(cfg["prices"] + cfg["facts"] + [cfg["brand"]]))
    OUT.mkdir(parents=True, exist_ok=True)
    tok_in = tok_out = 0
    for page in cfg["pages"]:
        p, usage = draft(cfg, page)
        tok_in += usage.prompt_token_count or 0
        tok_out += (usage.candidates_token_count or 0) + (usage.thoughts_token_count or 0)
        invented = numbers(p.body_markdown) - allowed
        front = (f'---\ntitle: "{p.title}"\ndescription: "{p.description}"\n'
                 f'draft: {"true" if invented else "false"}\n---\n\n')
        (OUT / f"{page['slug']}.md").write_text(front + p.body_markdown, encoding="utf-8")
        flag = f"  CHECK numbers not in your facts: {sorted(invented)}" if invented else ""
        print(f"{page['slug']}.md written{flag}")
    print(f"Tokens: {tok_in} in / {tok_out} out, "
          f"about ${tok_in / 1e6 * PRICE_IN + tok_out / 1e6 * PRICE_OUT:.4f}")

if __name__ == "__main__":
    main()

Run it:

python draft_pages.py site.yaml

Any page containing a number you didn’t supply (a made-up price, “berpengalaman 15 tahun”, a fake phone number) is saved with draft: true and listed on screen, so Hugo won’t publish it until you fix it. _index.md becomes the home page. Then read every page yourself and add what only you have: real photos of a truck or a job (ask the contractor), a real price table, the areas you actually confirmed.

Pick any lightweight Hugo theme, or write a one-file layout. In the base template (for example layouts/baseof.html on Hugo 0.146+, or layouts/_default/baseof.html on older versions), add a sticky WhatsApp button that points to your tracker, not straight to wa.me:

<a href="https://go.yourdomain.com/wa?p={{ if .IsHome }}home{{ else }}{{ with .File }}{{ .ContentBaseName }}{{ else }}other{{ end }}{{ end }}"
   style="position:fixed;bottom:16px;right:16px;padding:14px 20px;background:#25D366;color:#fff;border-radius:30px">
  Chat WhatsApp
</a>

Step 4: The lead tracker (Cloudflare Worker + D1)

The tenant will ask, “How many leads did you send me?” This Worker logs each WhatsApp click with the page it came from, then redirects to the tenant’s number with a pre-filled message. The message includes a page tag, so the tenant sees in their own chats which messages came from your site. That’s your proof, and it doesn’t depend on them trusting your dashboard.

npm create cloudflare@latest lead-tracker -- --type hello-world --lang js --no-deploy
cd lead-tracker
rm wrangler.jsonc        # C3 now generates JSONC; this guide uses wrangler.toml
npx wrangler login
npx wrangler d1 create leads

If d1 create offers to add the binding to your config, decline; you write the config yourself. Create wrangler.toml and paste in the database_id it printed:

name = "lead-tracker"
main = "src/index.js"
compatibility_date = "2026-10-01"

[vars]
WA_NUMBER = "6281234567890"
WA_TEXT = "Halo, saya lihat dari sedotwcbekasi.com, mau tanya sedot WC"

[[d1_databases]]
binding = "DB"
database_name = "leads"
database_id = "paste-id-here"

Create the table and set a secret key for the stats page:

npx wrangler d1 execute leads --remote --command \
  "CREATE TABLE clicks (ts INTEGER, page TEXT, country TEXT, ua TEXT)"
npx wrangler secret put STATS_KEY

Replace src/index.js:

const BOTS = /bot|crawl|spider|preview|facebookexternalhit|whatsapp/i;

export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url);

    if (url.pathname === "/wa") {
      const page = (url.searchParams.get("p") || "unknown").slice(0, 80);
      const ua = req.headers.get("user-agent") || "";
      if (!BOTS.test(ua)) {
        ctx.waitUntil(
          env.DB.prepare("INSERT INTO clicks VALUES (?, ?, ?, ?)")
            .bind(Date.now(), page, req.cf?.country || "", ua.slice(0, 200))
            .run()
        );
      }
      const text = encodeURIComponent(`${env.WA_TEXT} [${page}]`);
      return Response.redirect(`https://wa.me/${env.WA_NUMBER}?text=${text}`, 302);
    }

    if (url.pathname === "/stats" && url.searchParams.get("key") === env.STATS_KEY) {
      const { results } = await env.DB.prepare(
        `SELECT date(ts / 1000, 'unixepoch', '+7 hours') AS day, page, COUNT(*) AS clicks
         FROM clicks GROUP BY day, page ORDER BY day DESC LIMIT 300`
      ).all();
      return Response.json(results);
    }

    return new Response("Not found", { status: 404 });
  },
};

Deploy:

npx wrangler deploy

In the Cloudflare dashboard, add a custom domain such as go.yourdomain.com to the Worker (Workers & Pages → lead-tracker → Settings → Domains & Routes). Open https://go.yourdomain.com/stats?key=YOURKEY for a per-day, per-page count (dates in WIB).

Switching tenants is one line: change WA_NUMBER in wrangler.toml and run npx wrangler deploy again. That’s your leverage in every negotiation.

The tracker stores no phone numbers or names, only a timestamp, page, country and browser string. The lead’s details go straight from their phone to the contractor’s WhatsApp. Under Indonesia’s personal data protection law (UU PDP, Law 27/2022), keeping it that way is much simpler than running a form that collects names and numbers.

Step 5: Publish and get indexed

cd ../site && hugo --minify
npx wrangler pages deploy public --project-name sedotwc-bekasi

Attach your domain in the Pages project settings. Then:

  1. Add the domain to Google Search Console (DNS verification) and submit https://yourdomain.com/sitemap.xml. Hugo generates it automatically.
  2. Use URL Inspection → Request indexing for the home page.
  3. Get a few honest local mentions: post the site in service threads in local Facebook groups, and ask the contractor to link it from their Instagram bio.

Check Search Console weekly. The Performance report often shows you appearing for “[service] [city]” queries at positions 30–60 before you get any clicks at all.

Step 6: Rent it

Once the site gets 10+ clicks a month, message contractors from your Google Maps list. Keep it short:

“Pak, saya punya website sedot WC untuk area Bekasi yang sudah dapat chat dari calon pelanggan. Bulan lalu ada 14 chat. Saya bisa arahkan semua chat ke WhatsApp Bapak. Coba gratis 7 hari dulu, setelah itu Rp 30.000 per chat atau Rp 750.000 per bulan, terserah Bapak.”

The free week matters: the contractor sees real messages tagged [harga-sedot-wc-bekasi] arrive in their own WhatsApp. Put the deal in writing (a WhatsApp message you both reply “setuju” to beats nothing): price, payment date, what counts as a lead, and that you’ll redirect leads if payment is late.

Cost breakdown

ItemPer site, first year
.com domain (Cloudflare Registrar)~$10.50
Hosting, Worker, D1$0 (free plans)
Gemini drafting~$0.06
Your time8–12 hours to build, 1–2 hours/month after that
Cash total~$11 (under Rp 200,000)

Five sites cost about $55 a year in cash. The real costs are your time and months of waiting.

Realistic outcome for a first portfolio of five sites in low-competition niches: after six months, one or two send meaningful volume (20–60 clicks a month) and the rest send a trickle. One rented site at Rp 750,000–1.5 million a month pays for the whole portfolio many times over. That’s an estimate based on how small local sites usually behave, not a guarantee.

Risks: what gets you penalized or in trouble

Scaled content abuse. Since March 2024, Google’s spam policies explicitly cover “scaled content abuse”: lots of pages made mainly to manipulate rankings, however they’re produced, by people, automation or both. AI-written pages aren’t banned in themselves, but 200 near-identical pages with the city name swapped will be treated as spam. Keep sites small, factual and edited by hand.

Doorway pages. Separate pages for every kelurahan or kecamatan that say the same thing are textbook doorways under Google’s spam policies. One page listing the areas you serve is enough.

Google Business Profile (the real grey area). A Maps listing drives a big share of local leads, so people create fake listings with virtual-office addresses or a brand that doesn’t exist. Google’s guidelines require a listing to represent the actual business serving customers, and Google regularly suspends listings that don’t, often after a video verification it can request at any time. Don’t create a GBP for your rank-and-rent brand. If the tenant has their own GBP, that’s theirs to manage. Your asset is the website.

Misleading customers. Don’t claim to be the company. Put a line in the footer, for example: “Situs ini menghubungkan Anda dengan penyedia jasa mitra di Bekasi.” No fake reviews, no invented “15 tahun pengalaman”, no prices the contractor won’t honour. Indonesia’s consumer protection law (UU 8/1999) prohibits misleading offers, and fake reviews also break Google’s policies.

Trademarks. Don’t put a brand in your domain or titles (“service AC Daikin Bekasi”) unless the tenant is an authorised service centre. Brands do send complaints, and it’s an easy way to lose a domain.

Bad tenant. If the contractor does poor work, angry customers blame “the website”. Check the contractor’s Maps reviews before renting, and drop tenants who get complaints.

Non-payment. Common with small contractors. Bill on a fixed date and redirect after one missed payment. Because you own the Worker, that takes ten seconds.

Reality check

What doesn’t work:

  • Mass-generating city pages or sites. They might rank briefly, then vanish in the next spam update, sometimes taking the whole domain with them.
  • Expecting money in month one. New domains take months. If you need income this month, this isn’t the right first project. Run it alongside a service you can sell today.
  • Jakarta-wide head terms. “Service AC Jakarta” is dominated by big players and ads. Go narrow: satellite cities, specific services (“cuci AC split”, “bongkar pasang AC”).
  • Buying backlinks or expired domains to shortcut ranking. Link spam and “expired domain abuse” are both named in Google’s spam policies.
  • Counting clicks as closed jobs. A click isn’t always a message, and a message isn’t always a job. That’s why the pre-filled tag exists: charge per real chat the contractor can see, not per click on your dashboard.

Common mistakes:

  • Publishing the Gemini draft without adding real photos or checking the facts
  • Linking the button straight to wa.me, then having no data when you pitch
  • Renting to one contractor with no backup, so one unpaid month means a dead site
  • Forgetting the footer disclosure
  • Ignoring odd spikes in /stats. If clicks jump overnight from one country or one user agent, it’s a crawler, not customers. Add it to BOTS.

Model names change. If gemini-3.5-flash-lite is retired, set PAGE_MODEL to the current Flash-Lite model and update PRICE_IN/PRICE_OUT.

Final checklist

  • Niche checked: weak top 10, real search volume, at least three contractors
  • site.yaml filled with facts you collected yourself
  • Pages drafted, every flagged number fixed, real photos added
  • Worker deployed, /stats working, button points to go.yourdomain.com/wa
  • Footer disclosure, no brand names, no fake reviews, no fake GBP
  • Search Console verified, sitemap submitted
  • Two more sites started in other niches or cities

Start today: pick one service and one satellite city, run the incognito search, and message three contractors for prices. That gives you your site.yaml, and the site can be live tonight.