React Landing Pages: Half the HTML Isn't the Page
This storefront's home page prerenders to 436 KB, and 52.1% of that is a serialized copy of the render, not the page. What actually reaches a crawler before any JavaScript runs.
A React landing page's real question isn't which component library — it's what arrives in the HTML before any JavaScript runs. This storefront's home page prerenders to 436,105 bytes. Only 207,676 of those are the page's markup. The other 227,111 — 52.1% — are a serialized copy of the same render, inlined for React.
That split is the thing worth understanding before you pick anything else, because it decides whether your landing page has content for a crawler, a preview card, or a reader on a slow connection at the moment the request completes.
The three states a React landing page can ship in
"Built in React" describes three genuinely different artifacts. They differ in one measurable way: what a curl of the URL returns.
| How it's built | What the first response contains | Headline visible without JS |
|---|---|---|
| Client-only SPA (Vite, CRA) | An empty <div id="root"> plus script tags | No |
| Server-rendered per request | Full markup, generated on each hit | Yes |
| Prerendered at build time | Full markup, generated once at build | Yes |
The first row is what most "react landing page example" tutorials produce, because it's the default of every React starter that isn't a framework. It's the wrong default for a landing page specifically: a landing page's entire job is to be found, previewed, and read, and all three of those happen to consumers that do not run your JavaScript — crawlers, link unfurlers, and any reader whose bundle hasn't arrived yet.
The third row is what this page is. next build marks / as ○ (Static), and the 251 .html files under .next/server/app after a fresh build are the literal output.
The anatomy: ten components, one of them interactive
The home page is one template composing ten organisms:
// src/components/templates/HomeTemplate.tsx
<Header /> {/* "use client" — mobile drawer + session check */}
<main id="main">
<Hero />
<FeaturedTemplates />
<TechStack />
<Features />
<FeatureTabs />
<UseCases />
<Testimonials />
<Blog />
</main>
<Footer />
Eight of those render a <section>; the header and footer render their own landmarks. Exactly one of the ten — Header — is a Client Component, and it's a Client Component for two reasons that are both genuinely interactive: a mobile drawer with useState, and a session check that decides whether the nav says "Sign in" or "Dashboard".
The other nine are Server Components. They render once, at build time, and ship no component code to the browser at all. Three of them do reach a Client Component further down — FeaturedTemplates renders TemplateCard, UseCases renders UseCaseCard, Footer renders NewsletterForm — which is the ordinary way a mostly-static page ends up with a few interactive leaves. Where exactly that boundary should sit, and the three mistakes that push it too high, are covered in Server Components vs Client Components; this post is about what lands in the HTML regardless of where you put it.
What's in the HTML before hydration
Measured from .next/server/app/index.html in a fresh build, with every <script> element stripped:
| In the prerendered markup | Count |
|---|---|
| Words of visible text | 1,256 |
<a> elements | 94 |
<img> elements | 29 |
<section> elements | 8 |
<h1> / <h2> / <h3> | 1 / 20 / 37 |
Every one of those is present before a single byte of application JavaScript executes. The <h1> — "Premium Tailwind CSS admin templates with a real backend" — is in the first response. The 94 links are crawlable links, not onClick handlers that a crawler sees as inert <div>s. The 29 images have src attributes a preload scanner can start fetching immediately rather than URLs a component will compute later.
That list is the whole practical difference between the first and third rows of the table above, and it's why "which UI library" is a smaller decision than it looks. None of the numbers in that table change if you swap the component library. All of them go to zero if you ship a client-only SPA.
The half you didn't write
Here's the part that surprised us when we measured it. The file is 436,105 bytes, but the markup above accounts for only 207,676 of them. The remaining 228,429 bytes live in <script> tags — 108 of them — and 92 of those are a single repeated shape:
<script>self.__next_f.push([1,"..."])</script>
That's the React Server Components payload: a serialized description of the same tree the markup already contains, inlined so React can hydrate the client parts and, more importantly, so a client-side navigation away and back doesn't need a network round trip to reconstruct the page. It comes to 227,111 bytes, 52.1% of the file.
Compressed, the gap narrows but doesn't close:
| Raw | Gzipped | |
|---|---|---|
| Full HTML as served | 436,105 B | 87,896 B |
| Markup only, scripts stripped | 207,676 B | 39,579 B |
So the payload costs roughly 48 KB gzipped on this page. That is not a bug and it isn't Next.js being wasteful — it's the price of the RSC model, and you are buying something real with it. But it does mean that "my landing page's HTML is 436 KB" is a misleading sentence, and that the lever for shrinking it is not minifying your markup. The payload scales with how much content the server render produced, so the way to make it smaller is to put less on the page — which on a landing page is usually the right instinct anyway.
For comparison, our catalogue page measured 485 KB of HTML compressing to 34.7 KB gzipped for 111 product cards. Repetitive markup compresses beautifully; a payload of distinct strings does not.
Sections are data files, not JSX
The other structural decision worth copying is where the 1,256 words live. Not in the components. Each section reads a typed array from src/data/:
// src/components/organisms/UseCases.tsx (Server Component)
import { useCases } from "@/data/useCases";
export default function UseCases() {
return (
<section className="py-16 md:py-24">
<Container>
{useCases.map((u) => (
<UseCaseCard key={u.title} {...u} />
))}
</Container>
</section>
);
}
The organism owns the section shell and the mapping; the molecule owns one card; the data file owns every word. A copy change is a diff in one .tsx array, reviewable by someone who doesn't read JSX, and it never touches a component. The full layering rule — and the one constraint that makes it hold, that molecules never import runtime values from data files — is in Atomic Design in a Next.js codebase.
This matters for a landing page more than for most pages, because a landing page's copy is the thing you will change most often and the thing you're most likely to want to change without a developer.
What we'd tell you to check first
If you're building a landing page in React right now, the highest-value check takes ten seconds and needs no tooling:
curl -s https://your-site.example/ | grep -o '<h1[^>]*>[^<]*'
If that prints your headline, you're in the third row of the table. If it prints nothing, every crawler, preview card, and reader on a cold connection is seeing what it printed. Everything else in this post is a refinement of that one result.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Social preview cards show the site name and no description | The page's content — and often its <meta> tags — are produced after hydration, and unfurlers don't hydrate | Render the landing page on the server or at build time; verify with curl, not with the browser's Elements panel, which shows the post-hydration DOM |
| Search Console reports the page as indexed but the snippet is generic | Same root cause, one step later: the crawler rendered eventually but the first pass had nothing | Prerender; a build-time HTML file removes the render queue from the equation entirely |
| "My landing page HTML is enormous" after switching to the App Router | Over half of it is the inlined RSC payload, not markup | Measure the two separately before optimizing; strip <script> tags and re-measure, or the numbers will send you after the wrong thing |
| A single interactive widget makes the whole page a Client Component | "use client" was added at the top of the page or template rather than at the leaf that needs it | Push the directive down to the smallest component with state — here, one of ten organisms carries it |
| Hero image arrives late even though it's the first thing on screen | It's loading="lazy" like every other image, so the preload scanner skips it | Mark the first above-the-fold image priority; leaving it lazy cost us about 1.7s of load delay on the grid pages |
| Copy edits require a component diff and a code review | Text is hardcoded in JSX | Move each section's content into a typed data file and map over it |
Frequently asked questions
Can you build a landing page with React alone, without a framework? Yes, and it will render correctly for anyone who runs your JavaScript. The problem is the population that doesn't: crawlers on a first pass, link unfurlers, and readers during the seconds before your bundle parses. For a page whose only job is to be found and read, that's the wrong population to lose. If you're set on plain React, prerender the output to static HTML at build time — that's the capability the framework is providing, not the components.
How much of my page needs to be a Client Component? On this one, one component out of ten, plus three interactive leaves below them. That ratio isn't a target — it's what falls out of asking, per component, whether it has state, an event handler, or a browser API. Most landing-page sections have none of the three.
Is the inlined RSC payload something I can turn off? Not in the App Router — it's how client-side navigation and hydration work. What you can influence is its size, which tracks the amount of content the server rendered. A shorter page produces a smaller payload; there's no separate setting.
Does a static React landing page mean I can't have a form?
No. This page is fully prerendered and still carries a working newsletter signup in its footer, and the /contact page — also prerendered — carries a full contact form. A Server Action posts to the server without making the page that hosts it dynamic: the page stays a build-time HTML file, and only the submission touches a server.
Templates in this post
ASoc Rally is an AI-CRM marketing site with a pipeline preview and a three-tier pricing table — a good reference for how many distinct sections a real landing page carries before any of them need to hydrate. ASoc Reach, an AI-marketing agency page with services, achievements, projects and team blocks, is the content-heaviest shape in the set and the one where the data-file split pays for itself fastest. ASoc Relay is a messaging-platform SaaS site whose integrations and inbox sections are exactly the kind of content people reflexively build as client-side tabs and don't need to.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates. For the rendering-mode audit behind the ○ (Static) marker used here, see static rendering in the App Router.
