# Road to Listora

> The admin import screen went live offering zero importable entities. Bulk CSV import — the product spec's declared launch-day seeding path, elevated to first-class v1 priority — backed against a registry containing only entries the import path refuses to process. So the screen offered nothing. Every

Source: https://saascode.ai/inside/road-to-listora · Published: 2026-08-29 · Section: builds · Product: Listora (https://saascode.ai/products/listora)

---
The admin import screen went live offering zero importable entities. Bulk CSV import — the product spec's declared launch-day seeding path, elevated to first-class v1 priority — backed against a registry containing only entries the import path refuses to process. So the screen offered nothing. Every automated check came back clean because the route existed; the gap was one level down, in the registry the route reads. A directory with no listings has no value. Listora is a niche-directory engine, sold once as source code, pointed at a vertical by its operator. The traffic is the operator's work. The engine is what shipped.

## Starting point

The structural model is Yelp's: a dual-field find-plus-location search, a list-and-map result split, listing profiles carrying photos, hours, contact details and reviews. Not Yelp's advertising business, not its scale — one operator, one vertical or locality, recurring listing tiers as the revenue model. Houzz supplied a second reference point: a directory narrowed to a single industry that becomes the authority for that industry.

Two architectural bets shaped the build from the start. The first: every claimed business becomes its own lightweight tenant. Unclaimed listings sit unowned until a business owner initiates a claim; the claim transfers stewardship. One listing, one tenant — a structural invariant, not a policy that a future upgrade could quietly relax.

The second: the engine itself stays invariant across verticals. Category trees, attribute schemas, listing plan names and prices are all runtime-configurable from the operator admin panel, with no code fork required to point the same product at restaurants, contractors or real-estate professionals. The fork model rests on this entirely: a vertical is a configuration, not a separate product.

Before any code landed, four design directions were explored. The chosen direction — warm terracotta, balanced spacing, humanist type — placed a live working directory search in the hero rather than a screenshot. The landing page is the directory, not a description of one.

## Reading the market

The operator's realistic alternatives are roughly three: a hosted directory-SaaS that charges per site and per plan tier, a niche platform owned by someone else, or a self-assembled stack of directory plugins.

The hosted-SaaS market has proof that operators pay for it. Brilliant Directories has operators running their own directories and charging listed businesses recurring fees; the platform's aged UI is the recurring critique on review aggregators, where reviewers consistently call it a 2010s product. eDirectory runs on a PHP stack; the technical contrast with a modern Next.js build is real.

The plugin path deserves an honest account. Directorist, GeoDirectory and HivePress are not fragile — they are actively maintained, well-reviewed and healthy on primary evidence. GeoDirectory stores listings in custom database tables, which is the architecturally correct choice for a serious directory. What the plugin path costs an operator is systems integration: four or five vendors with separate release cadences, separate renewals, and review clusters that document crashes at upgrade boundaries. For an operator who wants a single owned codebase with no per-site subscription, that integration overhead is the real cost — not inherent brittleness.

HivePress with the free ListingHive extension and roughly a $99/year bundle is a genuinely strong cheap answer for a first directory. Acknowledging that buys credibility the rest of the argument spends.

The tier logic the product's pricing follows is placement-based: payment buys position in results, not just profile richness. Free listings exist; paid tiers rank higher. The seeded plan defaults are operator-repriceable from the admin panel — for a directory with no traffic yet, those defaults sit at the optimistic end of the range.

## The decisions that shaped it

**Search stays in the database.** PostgreSQL trigram indexes power name and description matching; earthdistance handles proximity ordering in-database. No external search vendor in the hot path. When listing volume reaches the scale where full-text relevance noticeably degrades, the documented next step is a dedicated search sidecar. That is a future decision, not something v1 depends on.

**Geocoding may never block a save.** Every address submission fires a geocoding call bounded by a hard timeout. Failure saves the row with null coordinates and a pending status; a scheduled job picks it up on the next run. This was verified with a deliberately broken seed row: the listing renders fully on its public page, appears correctly in text-based search, and drops cleanly out of proximity results. No map tile, no error boundary, no crash — the directory stays functional even when the geocoder does not.

**Ratings have exactly one write path.** A database trigger fires on every review change and calls a single privileged function that recomputes the listing's rating average and review count. The weekly reconciliation job calls the same function. No other code path writes those columns — confirmed by searching the entire codebase for inline counter updates and finding zero.

**The claim flow is a real state machine.** An owner initiates a claim; the system locks the listing. A single-use token is generated — only its hash is stored — and a verification email is sent. The token verifies once, transfers ownership, and expires the listing lock. A replay reports the terminal state rather than a phantom failure; an expired claim releases the listing. The flow ran end to end against the live database. The honest caveat: when email delivery fails, the API still reports success. That is a code defect, open going into v1.0.0, not a design choice.

