kevinhoang.co
Full-Stack Engineer
kevinhoang.co is the website for my real-estate business, and I built every part of it: more than a hundred prerendered pages, a built-in CRM and admin tools, a comparable-sales price estimator, and a Vietnamese-language section. It is a React and TypeScript app on Supabase that statically generates every public route, so search engines and AI crawlers see real content instead of an empty JavaScript shell.
Why prerender a React app instead of shipping a single-page app?
Most crawlers, social link previews, and AI retrievers do not run JavaScript, so a client-rendered React app shows them a blank page. The site uses vite-react-ssg to prerender every route to HTML at build time, with each page's title, canonical URL, Open Graph tags, and JSON-LD coming from one Seo component. There is no catch-all rewrite to index.html, so a URL that was never prerendered returns a real 404 instead of posing as a page.
Getting there meant finding and writing down a set of hydration rules, each one learned from a real bug:
- Load the app script with defer, not async, to avoid a race on the static-data manifest.
- Never read window, localStorage, or the query string during the first render.
- Keep the language fixed at build time and apply a visitor's saved preference after mount.
- Never nest one link inside another.
- Prerender private admin and CRM routes too, and keep them out of search with noindex, because withholding their HTML made the host serve the homepage's markup at those URLs.
How does the build produce its own SEO files?
The build is a pipeline: typecheck, prerender every route, generate sitemap.xml and llms.txt from one route registry, then submit changed URLs through IndexNow. When a batch of thin blog posts was retired, their slugs were frozen into a committed redirect map first, and a script generates the 301 redirects from that file, so old links still resolve.
Listing data used to be fetched inside an effect, so the prerendered page held a spinner instead of listings. A sync script now writes a committed snapshot that is rendered into the HTML, and the live fetch still refreshes it after hydration.
How was the home-price estimator tested?
Listing pages show a comparable-sales estimate. All of the math lives in one pure module with no network or DOM access, so it can run against a synthetic market generated from known parameters, and a check asserts that the estimator recovers them. A separate backtest values homes that actually sold, as of each sale's own date and with later sales removed, because letting a model see sales from a home's future is the easiest way to make a backtest look better than it is.
The comparable-selection rules were tightened after reviewing live output: an estimate is built from five closely matched sales or not shown at all, and widening the search widens the distance, never the definition of a similar home.
What runs behind the public pages?
Supabase provides authentication, Postgres, and storage for a built-in CRM (contacts, deals, and follow-ups), lead capture, rental applications, open-house sign-in, saved homes, market reports, and analytics dashboards built with Recharts.
The Vietnamese-language section is served as real prerendered routes rather than a client-side language toggle, because a toggle's translations are never crawled in that language.
How does the site stay maintainable after 150 commits?
Identity and contact details live in one config module that every page, email, and schema block reads, so they cannot drift apart. A project guide records each rule next to the bug that prompted it, and the build fails on any type error by design. The whole stack also runs in Docker for development.