Crawlers and link-preview bots judge a page by the HTML the server sends. Lampstand's PHP renderer edits the React shell's head per route, the editor enforces length and keyword standards in the field, and the one gap found was a check that measured the field, not the page.
The site looks modern. It loads quickly, it animates nicely, and it was built in React because that is how modern sites are built. Six months later it ranks for nothing, not even for the services it exists to sell. Someone shares an inner page in a WhatsApp group and the preview arrives as a bare link, or carries the homepage’s title on a page about something else entirely.
Nothing is broken, in the sense that nothing shows an error. The problem is who is reading. That is the core problem of single page application SEO: many of the readers that decide how a page is found never run your code, or run it late. A person opens the page in a browser, which downloads the application, runs it, and lets it write the title, the description and the content into the page. A crawler or a link-preview bot does less than that, sometimes much less. Whatever your application writes after it starts is only visible to a reader that starts it.
I dealt with this properly on Lampstand Edu Consult, an education consultancy that places Nigerian students at universities abroad. I built their public site and the CMS behind it: a React front end, a flat PHP API, SQLite, all running in Docker. The front end is exactly the kind of application that has this problem. What follows is what I built for the machines, what I built for the people who write the content, and the gap between the two that I found in my own work when I checked the live site against the admin.
A crawler judges the page by what arrives before your code runs
Every page request gets two answers, whether you designed it that way or not. The first is the HTML the server sends. The second is whatever that HTML turns into once the browser has fetched and run the JavaScript. For a traditional site those are nearly the same document. For a single-page application they can be completely different. The first answer is often an empty shell, a single container and a list of script tags, and everything a person would recognise as the page arrives in the second.
Google does run JavaScript. Googlebot renders the page and reads the result. What it does not do is behave like a visitor. It does not scroll, hover or press anything. It sees the start view and stops there. I have seen how much that matters on interactive tools: thousands of words a visitor can reach by clicking through, and a few dozen in the HTML the server sends. A page like that can wait in the search engine’s queue for months without being indexed, because what the crawler reads is almost empty. The content exists. It sits behind a click, and for a crawler a click never comes.
Rendering is also a second step, so something has to go right twice. The page has to be fetched, then rendered, and a title written by JavaScript only exists after the render has succeeded. I do not know the details of how Google schedules that work, and I would be wary of anyone outside Google who claims to. I do know that a title present in the first response cannot be lost to a step that has not happened yet.
Then there is everyone else. Other search engines, the fetchers that build link previews in messaging apps, the bots that make a card when someone pastes a link into a social post: many of these read the HTML and go no further. Assume the one you care about is among them. If the Open Graph tags that describe your page are written by React after it boots, a large share of the machines that decide what your link looks like will never see them.
Two views of the same page will tell you which one a bot gets
You can check your own site in five minutes.
Open a page, right-click on empty space and choose View page source. That is the first answer: exactly what the server sent, before any script ran. Now right-click on the page and choose Inspect. That is the second answer: the live document after your application has finished with it. Search the source for your page title and meta description. If they appear in Inspect but not in the source, or the source shows the same title on every page, then a bot that does not run JavaScript sees a site with one page and no description.
Then look at the body of the source. If it is a near-empty container followed by scripts, your content only exists for readers that execute code. Google may still find it. Plenty of other readers will not.
The second test is the one that tends to convince a business owner, because it shows them what their customers see. Take the address of an inner page, a service or a location rather than the homepage, and paste it into a WhatsApp chat with yourself, or into a draft post on LinkedIn. Look at the card that appears. It should show that page’s title, that page’s description and an image. If it shows the homepage’s title, or nothing but the bare address, that is what everyone sees whenever someone recommends you.
If you are comfortable with a terminal, curl -s followed by the address and piped into head -40 gives you the first answer without a browser in the way. That is how I check Lampstand, and it is the check that found the problem further down.
Test an address that does not exist, such as your domain followed by a made-up path. A server should answer with a 404. Many single-page applications answer 200 and serve the homepage instead, which tells a crawler that any mistyped link on the internet is a real page on your site. Lampstand did this too until September 2026: an unknown path returned 200 with the homepage title and a canonical tag pointing home. The canonical limited the damage, but the correct answer is a 404, and that is what it returns now.
Lampstand’s server writes the head before React ever loads
Lampstand’s answer is one PHP file, seo-render.php, which sits apart from the API. Any public request that is not a static file, an API call or the admin ends up there. It does not render React on the server. Rendering a whole React application server side means running JavaScript on the server, keeping two environments in step, and debugging mismatches between the markup the server produced and the markup the browser expects. That is a lot of machinery for a site whose pages change a few times a year.
The renderer does something much smaller. It takes the built HTML shell and edits it before sending it. The shell carries a pair of marker comments in its head, and the renderer replaces everything between them with the tags for the page being requested.
$block = "<!--SEO-->\n"
. ' <title>' . $e($title) . "</title>\n"
. ' <meta name="description" content="' . $e($desc) . "\" />\n"
. ' <link rel="canonical" href="' . $e($url) . "\" />\n"
. ' <meta property="og:type" content="website" />' . "\n"
. ' <meta property="og:site_name" content="Lampstand Edu Consult" />' . "\n"
. ' <meta property="og:title" content="' . $e($title) . "\" />\n"
. ' <meta property="og:description" content="' . $e($desc) . "\" />\n"
. ' <meta property="og:url" content="' . $e($url) . "\" />\n"
. ' <meta property="og:image" content="' . $e($img) . "\" />\n"
. ' <meta name="twitter:card" content="summary_large_image" />' . "\n"
. ' <meta name="twitter:title" content="' . $e($title) . "\" />\n"
. ' <meta name="twitter:description" content="' . $e($desc) . "\" />\n"
. ' <meta name="twitter:image" content="' . $e($img) . "\" />\n"
. ' <!--/SEO-->';
$start = strpos($html, '<!--SEO-->');
$end = strpos($html, '<!--/SEO-->');
if ($start !== false && $end !== false) {
$end += strlen('<!--/SEO-->');
$html = substr($html, 0, $start) . $block . substr($html, $end);
} Every value is escaped on the way out. The canonical is built from the path the server actually received. The Open Graph and Twitter tags reuse the same title and description variables as the search tags, so the three sets cannot drift apart. There is nothing clever in it, and that is the point: the head is a string the server assembles, and a string the server assembles is there for every reader, including the ones that never run a line of script.
You can see the result without trusting me. Fetch the Destinations page with curl and the first twenty lines contain a page-specific title, a description, a canonical, a full set of Open Graph and Twitter tags and a social image. Until September 2026 that image was the logo on every page. Each page now gets its own image, cut to the 1200 by 630 shape the preview cards expect, because a page-specific image makes a much better card.
The same file does two more jobs. It embeds the page’s published content, the same data the React app would otherwise request from the API, into the shell as an inert JSON block. React reads that on its first render, so the page paints without waiting for a second round trip. I count that as a speed measure, not an SEO one. The content is in the first response, but as data rather than as readable text, and I would not rely on any crawler treating a JSON block as the substance of a page.
It also writes the organisation’s structured data as JSON-LD: the name, the logo, the area served, the contact details, the address and the social profiles. Those come from settings the owner edits in the admin, so a change of phone number reaches every page’s structured data without anyone touching code. This part is fully connected: the field the owner edits is the value a crawler reads.
The renderer also marks each public page as cacheable by a CDN for a short window, and never marks a preview or anything an admin is looking at. Whether the CDN actually keeps the page depends on its own rules as well as on that header, so I check what it reports rather than what I asked for. A header is a request, not a result.
A standard written into the field gets followed, one written in a document does not
Printing the right tags is only half of it. The tags are only as good as the words someone types into them, and the person typing is not an SEO specialist. They run an education consultancy.
I work to a rule here, learned the slow way on another site: a documented rule that nothing checks will be violated. Not through carelessness. The site simply drifts, because nothing fails when it does. So Lampstand did not get a style guide for meta descriptions. The guidance went into the fields.
Every page in the admin has an SEO metadata panel beside its content. The meta title field has a meter underneath that fills as you type and turns green between 50 and 60 characters. The description meter does the same between 140 and 160. Below both sits a search preview: the page’s address, the title and the description, cut off where a results page would cut them, updating with every keystroke. An editor who writes a 90-character title watches it end in an ellipsis before they save. Nobody has to explain truncation to them. They can see it.
Those ranges are conventions, not limits published by a search engine. Results pages truncate by pixel width rather than character count, and Google sometimes rewrites a title or description when it decides it has a better one. The meter is a sensible target that keeps most listings intact. It does not promise how any single listing will look.
The blog editor goes further, because a post has a job a page does not: it is written to be found for something specific. It asks for a focus keyword, the phrase the post should rank for, and scores the post against it while you write.
// 2 in title
if (fk) checks.push({
id: "fk-in-title", level: title.includes(fk) ? "pass" : "warn",
label: title.includes(fk) ? "Focus keyword appears in the post title" : "Focus keyword does not appear in the post title"
});
// 3 in seoTitle
if (fk) checks.push({
id: "fk-in-seotitle", level: seoTitle.toLowerCase().includes(fk) ? "pass" : "warn",
label: seoTitle.toLowerCase().includes(fk) ? "Focus keyword in SEO title" : "Add focus keyword to the SEO title"
});
// 5 in slug
if (fk) checks.push({
id: "fk-in-slug", level: slug.split("-").join(" ").includes(fk) ? "pass" : "warn",
label: slug.split("-").join(" ").includes(fk) ? "Focus keyword appears in the URL slug" : "Focus keyword missing from the URL slug"
}); Note the slug check. A slug is written with hyphens and a keyword with spaces, so the check turns the hyphens back into spaces before it compares them. Without that, “uk-student-visa-nigeria” would never match “UK student visa Nigeria”, and the editor would be told to fix something that was already right. A check that cries wolf gets ignored, and then it is worse than no check.
There are fourteen checks in all. The keyword is tested against the title, the SEO title, the meta description, the slug and the opening of the post. Then come the title and description lengths, the body length, the number of subheadings, links, a cover image for social previews, tags, and the shape of the slug. Each check passes, warns or fails. Together they give a score out of a hundred, shown as a coloured ring at the top of the sidebar, and each failing line says in plain words what to change. A social preview card beside it shows the post as it would look when shared.
I should be precise about “enforced”, because the word is doing a lot of work. The checks advise. They do not block publishing, and that was deliberate. A hard gate on a keyword rule would one day stop the owner publishing an urgent visa update because the slug was phrased differently from the keyword. What the panel does is make the standard impossible to miss. It sits in the field the editor is already looking at, at the moment they make the decision.
My renderer was reading its own table, not the editor’s panel
This is the part I would rather not have found, and it is the reason I check the live page instead of trusting the code I remember writing.
When I compared the head that Lampstand’s server sent for the Destinations page with the SEO fields in the admin, they did not match. The editor’s title read “Study Abroad Destinations: UK, Canada, US, Ireland, Australia and More”. The served title read “Destinations | Lampstand Edu Consult”. The descriptions differed too. The editor’s words were in the response, inside the content block the renderer embeds for React. They were not in the head, which is the only place a crawler looks for them.
The cause was a table. The renderer carried its own list of titles and descriptions, one per route. The React app carried a matching list, so that the tab title stayed correct when a visitor moved between pages without a full load. The SEO panel, with its meters and its preview, saved to the page content. Nothing connected the panel to the head. That made three copies of the same fact, and the copy the editor controlled was the one no crawler read.
The dashboard hid it. Its content health panel counted pages that had a meta description, and it counted them from the content files, so it reported a healthy number for descriptions that never reached a search result. It measured the field, not the page.
Nothing showed up as an error. Every page had a unique, sensibly sized title and description, so a crawler saw a well-built site. The only person misled was the owner, who could edit a field, watch the preview change, save, and alter nothing a search engine would ever see.
The fix was small, which is typical of this kind of defect. The renderer already opened the page’s content file to embed it, so it now takes the title and description from that same file and falls back to its table only when the editor has left a field empty. The client side reads the same values from the embedded content. That gives one source with two readers, instead of three sources that happened to agree. It went live on 28 September 2026, and the served title for Destinations is now the one the editor wrote.
The lesson is not about React. It is that the same rule applies twice. A standard only holds if something checks it, and a check only means something if it measures the thing the outside world receives. My meters checked the field. My dashboard checked the field. Nothing checked the head, and the head is all a bot reads.
Check the page a stranger receives, not the field you filled in
If you are commissioning a React site, or you have inherited one that is not being found, these are the questions I would ask, in this order.
- Does the HTML the server sends contain a unique title, description and canonical for each page? Check with View page source or
curl, never with Inspect. Inspect shows you what your browser built, not what a bot received. - Where do those values come from? If the honest answer is a list in the code, the SEO fields in your admin are decoration, however good they look.
- Does a shared link to an inner page show that page? Paste one into a messaging app and look at the card.
- What does an address that does not exist return? It should be a 404, not your homepage with a 200.
- Is the content a person reads on first load present without a click? Anything behind a tab, an accordion or a stepper may as well not exist to search.
Most of them need five minutes and a browser. A renderer that edits the head of a shell is far smaller than moving a site to full server rendering, and for a site of this size it closes most of the gap.
The wider build, including the content model and the admin, is in the Lampstand case study.
If you only do one thing, open View page source on your most important page and read the title. It is the version of your site most machines will ever see.