**Billing rides the existing subscription infrastructure.** Listing plans — Free, Featured, Premium — map to the same org-subscription layer used throughout. The payment webhook is the sole tier activator; a two-layer idempotency guard stops a retry from double-applying an event. Plan lookups use fixed internal identifiers, never plan names: an operator who renames Featured cannot accidentally downgrade every business that subscribed to it.

## What fought back

The cold-start gap was the most consequential find, and it was invisible until someone walked the actual admin path.

The import screen existed. The registry backing it did not include a listings entity — only placeholder entries the import path refuses. Adding the entity required getting four defaults exactly right. No org-column stamp on import: stamping the operator's own org on every incoming row would have made every imported listing unclaimable by an actual business. Slug as the dedup key, to prevent duplicates on re-import. Status exposed as an optional field, because the column's default is draft: without it, every imported row would have been silently non-public. Geocode status excluded, so its pending default feeds the scheduled retry job rather than triggering an immediate geocoding call on thousands of rows at once. Those four decisions together are the entire import-validation story.

The public listing page came close to being unreachable for anonymous visitors. A prefix-based middleware match would have pulled every business profile page — the most SEO-critical surface in the product — through an authentication check and redirected unauthenticated visitors to a login screen. A directory whose public pages sit behind a login is not a directory.

The product's only anonymous write path — public business submissions, a core mechanism for organic directory growth — was returning 500. The correct fix was dropping a RETURNING clause from the database insert. The tempting alternative was granting the read the insert needed to anonymous callers; that would have handed every anonymous visitor the intake queue, including other people's business names, emails and phone numbers.

A category hierarchy bug came close to taking down public browse for anonymous visitors. A category could be re-parented under its own descendant without a database constraint to stop it; the rollup function called by the public browse endpoint would then recurse without bound. One admin save could close a cycle; the next browse request would hang.

The map died completely on a production build and left no visible signal. Every adjacent health check read clean — tile styles loaded, the canvas sized correctly, WebGL reported no context errors, markers painted at the right coordinates. Zero tiles rendered. The map library derives its worker URL from its own module metadata; in a production build that resolves to an empty string, the worker never starts, and tile fetching lives entirely in that worker. The build exited clean because no automated check renders a map and inspects the result.

Review flagging was deliberately designed not to suppress content. An owner who can flag a review to hide it holds a one-click veto over every critical review on their page. Flagging marks a review for operator triage; it hides nothing. The operator clears the flag by moderating. Review responses ship on the Free plan because the Free plan's feature description said so — shipping v1.0.0 with a pricing-page promise that the code does not honour is a worse outcome than building the feature.

**Edges carried into v1.0.0:** proximity search silently drops invalid coordinates and returns an unfiltered result set with no signal that the location filter was ignored. Search impression metrics report null rather than zero — a Premium business reading zero would reasonably conclude nobody is finding them, which may not be true. Production map tiles are an operator decision; the development tile provider is a single-maintainer project not suited for production. The geocoder's footer attribution link is a license obligation, not decoration. English only.

## What shipped

Listora ships as a complete multi-tenant directory engine: public browse and proximity search, rich listing profiles with categories and per-category attribute schemas configurable from the admin panel at runtime, reviews with a moderation queue, a claim-and-verify flow that converts a scraped or imported row into an owned business page, listing plans with placement-based tier logic, a public submission queue, bulk CSV import for cold-start seeding, and a structured SEO layer across every public listing and category page.

The multi-tenancy is structural. Cross-tenant writes are blocked at the database level, verified against the live build where an attempt to update a different org's listing returned zero rows.

The demo reaches every moderation surface doing something on first login — a listing with a failed geocode that renders cleanly without a map, reviews and a claim pending, a listing in draft state. No queue is empty on arrival; every moderation path has something to act on.

What Listora does not ship is an audience. The traffic is the operator's work. That is not a gap in the product; it is the nature of the model. Every directory that has ever mattered solved that problem through the operator's own distribution, content strategy or community. The engine handles everything from the first imported listing to a claimed, reviewed, ranked business page. The rest is the operator's to build.

## See it

[See Listora →](https://listora.saascode.ai)

## Related reading

- [Listora vs Brilliant Directories](https://saascode.ai/inside/listora-vs-brilliant-directories.md)
- [Best Directory Website Software in 2026: A Buyer's Guide](https://saascode.ai/inside/best-directory-website-software-in-2026-a-buyer-s-guide.md)
