Search engines render JavaScript imperfectly. Googlebot's Web Rendering Service queues your page after initial fetch, processes it days later (sometimes weeks), and may fail on slow scripts, infinite scroll, or scripts blocked by privacy settings. Other crawlers (Bing, social media unfurlers, AI bots) render JS less reliably. If critical content depends on JS execution, your indexing is fragile. The fix: server-render or prerender what crawlers need to see.
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://yoursite.com/page | grep -i "<title>"Raw HTML from the server. This is what Googlebot indexes BEFORE rendering. Title, meta description, structured data should be present here.
Common gaps between raw HTML and rendered HTML:
<div id="root"></div>)// pages/product/[id].js
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
return { props: { product } };
}
export default function ProductPage({ product }) {
return (
<>
<Head>
<title>{product.name} - Brand</title>
<meta name="description" content={product.description} />
</Head>
<h1>{product.name}</h1>
<p>{product.description}</p>
</>
);
}
Output HTML contains title, meta, and content from getServerSideProps. Googlebot indexes immediately.
<script setup>
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.id}`);
useHead({
title: () => `${product.value.name} - Brand`,
meta: [{ name: 'description', content: product.value.description }]
});
</script>
<template>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</template>
// +page.server.js
export async function load({ params }) {
return { product: await fetchProduct(params.id) };
}
// +page.svelte
<svelte:head>
<title>{data.product.name} - Brand</title>
<meta name="description" content={data.product.description} />
</svelte:head>
<h1>{data.product.name}</h1>
<p>{data.product.description}</p>
For content that doesn't change per user, prerender at build time. Faster than SSR, cheaper to host (just static HTML).
export async function getStaticPaths() {
const products = await fetchAllProducts();
return {
paths: products.map(p => ({ params: { id: p.id } })),
fallback: 'blocking'
};
}
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);
return { props: { product }, revalidate: 3600 };
}
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/', '/about', '/pricing']
}
}
});
---
// Astro file: pages/product/[id].astro
export async function getStaticPaths() {
const products = await fetchAllProducts();
return products.map(p => ({
params: { id: p.id },
props: { product: p }
}));
}
const { product } = Astro.props;
---
<html>
<head>
<title>{product.name}</title>
</head>
<body>
<h1>{product.name}</h1>
</body>
</html>
Hydration is when client JS attaches event handlers to server-rendered HTML. If client and server output differ, you get warnings and possibly broken interactivity.
// Mismatch: Date.now() differs between server (render time) and client (hydration time)
function Footer() {
return <p>Year: {new Date().getFullYear()}</p>;
}
// Fix: render on client only, or pass server value down
import { useEffect, useState } from 'react';
function Footer() {
const [year, setYear] = useState(null);
useEffect(() => setYear(new Date().getFullYear()), []);
if (!year) return null;
return <p>Year: {year}</p>;
}
// Mismatch: window/document not available on server
function Component() {
const isMobile = window.innerWidth < 768; // ReferenceError on server
return isMobile ? <Mobile /> : <Desktop />;
}
// Fix: check for typeof window OR use a hook
function Component() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return isMobile ? <Mobile /> : <Desktop />;
}
If you can't SSR or prerender, dynamic rendering serves prerendered HTML to crawlers and SPA to users. Rendertron, Prerender.io are common services.
// nginx — detect crawler User-Agent and proxy to prerender service
map $http_user_agent $is_crawler {
default 0;
~*Googlebot 1;
~*Bingbot 1;
~*facebookexternalhit 1;
}
server {
location / {
if ($is_crawler = 1) {
proxy_pass https://service.prerender.io;
}
try_files $uri /index.html;
}
}
Google permits this but warns against differences between crawler and user content. Prefer SSR or prerendering when possible.