Skip to content

Server-Side Rendering

The single biggest SEO architecture decision: how is HTML delivered to the crawler?

StrategyWhen to useSEO impact
SSR (Server-Side Rendering)Dynamic, user-specific contentExcellent — crawler gets ready HTML
SSG (Static Site Generation)Rarely-changing content (blog, marketing)Excellent + fastest LCP
ISR (Incremental Static Regeneration)Many pages, occasionally updatedSSG with auto-refresh
CSR (Client-Side Rendering)App-like SPAsSEO-hostile — only when necessary
HybridMixed (marketing SSG, app CSR)OK if done right

Modern Googlebot uses Chromium and CAN render JavaScript — but with delay and cost:

  1. First-pass crawl: HTML only, ranked initially based on what’s in the initial HTML response
  2. Second-pass crawl (rendering queue): JavaScript is executed, additional content discovered, possibly weeks later

This means:

  • CSR-only sites have delayed indexing of dynamic content
  • Server-rendered content ranks faster than client-rendered content
  • Bing and other crawlers are less sophisticated than Google; CSR is more hostile to them

For SEO-critical pages (landing pages, blog posts, product pages), SSR or SSG is strongly preferred over CSR.

Node.js server runtime, supports SSR + ISR. Default for Nitrogen sites.

svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter({
out: 'build',
precompress: true // gzip + brotli at build
})
}
};

Pairs with Docker deployment, reverse proxy (Traefik/Nginx), HTTPS via Let’s Encrypt.

Pure SSG, no Node runtime needed. For small marketing sites with no dynamic content.

import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: true
})
}
};

Generates dist/ directory with static HTML files, deployable to any static host (Cloudflare Pages, GitHub Pages, Netlify, S3).

Auto-detects deploy platform (Vercel, Netlify, Cloudflare). NOT recommended for self-hosted Nitrogen projects — use explicit adapter-node.

SvelteKit lets you control rendering per page:

// +page.js or +page.server.js
export const prerender = true; // generate HTML at build time
export const ssr = true; // runtime SSR enabled (default)
export const csr = true; // client-side hydration enabled (default)

Common combinations:

export const prerender = true;

Page is rendered at build time, written as a static HTML file. Fastest possible delivery. Use for: landing pages, blog posts, marketing pages.

// no directives — default is ssr: true, csr: true

Page is rendered on each request. Required for dynamic data (user-specific content, real-time data).

export const prerender = true; // HTML at build time
// csr: true is default — page hydrates client-side

Page is prerendered but JavaScript hydrates for interactivity. Best of both worlds for marketing pages with interactive elements (carousels, forms).

export const prerender = true;
export const csr = false;

Page is prerendered AND has no client-side JavaScript. Pure HTML/CSS. Use for documentation pages, blog posts where interactivity isn’t needed.

Benefits:

  • Zero JavaScript download (smallest payload)
  • Perfect LCP (no JS parse/execute time)
  • Perfect INP (no event handlers to optimize)
  • Works without JavaScript enabled (accessibility benefit)
export const ssr = true;
export const csr = true;
// no prerender — rendered on each request

Used when content varies per request (user-specific dashboards, search results, real-time data).

The simplest test:

Terminal window
# Chrome DevTools → Cmd+Shift+P → "Disable JavaScript" → reload

Then view source. If the content you care about is there → SSR/SSG is working. If not, you’re shipping CSR-dependent content.

Search Console → URL Inspection → enter URL → "Test live URL" → View
"Crawled page" rendered HTML

Shows exactly what Googlebot sees after JavaScript execution (the second-pass crawl result).

Terminal window
curl -A "Googlebot" -L https://example.com/page > rendered.html

The -A flag sends Googlebot user-agent (useful if your server delivers different content per user-agent, which is now an anti-pattern but still exists).

Different SSR patterns produce different “time-to-content” for crawlers:

PatternFirst-pass HTMLTime to indexable
SSG (prerender)Full contentImmediate on crawl
SSRFull contentImmediate on crawl
SSR + hydrationFull contentImmediate on crawl
CSR (SPA)Empty shell + JSWait for render queue (days/weeks)

The “render queue” delay matters because content that gets indexed faster also accumulates link equity faster, ranks better, etc. SSR’s advantage is real.

Content loaded after page interaction (infinite scroll, “show more” buttons) is NOT in the initial HTML. Crawlers may or may not discover it.

Best practice: lazy-load content from a server-rendered “shell” — the first batch of items is in initial HTML, more loads on interaction. Crawlers see the first batch (which has the SEO value); users see the lazy-loaded extras.

{#if mounted}
<!-- This won't be in SSR output! -->
<div>Client-only content</div>
{/if}

If mounted is set in onMount, the SSR HTML doesn’t include this content. Crawlers see only the SSR’d portion. Use this knowledge to deliberately keep content out of the SSR HTML (e.g., personalization widgets).

const Component = await import('./Heavy.svelte');

Dynamic imports happen at runtime. The imported component’s content is NOT in the initial HTML. Use only for genuinely large, deferrable components.

”It looks like client rendering, but it’s SSR”

Section titled “”It looks like client rendering, but it’s SSR””

If your +page.svelte uses onMount to fetch data:

<script>
let data = $state(null);
onMount(async () => {
data = await fetch('/api/data').then(r => r.json());
});
</script>
{#if data}
<h1>{data.title}</h1>
{/if}

This runs client-side only. The SSR HTML contains no <h1> content. Crawlers see nothing.

Fix: use +page.server.js or +page.js load function:

+page.server.js
export async function load() {
const data = await fetchFromDB();
return { data };
}
<script>
let { data } = $props();
</script>
<h1>{data.title}</h1> <!-- now SSR'd -->
<p>Hello, {user.name}!</p>

If the page is SSR’d per request with user-specific content, you can’t cache it on CDN. This kills performance.

Solutions:

  1. Move personalization to client-side after initial render (gives up some UX)
  2. Use edge personalization with cache key including user segment
  3. Render generic page server-side, personalize parts client-side

If SSR output differs from client render, Svelte warns about hydration mismatch. Common cause: server-side rendering with current time, client-side rendering with different time.

Fix: ensure SSR and CSR use the same data. For time-dependent content, either show server time consistently or wait until after hydration to show client time.