Panduan SPA SEO

cara membuat single-halaman applications (React Router, Vue Router, Angular Router) dapat di-crawl dan dapat diindeks — app-shell dan soft-404 masalah, History API vs hash routing, per-route HTML via SSR/prerendering, per-route canonicals dan judul, dan sitemap generation untuk client-side routes.

Pertama kali diterbitkan: 3 Jul 2026 · Terakhir diperbarui: 3 Agu 2026 · Advanced
Bahasa

sebuah single-halaman application memuat one document dan swaps views dengan JavaScript alih-alih requesting sebuah baru halaman dari server. banyak SPAs implement itu dengan sebuah 'app shell' — server mengembalikan one URL's worth dari nyata HTML, sebuah near-empty shell, until JS renders setiap route — tetapi itu's sebuah umum implementation choice, not sebuah aturan setiap SPA mengikuti; periksa what sebuah direct permintaan untuk setiap route actually mengembalikan sebelum assuming ini. fix, where sebuah bare app-shell setup adalah masalah, memiliki two independent halves: addressability (History API, not hash/#! fragments, so setiap view memiliki sebuah nyata URL — sebuah browser soft navigation via History API perubahan URL dan UI tetapi doesn't itself buat baru server respons) dan konten availability (SSR, prerendering, atau sebuah meta-framework so setiap dari itu URLs dapat kembalikan unique HTML pada permintaan). pada top dari itu, verify setiap route's judul, canonical, dan robots state both pada direct entry dan setelah rendering, handle soft 404s (routers tend untuk pertahankan sebuah 200 status untuk 'tidak ditemukan' views — redirect untuk sebuah URL itu itself 404s, atau tambahkan sebuah rendered noindex, though sebuah initial noindex dapat cause rendering untuk menjadi skipped), dan pastikan setiap route di Anda sitemap actually resolves untuk nyata, dapat diindeks HTML — there's no special SPA sitemap format.

TL;DR — sebuah SPA’s core SEO risk isn’t “JavaScript” (terjemahan) “JavaScript” di abstract — ini adalah itu client-side routing perubahan what pengguna sees tanpa mengubah what server akan kembalikan. fix memiliki two independent halves people constantly conflate: addressability (History API, unique per-route URLs, no #! fragments) dan konten availability (SSR, prerendering/SSG, atau sebuah meta-framework). melakukan hanya pertama dan Anda get sebuah tidy sitemap dari URLs itu semua render yang sama shell. pada top dari both, setiap route perlu -nya own canonical/judul/deskripsi di rendered DOM, Anda memiliki untuk handle soft 404s itu pertahankan sebuah 200, dan setiap route di sitemap memiliki untuk independently resolve untuk nyata HTML.

What SPA SEO actually adalah

SPA adalah sebuah application architecture, not sebuah guaranteed SEO failure state. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: MDN: Single-page application server rendering, prerendering, atau carefully implemented client rendering dapat expose konten, tetapi none guarantees pengindeksan. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: Google: JavaScript SEO basics

ini adalah deep dive pada single-halaman applications specifically — client-side routing dengan React Router, Vue Router, atau Angular Router, where setelah pertama muat server tidak pernah mengirim lainnya full halaman. ini sits alongside broader JavaScript SEO guide (which covers parity, lazy-memuat, infinite scroll, dan JS rendering umumnya) dan per-framework write-ups untuk React, Next.js, Nuxt, Angular, Vue, Svelte, dan Astro. Here I’m hanya interested di routing layer dan what ini melakukan untuk crawling dan pengindeksan.

Why bare client-side-routed SPAs fail di SEO

One URL, one HTML respons — app-shell masalah

Google describes failure mode precisely: “Some JavaScript sites may use the app shell model where the initial HTML does not contain the actual content and Google needs to execute JavaScript before being able to see the actual page content.” (terjemahan) “beberapa JavaScript situs dapat gunakan app shell model where initial HTML melakukan not berisi actual konten dan Google perlu untuk execute JavaScript sebelum menjadi able untuk see actual halaman konten.” di sebuah bare SPA, “app shell” (terjemahan) “app shell” adalah semua server ever mengembalikan. Fetch /products fresh dan Anda get yang sama near-empty document Anda’d get untuk /about. konten hanya diverges setelah browser runs Anda JavaScript dan Anda router decides what untuk tampilkan.

itu’s whole masalah di one kalimat: client-side routing perubahan what pengguna sees tanpa mengubah what server akan kembalikan. Everything downstream — soft 404s, duplicate konten, missing judul — adalah sebuah symptom dari itu.

server tidak pernah sees which “page” (terjemahan) “halaman” adalah requested (why hash routing fails)

Older SPAs muat views dari URL fragments — example.com/#/products. Google’s exact kata: “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” (terjemahan) “sebuah SPA dapat gunakan URL fragments (misalnya undefined untuk memuat berbeda views.” ini fails untuk sebuah spesifik, mechanical alasan: browser tidak pernah kirim fragment (anything setelah #) untuk server di HTTP permintaan. server literally cannot know which “page” (terjemahan) “halaman” adalah requested, so ini dapat’t kembalikan berbeda konten atau sebuah berbeda kode status untuk ini. sebuah crawler itu fetches URL once gets identical HTML regardless dari fragment.

itu’s juga why Google formally deprecated -nya 2009 AJAX-crawling scheme back di October 2015: “In short: We are no longer recommending the AJAX crawling proposal we made back in 2009.” (terjemahan) “di pendek: kami adalah no longer recommending AJAX crawling proposal kami dibuat back di 2009.” old _escaped_fragment_ workaround let server pre-render fragment routes pada permintaan — tetapi ini patched sekitar routing masalah alih-alih fixing ini. Google’s own recommendation replacing ini adalah History API, covered below.

Soft 404s — client-side routers pertahankan sebuah 200 untuk everything

ini one adalah close untuk inevitable di sebuah bare SPA. Client-side routers, oleh design, pertahankan original halaman’s 200 status untuk setiap virtual navigation — including “not found” (terjemahan) “tidak ditemukan” states. Google flags ini explicitly: “In a single-page application (SPA), this can be especially difficult. To prevent error pages from being indexed, you can use one or both of the following strategies.” (terjemahan) “di sebuah single-halaman application (SPA), ini dapat menjadi terutama difficult. untuk mencegah halaman error dari menjadi terindeks, Anda dapat gunakan one atau both dari following strategies.” dan why: “When a SPA is using client-side JavaScript to handle errors they often report a 200 HTTP status code instead of the appropriate status code.” (terjemahan) “When sebuah SPA adalah menggunakan client-side JavaScript untuk handle errors mereka sering report sebuah undefined HTTP kode status alih-alih appropriate kode status.”

hasil adalah empty atau error views getting terindeks sebagai thin 200 halaman. Google documents exactly two strategies here, precisely: redirect (atau membuat sebuah full permintaan) untuk sebuah URL whose server mengembalikan sebuah nyata 404/error status, atau tambahkan sebuah noindex tag untuk error view dengan JavaScript. Watch kedua one: jika itu noindex adalah present dari very pertama paint alih-alih ditambahkan setelah app decides route adalah invalid, ini dapat cause Google untuk skip rendering halaman altogether — so verify what sebuah direct permintaan mengembalikan sebelum apa pun JS runs, not hanya what menampilkan up di rendered DOM afterward.

Evidence for this claim For a client-rendered not-found view, Google documents two approaches: redirect to a URL whose server returns a 404 response, or add noindex with JavaScript; an initial noindex can cause rendering to be skipped, so raw and rendered directives require separate testing. Scope: SPAs Confidence: high · Verified: Fix Search-related JavaScript problems

Shared-shell duplicates — sebuah mungkin cause, not sebuah automatic diagnosis

There’s sebuah kedua, sneakier failure mode: distinct routes get terindeks sebagai duplicates dari setiap lainnya because mereka render down untuk yang sama shared header/nav/footer boilerplate. Gary Illyes described one cara ini happens: “I have a bunch of emails in my inbox where the issue is that the centerpiece took forever to load, so rendering timed out (my most likely explanation) and we were left with a bunch of pages that only had the boilerplate. With only the boilerplate, those pages are dups.” (terjemahan) “I memiliki sebuah bunch dari emails di my inbox where issue adalah itu centerpiece took forever untuk muat, so rendering timed out (my sebagian besar mungkin explanation) dan kami adalah left dengan sebuah bunch dari halaman itu hanya memiliki boilerplate. dengan hanya boilerplate, itu halaman adalah dups.” His fix: “Try to restructure the js calls such that the content (including marginal boilerplate) loads first.” (terjemahan) “Try untuk restructure js panggilan such itu konten (including marginal boilerplate) memuat pertama.” (Relayed via sebuah LinkedIn post, which resists automated verification; treat sebagai tinggi-confidence secondary.)

Notice Illyes frames ini sebagai “my most likely explanation,” (terjemahan) “my sebagian besar mungkin explanation,” not sebuah confirmed diagnosis — itu’s worth taking literally. “Render timeout” (terjemahan) “Render timeout” isn’t something Anda dapat diagnose dari symptoms alone; Anda perlu permintaan-tingkat evidence (what sebuah direct fetch actually mengembalikan), rendered-output evidence (whether centerpiece konten adalah present setelah rendering), dan Search Console evidence (duplicate/pengindeksan signals) sebelum attributing sebuah route’s duplicate-konten masalah untuk sebuah timeout specifically, alih-alih untuk sebuah bug, sebuah blocked resource, atau sebuah genuinely identical shell. There’s juga no fixed, published timeout untuk design sekitar — Google’s own basics doc says sebuah halaman “may stay on this queue for a few seconds, but it can take longer than that,” (terjemahan) “dapat stay pada ini queue untuk sebuah few seconds, tetapi ini dapat take longer daripada itu,” without committing untuk sebuah angka. Don’t plan sekitar sebuah assumed render-queue duration; instead, pastikan primary konten memuat sebagai early sebagai mungkin regardless dari how panjang rendering ends up taking.

fix: get nyata HTML per route

full fix memiliki two independent bagian itu people conflate constantly:

  1. URL addressability — History API, unique per-route URLs, no fragments.
  2. konten availability — SSR, static prerendering/SSG, atau sebuah meta-framework.

melakukan hanya #1 dan Anda get clean, shareable URLs itu semua masih mengembalikan sama blank shell. untuk sebuah route Anda actually ingin terindeks, Anda perlu both.

itu’s not sebuah universal mandate untuk tambahkan SSR everywhere, though. SSR dan prerendering reduce how much Anda depend pada sebuah crawler successfully executing Anda JavaScript — mereka don’t become mandatory instant sebuah situs adalah technically sebuah SPA. sebelum picking sebuah architecture untuk sebuah given route, periksa three things: whether itu route perlu untuk peringkat di semua (sebuah internal admin panel doesn’t), what sebuah direct, JS-free permintaan untuk ini sudah mengembalikan (beberapa setups sudah kirim meaningful HTML), dan which crawler Anda actually perlu untuk satisfy (Google renders JS fairly reliably; Bing dan sebagian besar AI crawler less so — see below). CSR itu sudah passes itu memeriksa doesn’t perlu untuk become SSR hanya because situs adalah sebuah SPA.

rendering sisi server (SSR)

server runs Anda app untuk setiap permintaan dan mengembalikan fully-formed HTML untuk itu route, lalu client “hydrates” (terjemahan) “hydrates” ini ke sebuah live SPA. ini adalah paling robust option because crawler gets complete konten pada pertama fetch, no JS execution diperlukan.

Static prerendering / SSG

alih-alih rendering per permintaan, Anda bangun setiap route’s HTML ahead dari time di deploy. Perfect untuk konten itu doesn’t perubahan per pengguna. dari my own JavaScript SEO writing: apa pun jenis dari SSR, static rendering, dan prerendering setup adalah going untuk menjadi fine untuk search mesin — thing untuk hindari adalah leaving konten locked behind client-hanya rendering.

atau: don’t hand-roll ini — gunakan meta-framework

untuk sebuah baru bangun, honest recommendation adalah untuk not hand-roll React-Router-hanya atau Vue-Router-hanya client-side routing di semua. sebuah meta-framework — Next.js, Nuxt, Angular dengan -nya SSR package, SvelteKit, Remix — gives Anda SSR/SSG dan route-based metadata out dari box, which sidesteps ini entire category dari masalah. (setiap dari itu memiliki -nya own deep dive pada ini situs.) menjadi honest tentang flip side: retrofitting SSR onto sebuah existing hand-rolled SPA adalah nyata engineering berfungsi — sebuah migration project, not sebuah config toggle.

Dynamic rendering sebagai sebuah stopgap, not sebuah destination

Anda dapat sajikan crawler sebuah separately-rendered HTML snapshot (dynamic rendering). Both Google dan Bing accept ini — Bing “recommend[s] dynamic rendering as a great alternative for websites relying heavily on JavaScript” (terjemahan) “recommend[s] dynamic rendering sebagai sebuah great alternative untuk situs web relying heavily pada JavaScript” (relayed dari Bing’s 2018 blog; two pendek bingbot-capability quotes below adalah directly verified, ini longer one adalah not independently re-diperiksa) — tetapi Google adalah jelas ini adalah sebuah workaround: “Dynamic rendering was a workaround and not a long-term solution… Instead, we recommend that you use server-side rendering, static rendering, or hydration as a solution.” (terjemahan) “Dynamic rendering adalah sebuah workaround dan not sebuah panjang-istilah solusi… Instead, kami recommend itu Anda gunakan rendering sisi server, static rendering, atau hydration sebagai sebuah solusi.” (Relayed dari Google’s dynamic-rendering doc; wording matches what’s sudah cited di ini situs’s broader JavaScript SEO guide.) ini adalah sebuah bridge, not sebuah architecture.

History API vs. hash routing (#!)

Five states, not two

Testing sebuah SPA route gets confusing because “does it work” (terjemahan) “melakukan ini berfungsi” actually spans five berbeda, separately-verifiable states:

StateWhat ini adalah
server responsbytes sebuah fresh, JS-free HTTP permintaan untuk sebuah URL actually gets back.
Rendered DOMWhat sebuah browser (atau Googlebot’s renderer) membangun setelah executing JavaScript terhadap itu server respons.
Search processingHow Google separately melakukan crawl server respons, later renders halaman, dan indeks berdasarkan both.
browser full-document navigationsebuah actual baru HTTP permintaan untuk sebuah URL — satu-satunya state itu dapat perubahan server respons atau kode status.
browser soft navigationsebuah History API transition (pushState/replaceState) itu perubahan terlihat URL, browser history, dan pada-screen UI.

one everyone conflates: sebuah soft navigation perubahan URL dan UI, tetapi ini melakukan not oleh itself buat baru respons HTTP atau status — itu hanya happens pada sebuah full navigation (atau sebuah equivalent direct permintaan, like curl). Testing sebuah route oleh clicking melalui app dari homepage exercises soft navigation; testing ini oleh requesting URL directly exercises server respons. Both penting, dan mereka dapat disagree.

What History API gives Anda

Google’s recommendation adalah unambiguous: “We recommend using the History API to load different content based on the URL in a SPA.” (terjemahan) “kami recommend menggunakan History API untuk muat berbeda konten berdasarkan URL di sebuah SPA.” (di live doc “History API” (terjemahan) “History API” adalah sebuah tautan, so ini deep tautan targets lead-di clause.) History API (pushState/replaceState) lets Anda router perubahan terlihat URL untuk sebuah nyata, bookmarkable path — /products, not /#/products — without sebuah full reload. itu fixes addressability half: setiap view now memiliki sebuah URL sebuah server dapat respond untuk differently.

Why fragment/hash URLs adalah invisible untuk server — dan untuk Google

Because fragment tidak pernah reaches server (see above), History API adalah satu-satunya cara untuk give setiap route sebuah URL server dapat actually sajikan. Google puts sebuah floor di bawah ini di -nya basics doc: “don’t use fragments to load different page content. The following example is a bad practice, because Googlebot can’t reliably resolve the URLs.” (terjemahan) “don’t gunakan fragments untuk muat berbeda halaman konten. following contoh adalah sebuah buruk practice, because Googlebot dapat’t reliably resolve URLs.” ini adalah yang sama guidance I’ve written elsewhere — gunakan normal-looking URLs like /products, not hash URLs like /#/products, because Google dapat’t reliably indeks hash ones.

2015 deprecation, briefly

hash-bang (#!) era ended dengan Google’s 2015 post: “Times have changed. Today, as long as you’re not blocking Googlebot from crawling your JavaScript or CSS files, we are generally able to render and understand your web pages like modern browsers,” (terjemahan) “Times memiliki changed. Today, sebagai panjang sebagai Anda’re not blocking Googlebot dari crawling Anda JavaScript atau CSS files, kami adalah umumnya able untuk render dan memahami Anda halaman web like modern browser,” dan “you can use the History API pushState() to ensure accessibility for a wider range of browsers (and our systems).” (terjemahan) “Anda dapat gunakan History API pushState() untuk ensure accessibility untuk sebuah wider range dari browser (dan kami sistem).” One nuance worth keeping: Google didn’t instantly deindex old hash-bang situs — “we’ll generally crawl, render, and index the #! URLs” (terjemahan) “kami’ll umumnya crawl, render, dan indeks undefined URLs” — tetapi “we can still try” (terjemahan) “kami dapat masih try” adalah not “you should still do this.” (terjemahan) “Anda harus masih melakukan ini.”

membuat setiap route independently dapat diindeks

Clean URLs dan nyata HTML get Anda di-crawl. untuk get terindeks correctly, setiap route perlu -nya own signals.

Per-route tag canonical — dan “most restrictive directive” (terjemahan) “sebagian besar restrictive directive” trap

setiap route perlu -nya own rel=canonical di rendered DOM. trap: jika sebuah placeholder canonical (atau sebuah noindex) ships di raw HTML shell dan JavaScript adalah supposed untuk overwrite ini later, Anda dapat get sebuah conflict. Google resolves conflicts antara raw dan rendered versi oleh taking more restrictive signal — sebagai I’ve put ini sebelum, Google akan choose paling restrictive statements antara HTML dan rendered versi dari sebuah halaman. sebuah stray noindex atau wrong canonical baked ke shell dapat silently suppress whole route bahkan setelah Anda JS “fixes” (terjemahan) “fixes” ini.

Per-route judul dan deskripsi meta via JavaScript

Setting ini di JS adalah fine — Google says so directly: “You can use JavaScript to set or change the meta description as well as the <title> element.” (terjemahan) “Anda dapat gunakan JavaScript untuk set atau perubahan deskripsi meta serta undefined element.” requirement adalah itu mereka land di rendered DOM Google evaluates, not hanya flash briefly. Give setiap halaman -nya own judul dan deskripsi itu update when route perubahan.

nyata <a href> tautan antara routes

membuat Anda inter-route tautan nyata anchors dengan href attributes, not click handlers pada <div>s. Google discovers URLs oleh extracting hrefs; sebuah <div onClick> itu navigates via router adalah invisible untuk crawler’s tautan extraction. dan don’t lean pada client-side state untuk carry konten di seluruh itu navigations — Google’s renderer “does not retain state across page loads: Local Storage and Session Storage data are cleared across page loads. HTTP Cookies are cleared across page loads.” (terjemahan) “melakukan not retain state di seluruh halaman memuat: Local Storage dan Session Storage data adalah cleared di seluruh halaman memuat. HTTP Cookies adalah cleared di seluruh halaman memuat.”

Sitemap generation untuk SPA routes

There’s no special “SPA sitemap” (terjemahan) “SPA sitemap” format — ini adalah sebuah accounting masalah

sitemap protocol adalah unchanged untuk SPAs. actual berfungsi adalah membuat sure setiap route Anda list independently resolves untuk nyata, unique, rendered HTML. sebuah sitemap dari 500 client-side routes adalah worthless jika itu routes semua mengembalikan sama shell. So sitemap adalah downstream dari Anda rendering strategy, not sebuah substitute untuk ini.

Handling dynamic/parameterized routes (/product/:id)

untuk apps dengan parameterized routes, Anda dapat’t hand-maintain list. sitemap generator memiliki untuk run terhadap yang sama data source app menggunakan — sebuah bangun script atau sebuah server endpoint itu enumerates setiap id — so sitemap dan app tidak pernah drift apart. itu enumerated URLs adalah hanya worth listing once mereka’re SSR’d atau prerendered.

Keeping sitemap di sync dengan what’s server-resolvable

Regenerate sitemap sebagai bagian dari Anda bangun atau pada sebuah schedule tied untuk Anda konten source. sebuah route itu 404s (atau worse, soft-404s di 200) tetapi sits di Anda sitemap adalah sebuah crawl-budget dan quality signal Anda tidak ingin.

Testing what Google actually sees

Don’t spot-periksa one URL. whole poin dari SPA masalah adalah itu routes dapat differ di browser tetapi not pada wire, so test multiple routes di raw-HTML tingkat:

  • Fetch raw HTML per route dengan curl (sebuah Googlebot pengguna-agent where relevant) dan confirm konten adalah unique per URL, not shared shell.
  • pemeriksaan URL di Search Console — compare di-crawl/rendered HTML untuk several routes, dan confirm per-route judul, deskripsi, dan canonical adalah present.
  • Confirm error routes mengembalikan right signal — sebuah “not found” (terjemahan) “tidak ditemukan” view seharusnya either redirect untuk sebuah nyata error status atau carry noindex di rendered DOM.

umum myths tentang SPA SEO

  • “Google can’t index SPAs at all.” (terjemahan) “Google dapat’t indeks SPAs di semua.” Outdated. Google runs evergreen Chromium dan umumnya renders SPA konten. nyata risks adalah spesifik: soft 404s, hash routing, render timeouts, dan non-Google crawler (Bing less reliably, sebagian besar AI crawler not di semua).
  • “Adding the History API fixes SPA SEO.” (terjemahan) “menambahkan History API fixes SPA SEO.” ini fixes addressability hanya. jika server masih mengembalikan yang sama shell untuk setiap route, Google masih memiliki untuk execute JS untuk see anything.
  • “Hash routing still works as a fallback.” (terjemahan) “Hash routing masih berfungsi sebagai sebuah fallback.” Deprecated since 2015 dan recommended terhadap ever since.
  • “Client-side rendering is a ranking penalty.” (terjemahan) “rendering sisi klien adalah sebuah peringkat penalty.” No direct CSR penalty exists. damage adalah indirect — failed/delayed rendering, soft 404s, dan duplicate clustering dari render timeouts reduce what gets terindeks.
  • “Pre-rendering for bots is cloaking.” (terjemahan) “Pre-rendering untuk bot adalah cloaking.” Not when konten matches what pengguna eventually see — hanya when/where dari rendering differs, not konten.
  • “You need a special SPA sitemap generator.” (terjemahan) “Anda perlu sebuah special SPA sitemap generator.” No special format exists; berfungsi adalah membuat setiap listed route resolve untuk nyata HTML.

FAQs

dapat Google indeks sebuah single-halaman application? Yes, jika setiap route resolves untuk nyata, unique HTML (via SSR/prerendering) di sebuah nyata URL. Bare client-hanya SPAs sering don’t.

melakukan I perlu SSR, atau adalah rendering sisi klien ever okay? CSR dapat berfungsi untuk konten itu doesn’t perlu untuk peringkat, tetapi untuk anything Anda ingin terindeks reliably, prerender atau SSR ini — don’t bet pada renderer executing Anda JS di time.

adalah hash (#!) routing buruk untuk SEO? Yes — fragment tidak pernah reaches server, so Google dapat’t reliably resolve itu URLs. gunakan History API.

melakukan setiap route perlu -nya own tag canonical? Yes, di rendered DOM — dan membuat sure nothing more restrictive (sebuah stray noindex atau wrong canonical) ships di raw shell.

adalah sebuah prerendering service cloaking? No, disediakan konten disajikan untuk bot matches what pengguna see.

seharusnya I gunakan meta-framework alih-alih membangun routing myself? untuk sebuah baru bangun, biasanya yes — Next.js/Nuxt/Angular-SSR/SvelteKit give Anda SSR/SSG dan per-route metadata untuk free.

Why melakukan my SPA kembalikan 200 untuk halaman itu don’t exist? Because client-side router mempertahankan original 200 status untuk virtual navigations. Fix ini dengan sebuah JS redirect untuk sebuah nyata error status atau sebuah rendered noindex.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin a quote first.