How we built SoldFront
By Eliran Yihye, Founder and Lead Engineer at Cuanto Labs ·
SoldFront gives a real estate agent a fast website on their own domain, with MLS listings that sync automatically once their MLS approves the data feed. Under the hood it is one multi-tenant Payload CMS and Next.js application on Postgres, a sync pipeline that speaks the RESO Web API to four listing-data vendors, and a marketing layer written so AI assistants can read and cite it. This post walks through how those pieces fit and what we traded away to keep them simple.
The problem
Most agent websites are either an agency template that traps the lead database, or a page that drops a third-party IDX widget into an iframe. Both are slow, and neither gives the agent a site they own. The harder part is the data. An MLS does not hand listings to whoever asks. The agent has to authorize a vendor, the broker has to sign off, and the board issues credentials for one of several vendor APIs, each with its own authentication, rate limits and replication rules.
So the product has two jobs. Serve many agents from one codebase without mixing their data, and keep every agent listings fresh and compliant without anyone babysitting a feed.
Architecture
SoldFront is a single Next.js 16 app on Payload CMS 4 with the multi-tenant plugin, backed by a managed Postgres. A request first passes through src/proxy.ts, the Next.js 16 proxy. It handles CORS and rate limiting, then decides whether the host is the SoldFront marketing site or an agent site. Agent domains resolve to a tenant, using an edge key-value lookup in production so the common path never touches the database. Everything after that runs against the tenant that was resolved.
Listings are ordinary Payload collections, listings and mls-connections, with tenant-scoped access rules. Server code reads and writes through the Payload Local API only. The repo forbids hand-written SQL against app tables, which means collection hooks and cache revalidation run on every write the sync makes.
A scheduled job dispatches sync work. The diagram shows the path from an MLS vendor API to a page an agent visitor sees.
The MLS sync pipeline
Each connection is a row in mls-connections holding the provider, an encrypted credential set, filters, a sync interval and a watermark. src/mls/providers.ts holds one preset per vendor. Bridge uses a query token and allows 5 requests per second. Trestle uses an OAuth client credentials token and allows 4. Spark uses a bearer token and allows 4. MLS Grid uses a bearer token, allows 2 per second with a 40,000 request daily cap, requires gzip, and requires an originating system filter on every query. A custom preset covers anything else.
src/mls/client.ts is a provider-agnostic OData client. It requests pages of 200 with $top, follows @odata.nextLink until the feed is exhausted, filters with ModificationTimestamp gt the last watermark, and retries 429 and 5xx responses up to 4 times with exponential backoff that honors Retry-After. A small rate limiter enforces the per-vendor spacing before every request.
src/mls/mapping.ts turns a RESO record into a listing document. It normalizes the vendor status into active, pending, closed or off-market, orders photos by the RESO Order field, and marks a record for deletion when MLS Grid sets MlgCanView to false, because that vendor requires replicas to remove such listings. Off-market records are deleted too, so a page never advertises a home that is gone.
Credentials never sit in the database as plaintext. src/mls/encryption.ts encrypts each secret with AES-256-GCM using a 32 byte key from the environment, and it fails closed. If the key is missing or the wrong length, reads and writes throw instead of falling back to plaintext.
Onboarding is a job too. When an agent asks for a feed, requestFeed creates the connection in a requested state, emails the agent the authorization steps for their specific MLS, and notifies our support inbox. After credentials are entered, the dispatcher picks the connection up and its first successful sync marks it active.
Three trade-offs
A lease instead of job concurrency. Two overlapping syncs of one feed would waste a rate-limited quota and fight over writes. The Payload version we run has no concurrency control on its jobs config, so the concurrency key on the sync task is inert today. src/mls/lease.ts implements the guarantee ourselves. The dispatcher only queues connections with no unexpired lease, and the sync task claims the lease with a conditional update before it does any work. The lease lasts 15 minutes and is extended every 200 records, so a long sync never expires under itself. The cost is that a crashed run holds its connection until the lease times out.
Row by row writes through the Payload API. src/jobs/mls/sync.ts looks up each listing by connection and listing key, then updates or creates it. That is an extra query per record and it would not win a benchmark. We accept it because every write then runs collection hooks and cache revalidation, and a unique index on connection plus listing key backs it up. At one agent feed per connection and an hourly cadence, correctness beats throughput. If a feed outgrows this, batching would be the next step.
An hourly schedule on purpose. The dispatcher runs on the hour, and the job runner is triggered a couple of minutes later. The default sync interval is 60 minutes and the floor is 15. Tighter ticks would keep the managed Postgres compute awake around the clock, so we trade a little freshness for a database that can suspend.
Built to be cited
The marketing site is static and server rendered. src/components/marketing/seo.ts is the single source for absolute URLs, the sitemap route list and the JSON-LD builders, so the Organization, SoftwareApplication, AboutPage and BlogPosting nodes share one set of identifiers. Two route handlers serve llms.txt and an extended llms-full.txt, and the sitemap is generated from the same registry, so adding a page to the registry also adds it to the sitemap.
What is next
The repo is honest about what is not finished, and that is our list. The MLS Grid rate limit and daily cap in providers.ts are marked as read from the vendor policy and not re-verified against a live account, so that is the first check once an account is live. The authorization steps emailed to agents follow the general vendor pattern and are flagged in requestFeed.ts for ops review before the first real send. And when Payload ships concurrency control for jobs, the lease can sit behind it.
Frequently asked questions
- How do listings get onto a SoldFront site?
- The agent asks for a feed, their MLS authorizes SoldFront as a vendor and issues credentials, and a scheduled job then pulls changes through the vendor RESO Web API and writes them as listings for that agent only.
- Which listing-data vendors does SoldFront support?
- Bridge, Trestle, MLS Grid and Spark, plus a custom preset for any other RESO Web API endpoint. Each has its own authentication and rate limit in the code.
- Are MLS credentials stored safely?
- Credentials are encrypted at rest with AES-256-GCM. If the encryption key is missing or malformed the system refuses to read or write them, rather than storing plaintext.
- What happens when a listing goes off the market?
- The next sync receives the status change and deletes the listing, and it also deletes any record that MLS Grid marks as not viewable, so a site does not show homes that are no longer available.