Making a React Portfolio Readable to Search Engines
This site is a Vite + React single-page app, which by default hands crawlers an almost empty page. Here's how one metadata table now drives both the live React head and a prerendered HTML file per route, why the sitemap dates come from git, and what Google's Rich Results Test still caught.
A portfolio has one job: when someone searches your name, it should be the thing they find, and it should say who you are before they click. This site is a Vite + React single-page app, and out of the box that works against it - the HTML that ships is an empty root element plus a script tag, every route shares the same title until JavaScript runs, and link-preview bots and many crawlers never run it. So I treated the site's SEO as an engineering problem with the same rules as my other projects: one source of truth, generated output, and tests for the parts that can silently break.
One metadata table, two consumers
Every route's title, description, image and JSON-LD comes from a single file, src/data/seo.js. Two things read it. In the browser, a small <SEO /> component uses react-helmet-async to set the head as you navigate. At build time, a Node script runs after vite build and writes a real dist/<route>/index.html for every route, with the right title, canonical URL, Open Graph and Twitter tags, JSON-LD, and plain crawlable body content like the project list, skills and blog text.
Two small details make that work. The data file imports nothing from the browser or React, so Node can load it directly. And the prerendered tags carry the same data-rh attribute Helmet uses, so when React mounts it replaces them instead of adding a second copy. The result: a crawler that never runs JavaScript sees the same page a visitor does, and I only ever edit metadata in one place.
Sitemap dates from git, not the build clock
The easy way to fill a sitemap's <lastmod> is the build date. The problem is that every deploy then claims every page changed today, which is noise search engines learn to ignore. So a pre-build script asks git for the first and last commit date of the files behind each route - the About page's component plus the FAQ data, each blog post's renderer plus the post data, and so on - and writes them to a generated lastmod.js.
That file is committed, because a hosted build may run from a shallow clone without the history. In that case the script skips and keeps the committed file rather than guessing. The same dates feed the sitemap, the Article and ProfilePage structured data, and the article:published_time meta tags, so all of them agree with each other.
What the Rich Results Test caught anyway
Generated output is only as good as its format, and Google's validators read it literally. Running the homepage through the Rich Results Test turned up two warnings that no local check had flagged:
- dateCreated and dateModified were plain dates like 2026-04-08. Schema.org accepts that, but Google's ProfilePage check wants a full ISO 8601 datetime. The fix was one isoDateTime() helper used everywhere a date leaves the data layer, so the JSON-LD and the meta tags can't drift apart again.
- My upcoming employer was described as a nested Organization with a postal address. Google read that as a standalone organization and asked for a street address and postal code. The address added nothing to a personal profile, so it came out - and the warnings went with it.
Not every Search Console warning is a bug
Search Console also lists http://guptasanjay.com.np/ under 'Page with redirect'. That one is working as intended: the http and www versions permanently redirect to https://guptasanjay.com.np, and the canonical tag and sitemap only ever name that one address. A redirecting URL isn't supposed to be indexed - its target is. Learning to tell a real problem from a report that's just describing your setup saved me from 'fixing' something that wasn't broken.
Tests for the data, not the markup
Because routing, the sitemap, llms.txt and JSON-LD all come from the same data, a small node --test suite checks that data directly: every route path is unique, every route has a title and description, breadcrumbs start at Home and end on the page itself, and every same-page @id reference in the structured data actually resolves. Those are exactly the mistakes that don't show up in the browser but do show up weeks later as a missing search result.
None of this needed a framework switch. A single-page app can be search-friendly if the metadata lives in one place, the build turns it into real HTML, the dates mean something, and the external validators get the last word. The same habits I use for an API - one source of truth, generated artifacts, tests on the boundaries - turned out to apply to a portfolio's SEO just as well.
