Indexable multi-tenant SaaS11 min read
1,400 indexable pages without thin content: how I designed Agendamelo's directory
By Jorge CortésPublished
I designed Agendamelo's directory — about 1,400 pages, 19 trades across 69 communes — so each URL is useful on its own: trade and commune copy, live business cards, and a count that may be zero. Without that utility, the combination is not published or not indexed.
The directory exists so a client finds a trade in a place, and each business gets that visit with no platform commission. Evidence: the Agendamelo case. Here I defend the architecture.
Why is trade × commune the right unit in Chile?
In Chile a client does not search for “a service in a city”. They search for a trade in a place they can reach. “Barbershop in Providencia”. “Psychologist in Ñuñoa”. “Hair salon in Las Condes”. The commune — the Chilean comuna — is the unit of movement: Metro, travel time, municipal permit. Santiago as a blob does not work. It is too large to decide. The region is worse: it mixes communes that do not visit each other.
Trade is the other half. Agendamelo is booking for independent professionals. Barbershops, hair salons, psychologists, manicurists, aesthetics, tattoo artists, dentists, veterinary clinics, spas, massage: trades you reserve. A supermarket does not belong. If the product is a booking, the taxonomy has to be a booking taxonomy.
Nineteen trades across sixty-nine communes is the coverage bet, not a scrape of all of Chile: Santiago, cities where demand is already typed — Viña del Mar, Concepción, Valparaíso, Temuco, Antofagasta, La Serena — and the rest of the generated matrix. About 1,400 public pages. Each cell is a trade in a place, not a token in a template.
City × category lies. Providencia and Puente Alto do not compete for the same walk-in client. A foreign CMS calls the trade Beauty; in Chile the client types the oficio. The slug matches that language: /barberias, /peluquerias, /psicologos. Title Case on the commune (/barberias/Providencia). Ñuñoa serialises to Nunoa; Peñalolén to Penalolen. That is the public key of the cell.
The test: strip the commune name and the trade. Does a page remain that still explains something? If not, the cut is wrong. Trade × commune passes because each axis has its own semantics. The directory is the public index of the schema onboarding already requires. It does not invent another taxonomy.
What makes each page unique?
A “Best [trade] in [commune]” template repeated 1,400 times is thin content. The client sees an H1 with the name swapped and empty cards, and leaves. The tenant does not appear. Volume works against the product.
Uniqueness comes from blocks with different sources, not from a synonym.
| Block | Where it lives | What makes it unique |
|---|---|---|
| Trade copy | Trade page | The work being booked |
| Price FAQ | Trade page | Ranges for that trade in Chile |
| Geographic intro | Commune page | Landmarks, Metro, traffic of that commune |
| Count FAQ | Commune page | A live number, including zero |
| Tenant cards | Both | Services and prices from onboarding |
| Nav, CTA, schema | Every page | Boilerplate. It does not cover the body |
/barberias has to explain the trade and show live businesses with prices. Santiago Centro names Plaza de Armas, La Moneda, the Metro; that intro is not reused in La Florida. If the query returns three barbershops in Providencia, the cards are the proof. If it returns zero, the count is the datum. Faking a listing is worse than admitting the gap.
The cards carry what the tenant already published: name, services, prices in CLP, commune. Five-minute onboarding is the schema constraint. With that there is an H1, prices and a calendar at agendamelo.cl/tu-negocio. The directory reuses those rows. It does not ask for a second CMS to “fill in the SEO”.
The live product is at agendamelo.cl. The architecture cut is in the case study. This article uses it as proof, not as a retelling.
When do I not publish a combination?
The 19 × 69 matrix does not oblige hollow cells to be indexed. A combination has three honest states:
- It exists and it is indexed. There is geographic or trade copy that is not a swappable H1, and an honest count. Zero is a datum if the rest teaches the trade in that place.
- It exists and it is not indexed. The URL serves a human — a tenant who just picked a commune, a preview — but the body is still a skeleton.
noindexis not a punishment. It is refusing to lie to the index. - It does not exist.
generateStaticParamsdoes not emit it. The sitemap does not list it. Anyone who pastes the URL gets a 404. Correct outside the coverage bet, and inside it when there is not even a useful page to write.
If the only distinct token is the commune name in the H1, the combination is not published as an index. An empty template is not “businesses coming soon”. It is thin content with a date on it.
Without name, services and hours the tenant does not enter the cards. Gallery and reviews improve the card; they do not enable it. Outside the 69 communes the URL is not fabricated just in case. Until there is demand — or a real tenant in that cell — the route does not exist.
The free profile after the trial is a fourth case: the page stays, the booking engine turns off. If the directory counts that card as a live agenda, it works against whoever wants a slot. Splitting “published” from “booking is live” is the correction I would still make.
Not publishing is a product decision. Every URL in the contract with the index has to work alone.
Does the content come from the tenant or from a template?
From the rows. The tenant is the CMS. The template only assembles.
Postgres on Supabase. Every business, service, hours row, appointment and review carries tenancy. The path /tu-negocio is the public key. The directory lists slug, commune, trade, services and prices — not internal ids. The panel writes. The site reads scoped to that tenant. The directory reads scoped to the cell. If isolation fails, the incident is someone else’s agenda on another card.
Nobody writes 1,400 articles. Onboarding writes the minimum entities. The public render already has a title, prices and a calendar. The directory projects those entities onto two axes. Trade copy and commune copy are the only editorial blocks: tens of them, not thousands. They do not scale with the cartesian product.
generateStaticParams materialises the set. Next.js is the right place for ~1,400 routes that have to exist as HTML. A live count cannot wait for a deploy every time a barber publishes in Providencia. ISR versus pure SSG is not a closed fact here.
The opposite temptation is an “SEO pages” CMS beside the product. It splits the truth: the panel says three services; the directory says a generic paragraph. Systems over screens: one source, several projections. If prices are public on the tenant, they are public on the card.
When I design a digital product with a public surface per tenant, the Think question is: which row, once written, produces a URL someone can open without us in the middle? If the answer is “a copywriter later”, the directory is already hollow.
Sitemap, canonical, internal linking: what does the product decide?
Three mechanisms. None of them repairs a page that does not work alone.
The sitemap lists the URLs the product treats as objects. It does not list the 19 × 69 spreadsheet if a cell does not exist, nor a noindex, nor the dashboard, nor a tenant under the minimum schema. lastmod is honest: the commune page moves when a tenant enters or leaves, not “now” on every deploy.
Each cell’s canonical is itself. Collapsing /barberias/Providencia onto /barberias destroys the unit. Providencia is not a filter on the Chile page. It is a different question. Hreflang does not apply inside Agendamelo: the product is Spanish, for Chile. EN/ES parity on jorgeco.tech is a different problem.
Internal links prevent orphans and cannibalisation. The trade page links communes whose count is greater than zero, not all 69 as a checklist. The commune page links the trades it actually offers. Each card links the mini-site: that is the conversion. The mini-site links back to the trade and the commune. Related trades link with a trade criterion — barbershop and hair salon, not barbershop and veterinary clinic.
That graph is generated from the same filters as the cards. If it is written by hand, it rots. Schema.org describes what the page is: an ItemList of real tenants, a FAQPage that includes the count, including zero. Lying in the JSON is the same thin content, in another content-type.
This is not a ranking service. It is the minimum so ~1,400 public objects do not trip over themselves. SEO, AEO and GEO as a method live at pgas.online.
How does the directory return traffic to each business at zero commission?
The published result: about 1,400 indexable pages send free search traffic to each business, with zero commission per booking. That is commercial architecture, not a slogan.
The platform charges a subscription. The client books online and pays at the venue. No payment gateway on the visitor. No leads the business buys back. A directory that auctions clicks inflates the count and makes tenants pay to appear. That is a different product. I did not build it.
Zero commission aligns incentives with the thesis. More real tenants make the page more useful; the useful page sends visits to the mini-site; the mini-site converts into a booking. The platform charges for the software, not the match. Featured placement is a plan feature, not an opaque auction. If it faked supply, it would break uniqueness the same way an empty template does.
Traffic has to land on a URL that books. That is why onboarding publishes the mini-site the same day. A card pointing at Instagram does not close the loop. A card pointing at agendamelo.cl/tu-negocio, with prices and a calendar, does.
Free for the tenant means: no charge for the click or the booking. Seven-day trial, no card; then a paid plan. An index built to extract a take-rate stuffs cells. An index built to return visits cares for each page.
How I apply this
The Think checklist before a route generator. Already applied in Agendamelo.
- 01/I name the demand unit in the client’s language. In Chile, for booking, that was trade × commune. If it cannot be said as a query someone would type, it is not a unit.
- 02/I make the data model the CMS. The row onboarding writes is the row the directory reads. A second CMS for “SEO content” means there is still no public object.
- 03/I split each URL into blocks with different uniqueness sources. Editorial where there are few entities. Query where there are many. Boilerplate only in navigation, CTA and schema.
- 04/I define three states — indexed, noindex, does not exist — before
generateStaticParams. A combination whose only unique token is the H1 is not published. - 05/Sitemap, canonical and links derive from that table. Canonical is the cell itself. The graph links real supply and sends the click to the tenant.
- 06/I open the page alone, without the other 1,399. If it does not serve a client on that query, it stays out of the index.
If the brief asks for “thousands of city landings” and cannot name the unique block, the work starts by refusing the cut. Sometimes the obvious move is not to fabricate the page.
The thesis, again
A programmatic directory of 1,400 pages only works if each page is useful on its own. In Agendamelo that utility is designed: trade × commune as the unit, tenants as the source of the copy, cells unpublished when there is nothing to say, a graph that returns the visit to the business, zero commission so the index does not rot. The large number is an effect. The useful unit is the product.