A marketplace that recruits whichever side of itself is short
A public website and a signed-in marketplace in one codebase, with a home page that reads live supply to decide which side to argue for, prices held in the database, and contact details the server withholds until they are asked for.

- JavaScript, down from 337 KB
- 153 KB
- material pages as search entry points
- 16
The problem
Surplus construction material (leftover formply, sheet, timber, fixings) goes in the skip because finding a buyer for it costs more than the material is worth. Selling it on only works if listing a pallet takes a couple of minutes and finding one takes less.
A young marketplace is also short of one side or the other, and usually both. The standard answer is fabricated listing counts and placeholder inventory, which is the first thing a trade audience notices and the last thing it forgives.
And the thing had to be two products at once. Signed out, it has to convince a stranger that an account is worth having. Signed in, it has to get a job done: find material, answer an enquiry, list the next pallet. The person doing the second is on a phone, on a site, in sunlight, so page weight and layout stability are not polish, they decide whether the page is usable at all.
What we built
Taken off an AI app builder and onto its own database
The first version was generated on an AI app builder. Before any code moved, a written audit compared what the export contained with what the business needed. The schemas, backend functions and payment logic were then ported and rebuilt on Supabase, Stripe and Vercel in stages, and the builder's SDK was removed completely. The migration was done with Claude Code and reviewed as pull requests.
The database is the boundary
With no server of its own, the browser talks to Postgres directly, so row-level security is the whole authorisation layer. Policies, function exposure and storage rules live in ordered migrations, with SQL tests for the rules that matter (listing privacy, enquiry limits, blocking, account deletion), browser tests, and CI for accessibility, layout stability, security, uptime and backups.
One codebase, two jobs
Signed out, the site is a shop window: home page, seller and buyer pages, pricing, how it works, project closeouts, support, and sixteen material category pages. Signed in, it is a tool (find material, answer an enquiry, list the next pallet) across a five-destination tab bar with an account menu for listings, saved listings, alerts, payment history and the business profile. Both run from the same codebase, and the browse surface is the one page that has to be both at once: public enough for a stranger, and the product for someone with an account.
A home page that reads its own supply
Which side is short changes week to week, so the home page counts live supply against a threshold constant and argues accordingly: recruiting sellers while listings are thin, buyers once they are not. Nothing is fabricated to fill the gap. When there is no true number to show, the proof strip shows nothing at all rather than a placeholder count.
Contact details the server withholds
Revealing a seller's phone number is a server-side action, not a field hidden with CSS. The number is not in the page source before it is revealed, so reading the HTML does not get you it. Enquiry limits are counted per buyer-seller pair, so the platform cannot be used to message every seller on it. Business verification and reporting sit alongside. Demo listings admit what they are: they replace the contact form with an alert signup instead of pretending a seller will reply.
Prices as rows, not copy
Pricing plans live in a pricing_plans table, read by the pricing page and by the checkout. A content test fails the build if a price is typed into page copy anywhere on the site. Marketing copy therefore cannot drift from what a customer is actually charged, not because someone remembers to update both, but because forgetting stops the build.
Entry points per material, not one page for everything
Route heads are prerendered per route, so a crawler sees them without running JavaScript, with a sitemap and FAQ structured data alongside. The sixteen material pages exist to be landed on: someone searching for a specific material arrives at a page about that material, rather than at a home page competing for every term at once.

The outcome
Cumulative layout shift on the home page is 0, down from 0.76, and the JavaScript shipped is 153 KB, down from 337 KB. Accessibility is audited per design token with a check kept in the loop rather than run once, treated here as a conversion concern rather than a compliance one, because the buyer is holding a phone on a building site.
Two products run from one deployment, and the signed-out side gives search sixteen specific pages to land on instead of one home page carrying every term.
The limits are known rather than hidden. Browse still issues one unbounded query and filters in the browser; that works at current volume and will truncate silently once listings pass PostgREST's row cap, and server-side search on the indexed path already exists as the fix. Filters are not reflected in the URL, so a filtered view cannot be shared, bookmarked, recovered with Back, or indexed. And nothing in either funnel is instrumented beyond page views, which is why event instrumentation is phase one of the current plan.
Built with
- React
- Vite
- Supabase
- PostgreSQL
- PostgREST
- Server-side rendering
- Vercel
- Vercel Analytics
- Stripe
- Claude Code
Page copy drafted with AI assistance. Skofi is accountable for it. Editorial policy
Have something shaped like this?
If any part of the above resembles a problem you are living with, a short call is the fastest way to find out whether it is the same problem underneath.
What happens on the call
- 15 minutes, no deck
- We map the process on the call
- A written scope, or an honest no
