Explain what a single page app is and how to make one SEO-friendly
TL;DR
A single page application (SPA) is a web application that loads a single HTML document and updates its content in the browser via JavaScript, rather than requesting a new page from the server on each navigation. This model provides application-like UX but presents challenges for search engine indexing because the initial HTML does not contain the rendered content.
In current practice, SPAs are made SEO-friendly by producing HTML on the server rather than relying solely on client-side rendering. The available strategies are server-side rendering (SSR), static site generation (SSG), incremental static regeneration (ISR), and streaming with React Server Components. Each strategy offers a different tradeoff between freshness, server cost, and time to first paint, and modern frameworks such as Next.js, Nuxt, Remix, SvelteKit, and Astro support selecting the strategy per route.
What is a single page app?
A SPA is a web application that loads a single HTML document and performs navigation and content updates on the client via JavaScript. The initial page load retrieves the HTML shell and application bundle; subsequent user actions update the DOM in place rather than triggering full-page navigations.
Key characteristics:
- A single HTML document is served on initial load; subsequent views are rendered client-side.
- The
fetchAPI (orXMLHttpRequest) is used to communicate with the server without full-page reloads. - A client-side router (for example,
react-routerorvue-router) maps URL changes to view transitions. - Application state is typically held in memory rather than stored per request.
Benefits:
- Smoother navigation after the initial load.
- Reduced server load, because subsequent navigations do not require rendering a new page.
- Application-like interaction patterns, such as preserved state across route transitions.
How search engines render JavaScript
The statement "Google cannot index JavaScript" is out of date. The practical situation is more nuanced:
- Googlebot executes JavaScript using an evergreen Chromium-based renderer. The bot fetches HTML first, and pages requiring JavaScript rendering are added to a separate render queue. The render queue has improved substantially since 2019, but indexing of JS-rendered content is typically slower than indexing of server-rendered HTML.
- Rendering is separate from crawling. This two-phase model means JS-rendered content can appear in the index later than its server-rendered counterpart, which is a disadvantage for time-sensitive content or highly competitive queries.
- Other search engines render JavaScript less reliably. Bing's JavaScript rendering is less consistent than Google's, and other engines such as Yandex and Baidu have more limited support. SSR or prerendering is therefore still relevant when non-Google traffic is important.
- Social and preview scrapers do not execute JavaScript. Clients that fetch OpenGraph and Twitter Card metadata — including Slack, LinkedIn, Discord, and Facebook — read the initial HTML only. Meta tags that are injected by client-side code are not visible to these clients.
For details, see Google's JavaScript SEO documentation and the Google Search Central documentation on rendering.
Rendering strategies
Modern frameworks allow different strategies to be used on different routes within the same application.
Server-side rendering (SSR)
The server renders the HTML for each request using the current application state. This produces indexable HTML on first response and reflects up-to-date data, at the cost of per-request server rendering.
Example with the Next.js App Router:
// app/products/[id]/page.tsx — a React Server Component, async by defaultexport default async function ProductPage({ params }) {const res = await fetch(`https://api.example.com/products/${params.id}`, {cache: 'no-store',});const product = await res.json();return (<div><h1>{product.title}</h1><p>{product.description}</p></div>);}
The App Router, introduced in Next.js 13, replaces the getServerSideProps data-fetching function of the Pages Router with async Server Components. cache: 'no-store' opts out of the framework's data cache to produce a fresh render on each request.
Static site generation (SSG)
HTML is produced at build time and served as static files. This is the cheapest option to host, produces the fastest first-byte response, and is suitable for content that does not change per request.
// app/blog/[slug]/page.tsxexport async function generateStaticParams() {const posts = await fetch('https://api.example.com/posts').then((r) =>r.json(),);return posts.map((p) => ({ slug: p.slug }));}export default async function Post({ params }) {const post = await fetch(`https://api.example.com/posts/${params.slug}`).then((r) => r.json(),);return <article>{post.body}</article>;}
Incremental static regeneration (ISR)
Pages are generated statically and cached, with a configurable revalidation interval. The cached HTML is served immediately; when the revalidation interval expires, the framework regenerates the page in the background on the next request. This combines SSG's serving cost with tunable freshness.
// app/categories/[slug]/page.tsxexport default async function Category({ params }) {const res = await fetch(`https://api.example.com/categories/${params.slug}`, {next: { revalidate: 3600 },});const category = await res.json();return <CategoryView data={category} />;}
React Server Components with streaming
React Server Components execute on the server and ship as a serialized tree rather than HTML. Combined with Suspense boundaries, the framework can stream the HTML shell early and defer slower sections until their data resolves. This is useful for pages with a fast critical section and slower below-the-fold content.
import { Suspense } from 'react';export default function Feed() {return (<><Header /><Suspense fallback={<FeedSkeleton />}><SlowFeed /></Suspense></>);}
Client-side rendering (CSR)
The HTML shell is served, and the full view is rendered by JavaScript in the browser. This remains appropriate for views that are not intended to be indexed, such as authenticated dashboards and internal tools, where SSR adds server cost without SEO benefit.
Choosing a strategy per route
The appropriate strategy depends on the route's data characteristics and indexing requirements. A typical allocation is:
| Use case | Recommended strategy | Rationale |
|---|---|---|
| E-commerce product page | ISR, short revalidation window | Prices and inventory change, but not per visit; cached HTML improves Largest Contentful Paint |
| Marketing site, documentation, blog | SSG | No per-request variability; suitable for CDN distribution |
| Dashboard behind authentication | CSR | Not indexed; SSR provides no SEO benefit and increases server cost |
| Personalized feed or homepage | RSC with streaming | Fast shell response; personalized content is streamed as it resolves |
| Search results page | SSR | Query-dependent output that should be indexable for long-tail queries |
| Real-time dashboard | CSR | Data changes more frequently than server HTML can be regenerated usefully |
| Breaking news article | SSR or short-window ISR | Freshness is important; SSR under traffic spikes, ISR otherwise |
Core Web Vitals comparison
The rendering strategy affects the metrics used by search ranking and perceived performance. The following table gives illustrative ranges for a content page with approximately 100 KB of data, measured on a slow mobile connection. Actual values depend on payload size, server region, and CDN configuration, and should be measured against the specific deployment.
| Strategy | TTFB | LCP | Notes |
|---|---|---|---|
| CSR | Low | High | Fast first byte (shell only); LCP blocked by JS download and API round trip |
| SSR | Moderate | Low | Slower first byte due to server rendering; content visible sooner |
| SSG | Low | Low | CDN-cached HTML; typically the fastest overall |
| ISR | Low | Low | Served from CDN cache; regenerated in background on revalidation |
| RSC streaming | Low | Low | Shell streams first; Suspense boundaries hydrate as data resolves |
Tools such as PageSpeed Insights and WebPageTest provide lab measurements against a specific URL.
Framework coverage
Several frameworks support the strategies above:
- Next.js (React) — App Router is the current default. Supports SSR, SSG, ISR, and React Server Components with streaming.
- Remix (React) — Emphasizes web standards and nested routing with loader and action functions. Merged with React Router in 2024.
- Nuxt (Vue) — Supports SSR, SSG, ISR (via the Nitro server), and hybrid rendering.
- SvelteKit (Svelte) — Adapter-based; deploys as SSR, SSG, or edge functions depending on configuration.
- Astro — Island architecture. Ships zero JavaScript by default and hydrates only the components marked as interactive. Well suited to content-heavy sites with limited interactivity.
- SolidStart (Solid) — Architecturally similar to SvelteKit with Solid's fine-grained reactivity.
General guidance for new projects:
- Content-heavy sites with limited interactivity: Astro.
- React applications with a mix of interactive and indexable routes: Next.js or Remix.
- Vue applications: Nuxt.
A framework with SSR or SSG support is preferable to pure client-side rendering when SEO is a requirement, even for applications that would otherwise be implemented as a traditional SPA.
Common misconceptions
- "SSR means no JavaScript on the client." SSR produces server-rendered HTML, but the client still downloads and hydrates the JavaScript bundle to attach event handlers. The bundle size is generally comparable to the CSR equivalent.
- "CSR is not SEO-friendly." Googlebot indexes content rendered by JavaScript. The practical concerns are latency, indexing reliability, and compatibility with non-Google engines and social scrapers. For high-competition queries these concerns are significant; for long-tail content they may be acceptable.
- "SSR should be used for everything." SSR has a per-request CPU cost. For content that does not vary per request, SSG is substantially cheaper to serve. Defaulting to SSR when SSG would suffice increases hosting cost without benefit.
- "Hydration is free." Hydration re-executes the component tree on the client to attach event handlers. Large hydration trees can affect Interaction to Next Paint (INP) and other interactivity metrics. React Server Components reduce hydration cost by allowing portions of the tree to remain server-only.