JavaScript SEO is the practice of ensuring search engines like Google, Bing, and other crawlers can access, render, and index material in single-page applications and JavaScript-driven websites. Unlike traditional HTML sites where information is present in the initial server response, SPAs rely on client-side rendering that executes in the browser, creating unique crawlability and indexation challenges that require deliberate technical SEO strategies to overcome.
Poor JavaScript SEO implementation leads to common problems: search engines cannot see content hidden behind JavaScript execution, internal links become invisible to crawlers, meta tags fail to appear in the rendered DOM, and structured data goes undetected. Fixing these issues requires choosing the right rendering strategy (SSR, SSG, or CSR), using the History API for clean URLs, implementing server-side meta tags and canonical tags, and testing every change with the search engine Search Console's URL Inspection Tool before deployment.
1. Choose the Right Rendering Strategy (CSR vs SSR vs SSG vs Hybrid vs Hydration)
The rendering strategy you choose determines whether search engines can see your material immediately or if they have to wait for JavaScript to execute. Client-side rendering failures on mobile can prevent search engines from seeing your content entirely. Most JavaScript search visibility problems stem from using the wrong rendering approach for your use case.
The Five Main Rendering Strategies
Client-Side Rendering (CSR): The browser downloads a minimal HTML shell, then JS builds the entire page. search engine can render CSR pages, but other search engines (Bing, Yandex, Baidu) have limited JavaScript support. CSR also causes render queue delays where search engine may take minutes to hours to render your pages.
Server-Side Rendering (SSR): The server generates complete HTML for each request. Search engines receive fully rendered information immediately. This works with all crawlers but requires server resources for every page view.
Static Site Generation (SSG): HTML is pre-built at deploy time for all pages. This gives you the fastest performance and best crawlability. Monitor script execution and Web Vitals performance to ensure your JS architecture delivers good user experience. Content must be relatively static or rebuilt on changes.
Incremental Static Regeneration (ISR) / Hybrid: Combines SSG with on-demand server rendering. Pages are statically generated but can be updated incrementally without full rebuilds. This is what Next.js calls ISR and what modern frameworks use to balance performance with fresh content.
Hydration: Starts with server-rendered HTML (like SSR), then JS "hydrates" the page to make it interactive. This gives you fast initial rendering for crawlers plus full interactivity for users. React Server Components, Next.js App Router, and modern frameworks use this approach.
How to Choose Your Rendering Strategy
Use this decision framework:
- Marketing pages, blog, documentation: SSG or ISR (fastest, best for SEO)
- E-commerce product pages: ISR or SSR (needs fresh data but should be crawlable)
- Dashboard, authenticated areas: CSR is fine (not meant for search engines)
- News sites, real-time content: SSR (needs immediate freshness)
- Large sites with mixed content: Hybrid approach (SSG for marketing, SSR for dynamic pages)
Check your current rendering strategy by viewing the page source (right-click, "View Page Source"). If you see minimal HTML with just script tags, you're using CSR. If you see complete information in the source, you're using SSR or SSG.
Modern frameworks make this easier. Next.js, Nuxt, SvelteKit, and Remix all support multiple rendering strategies in the same application. You can use SSG for your homepage and blog, SSR for product pages, and CSR for your dashboard—all in one codebase.
Migration Path from CSR to Server-Side Rendering
If you're currently using CSR and experiencing SEO issues, here's the migration path:
- Audit your current setup: Use Google Search Console's URL Inspection tool to see what Googlebot actually renders. Compare the "View crawled page" HTML with your source code.
- Identify critical content: Determine which documents must be immediately crawlable (homepage, category pages, product pages) vs. which can remain CSR (user dashboards, forms).
- Choose a framework: If using React, consider Next.js. If using Vue, consider Nuxt. These frameworks add SSR/SSG capabilities without requiring a complete rewrite.
- Implement incrementally: Start with your most important pages. Convert them to SSG or SSR while keeping less critical documents as CSR.
- Test with Google's tools: After each change, use the URL Inspection tool to verify Google can now see your content in the initial HTML response.
The key is that your most important SEO material (the information you want ranking in search results) must be in the initial HTML response or rendered very quickly. Don't rely on Google's rendering queue for critical pages.
2. Implement Server-Side Rendering for Critical Content
Once you've chosen your rendering strategy, you need to ensure your most important content is available in the initial HTML response. This is especially critical if you're using any form of client-side rendering.
What Content Must Be Server-Rendered
These elements must be in the initial HTML response for optimal SEO:
- Page title tag: This is the most important on-document SEO element. It must be in the initial HTML, not added by JavaScript.
- Meta description: While search engine can read JavaScript-rendered meta descriptions, having them in the initial HTML ensures all crawlers see them.
- Canonical tag: Must be in the initial HTML to avoid duplicate content issues.
- Main content (H1, paragraphs): Your primary information should be in the initial response. Don't hide it behind JavaScript interactions.
- Internal links: Navigation and content links should be in the initial HTML so Googlebot can discover them immediately.
- Structured data (JSON-LD): Schema markup should be in the initial response for rich results.
Implementation Examples by Framework
Next.js (React): Use the App Router with Server Components. All data fetching in Server Components is automatically server-rendered.
// app/page.jsx (Server Component by default)
export default async function ProductPage({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(res => res.json());
return (
<>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</>
);
}
Nuxt (Vue): Use the useAsyncData or useFetch composables for server-side data fetching.
// pages/products/[id].vue
<script setup>
const { data: product } = await useFetch(() => `/api/products/${useRoute().params.id}`);
</script>
<template>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</template>
Remix: Use loader functions which run on the server before rendering.
// app/routes/products.$id.jsx
export async function loader({ params }) {
const product = await fetchProduct(params.id);
return json({ product });
}
export default function ProductPage() {
const { product } = useLoaderData();
return <h1>{product.name}</h1>;
}
Dynamic Meta Tags with Server-Side Rendering
Each route needs unique meta tags. Here's how to implement them server-side:
Next.js Metadata API:
// app/products/[id]/page.jsx
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.id);
return {
title: `${product.name} - Buy Online | Your Store`,
description: product.metaDescription,
openGraph: {
title: product.name,
description: product.metaDescription,
images: [product.imageUrl],
},
};
}
Nuxt useHead:
// pages/products/[id].vue
<script setup>
const { data: product } = await useFetch(/* ... */);
useHead({
title: () => `${product.value.name} - Buy Online | Your Store`,
meta: [
{ name: 'description', content: () => product.value.metaDescription }
]
});
</script>
Testing Your Server-Side Rendering
After implementation, verify your information is in the initial HTML response:
- View Page Source: Right-click your route and select "View Page Source" (not "Inspect"). You should see your main content, title, meta description, and canonical tag in the HTML.
- the search engine Search Console URL Inspection: Enter your URL and click "View tested page." Compare the "Screenshot" with the "HTML" tab. Your critical information should be visible in both.
- Fetch as Googlebot: Use the "Test live URL" feature in GSC to see exactly what Googlebot sees when it crawls your page.
- cURL test: Run
curl https://yourdomain.com/pagein your terminal. The HTML response should contain your title, meta description, and main content.
If your critical content is missing from the initial HTML response, your server-side rendering is not working correctly. Check your framework's documentation for troubleshooting steps specific to your setup.
3. Configure Proper URL Structure (Avoid Hash Fragments, Use History API)
URL structure is critical for JavaScript applications. The wrong URL format can prevent search engines from crawling and indexing your documents correctly.
The Problem with Hash Fragments
Hash fragments (the part after # in a URL like example.com/#/products) are not sent to the server in HTTP requests. They're only used by the browser for client-side navigation.
This creates several search visibility problems:
- Google treats all hash URLs as the same page:
example.com/#/productsandexample.com/#/aboutboth point to the same HTML document on the server. - Other search engines can't crawl them at all: Bing, Yandex, and other crawlers may not execute JavaScript, so they see only the root page.
- Duplicate content issues: Multiple URLs with different hashes but identical server responses create duplicate material problems.
- Analytics tracking issues: Most analytics tools treat all hash URLs as the same URL unless specifically configured.
The Solution: Use the History API
Modern JavaScript frameworks use the HTML5 History API to create clean URLs without hash fragments. This makes your URLs look like traditional server-rendered pages.
Hash routing (bad for SEO):
example.com/#/productsexample.com/#/products/123example.com/#/about
History API routing (good for SEO):
example.com/productsexample.com/products/123example.com/about
Framework-Specific Configuration
React Router: Use BrowserRouter instead of HashRouter.
// Bad: Hash routing
import { HashRouter } from 'react-router-dom';
<HashRouter>
<Routes>...</Routes>
</HashRouter>
// Good: History API routing
import { BrowserRouter } from 'react-router-dom';
<BrowserRouter>
<Routes>...</Routes>
</BrowserRouter>
Vue Router: Set mode to 'history'.
import { createRouter, createWebHistory } from 'vue-router';
// Bad: Hash routing (default)
const router = createRouter({
routes
});
// Good: History mode
const router = createRouter({
history: createWebHistory(),
routes
});
Angular: Use PathLocationStrategy instead of HashLocationStrategy.
import { RouterModule } from '@angular/router';
// Bad: Hash routing
@NgModule({
imports: [RouterModule.forRoot(routes, { useHash: true })]
})
// Good: Path-based routing (default)
@NgModule({
imports: [RouterModule.forRoot(routes)]
})
Server Configuration for Clean URLs
When using the History API, you need to configure your server to serve your app's index.html file for all routes. Otherwise, direct navigation to example.com/products will return a 404 error.
Apache (.htaccess):
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
Nginx:
location / {
try_files $uri $uri/ /index.html;
}
Node.js (Express):
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
Next.js / Vercel: Handled automatically. Next.js uses file-based routing with clean URLs by default.
Handling Trailing Slashes
Decide whether your URLs should have trailing slashes and be consistent. Both example.com/products and example.com/products/ are valid, but you should pick one and redirect the other.
Recommended approach: Use trailing slashes for directories (category pages) and no trailing slashes for specific URLs (product pages).
example.com/products/(category)example.com/products/123(specific product)
Configure your framework and server to enforce this consistently. In Next.js, set trailingSlash: true in next.config.js if you want trailing slashes. In Nuxt, configure it in nuxt.config.ts.
Set canonical tags to your preferred version to avoid duplicate content issues if both versions are accessible.
4. Handle Routing & Navigation Correctly (Framework-Specific Routers)
Proper routing is essential for JS SEO. Your router determines how URLs map to content, and incorrect routing can prevent search engines from discovering all your pages.
File-Based Routing vs. Configuration-Based Routing
File-based routing (Next.js, Nuxt, SvelteKit): Routes are automatically generated based on your file structure. This is generally easier to manage and less error-prone.
// Next.js App Router structure
app/
├── page.jsx → /
├── about/
│ └── page.jsx → /about
├── products/
│ ├── page.jsx → /products
│ └── [id]/
│ └── page.jsx → /products/123
└── blog/
├── page.jsx → /blog
└── [slug]/
└── page.jsx → /blog/my-post
Configuration-based routing (React Router, Vue Router): You manually define routes in a configuration file. This gives you more control but requires more maintenance.
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/products', component: Products },
{ path: '/products/:id', component: ProductDetail },
{ path: '/blog', component: Blog },
{ path: '/blog/:slug', component: BlogPost }
];
Dynamic Routes and Parameter Handling
Dynamic routes (like product documents or blog posts) need special attention for SEO.
Next.js dynamic route:
// app/products/[id]/page.jsx
export async function generateStaticParams() {
const products = await fetchAllProducts();
return products.map(product => ({ id: product.id }));
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
return <h1>{product.name}</h1>;
}
The generateStaticParams function tells Next.js which product IDs to pre-render at build time. This is crucial for SSG. Without it, Next.js won't know which routes to generate.
Nuxt dynamic route:
// pages/products/[id].vue
<script setup>
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.id}`);
</script>
Nested Routes and Layouts
Nested routes allow you to create hierarchical URL structures that match your material organization.
Next.js nested routes:
// app/dashboard/settings/page.jsx
export default function SettingsPage() {
return <h1>Settings</h1>;
}
// This creates the URL: /dashboard/settings
React Router nested routes:
const routes = [
{
path: '/dashboard',
component: Dashboard,
children: [
{ path: 'settings', component: Settings },
{ path: 'profile', component: Profile }
]
}
];
Nested routes are SEO-friendly as long as each route has unique, meaningful material and proper meta tags.
404 Handling for Unknown Routes
Your router must return proper 404 status codes for unknown URLs. A common mistake is returning a 200 OK status with a "page not found" message, which creates soft 404 errors.
Next.js: Create an app/not-found.jsx file.
// app/not-found.jsx
export default function NotFound() {
return (
<>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
</>
);
}
Next.js automatically returns a 404 status code for this page.
React Router: You need to handle this manually.
import { Routes, Route, useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
}
// You also need to configure your server to return 404 status for unknown routes
Test your 404 handling by navigating to a URL that doesn't exist (like example.com/this-page-does-not-exist). Use browser developer tools to check the Network tab and verify the response status is 404, not 200.
Redirects and URL Normalization
Handle common URL variations with redirects to avoid duplicate content:
- HTTP to HTTPS: Redirect all HTTP requests to HTTPS.
- WWW to non-WWW (or vice versa): Pick one and redirect the other.
- Trailing slash normalization: Redirect
/productsto/products/(or the reverse) consistently. - Uppercase to lowercase: Redirect
/Productsto/productsif you use lowercase URLs.
Configure these redirects at the server level (Apache, Nginx, Vercel, Netlify) rather than in your JS code. Server-side redirects are faster and more reliable than client-side redirects.
Testing Your Routing Setup
After configuring your router, test it thoroughly:
- Direct navigation: Type each URL directly into the browser address bar and press Enter. Verify the URL loads correctly without JavaScript errors.
- Browser back/forward: Navigate through your site, then use the browser's back and forward buttons. Verify the correct documents load.
- Check status codes: Use browser developer tools to verify that valid pages return 200 OK and invalid URLs return 404 Not Found.
- Crawl your site: Use a crawler like Screaming Frog or Sitebulb to verify all your routes are discoverable and properly linked.
- Check Google Search Console: Monitor the Coverage report for crawl errors related to routing issues.
Proper routing ensures that search engines can discover all your documents and that users can navigate your site without errors. This is foundational for JavaScript search visibility success.
5. Allow Crawling of JavaScript & CSS Files (robots.txt, Resource Blocking)
Google needs to access your JavaScript and CSS files to render your pages correctly. If you block these resources in your robots.txt file, Google cannot see your material as users see it, which can hurt your rankings.
SPA-Specific Resource Blocking Issues
JavaScript applications often serve files from hashed paths like /static/js/main.a1b2c3.js or /assets/chunk-xyz.css. If your robots.txt uses broad patterns like Disallow: /static/, you're blocking all these critical resources.
User-agent: *
Disallow: /static/js/
Disallow: /static/css/
This is one of the most common JavaScript search visibility mistakes — blocking your own JavaScript files while wondering why Google can't see your content.
Verifying Your Resources Are Accessible
Use Chrome DevTools to simulate blocked resources and see what Google sees:
- Open Chrome DevTools (F12) → Network tab.
- Reload your page.
- Right-click your main JavaScript file → "Block request URL" or "Block request domain."
- Reload the page.
If your document looks broken or missing content, search engine experiences the same problem. Fix your robots.txt immediately to allow JS and CSS access.
Third-Party Resource Considerations for SPAs
If your SPA loads JS from a CDN or subdomain (static.example.com, cdn.example.com), verify that subdomain's robots.txt also allows Googlebot access. CDNs like Cloudflare, AWS CloudFront, and Fastly allow all resources by default, but custom configurations can block crawlers.
Also check that cache-busting query parameters on your JS files (like main.js?v=123) aren't blocked by wildcard patterns in robots.txt such as Disallow: /*?*.
6. Use Standard HTML Anchor Tags for Links (Avoid javascript:void, onclick)
How you implement links in your JavaScript application directly affects whether search engines can discover and follow them. Using non-standard link implementations can prevent crawlers from finding your pages.
The Importance of Standard HTML Links
Search engines discover new routes by following links. When Google crawls your site, it looks for standard HTML anchor tags (<a href="...">) to find links to other pages.
Standard HTML links are:
- Crawlable: All search engines can follow them, even those that don't execute JavaScript.
- Fast: Google doesn't need to render your route to discover the links.
- Reliable: They work consistently across all crawlers and tools.
- Accessible: They work for users with JS disabled or with assistive technologies.
Good Link Implementations
Standard HTML link (best):
<a href="/products/123">View Product</a>
This is the gold standard. search engine can immediately see and follow this link without rendering any JavaScript.
Link with JavaScript enhancement (acceptable):
<a href="/products/123" onclick="handleClick(event)">View Product</a>
This is acceptable because the href attribute provides a fallback for crawlers and users without JavaScript. The onclick handler adds interactivity for JavaScript users.
React Router Link component:
import { Link } from 'react-router-dom';
<Link to="/products/123">View Product</Link>
React Router's Link component renders a standard <a> tag with the correct href attribute. It also adds client-side navigation for faster page transitions.
Next.js Link component:
import Link from 'next/link';
<Link href="/products/123">View Product</Link>
Next.js Link also renders a standard <a> tag and adds prefetching for faster navigation.
Bad Link Implementations to Avoid
javascript: protocol (bad):
<a href="javascript:void(0)">View Product</a>
<a href="javascript:navigateTo('/products/123')">View Product</a>
These links have no crawlable destination. search engine cannot follow them because they don't point to a URL. They're essentially dead links for SEO purposes.
Empty href with click handler (bad):
<a href="#" onclick="navigateTo('/products/123')">View Product</a>
The href="#" points to the top of the current page, not to the product page. the search engine will follow the href attribute, which leads nowhere useful. The onclick handler is ignored by most crawlers.
Button without link (bad):
<button onclick="navigateTo('/products/123')">View Product</button>
Buttons are not links. search engine may or may not follow click handlers on buttons, depending on the implementation. This is unreliable for SEO.
div or span with click handler (bad):
<div onclick="navigateTo('/products/123')">View Product</div>
<span onclick="navigateTo('/products/123')">View Product</span>
These are not semantic HTML elements for navigation. the search engine is unlikely to follow these, and they're also inaccessible for users with disabilities.
Fixing Non-Standard Links
If your site uses non-standard link implementations, here's how to fix them:
Replace javascript: links with proper href:
// Before (bad)
<a href="javascript:void(0)" onclick="goToProduct(123)">View Product</a>
// After (good)
<a href="/products/123" onclick="goToProduct(event, 123)">View Product</a>
// In your JavaScript
function goToProduct(event, productId) {
event.preventDefault(); // Prevent default link behavior
router.push(`/products/${productId}`); // Client-side navigation
}
Replace buttons with links:
// Before (bad)
<button onclick="navigateTo('/products/123')">View Product</button>
// After (good)
<a href="/products/123" class="button-style">View Product</a>
You can style links to look like buttons using CSS, so there's no UX reason to use buttons for navigation.
Use framework link components:
If you're using React, Vue, Angular, or another framework, use the framework's link component. These components render proper <a> tags with the correct href attributes while also providing client-side navigation.
Testing Your Links
After fixing your links, verify they're crawlable:
- View Page Source: Right-click your route and select "View Page Source." Search for your links and verify they have proper
hrefattributes. - Disable JavaScript: In Chrome DevTools, go to Settings > Preferences > Debugger > Disable JavaScript. Reload your page and click your links. They should still navigate to the correct documents (though without client-side speed improvements).
- Use a crawler: Run Screaming Frog or Sitebulb with JavaScript rendering disabled. Verify that the crawler can discover all your pages by following links.
- Check the search engine Search Console: Navigate to Pages > Coverage and look for URLs that should be indexed but aren't. This may indicate that Googlebot cannot discover links to those pages.
Internal Linking Best Practices for JavaScript Sites
Beyond using standard HTML links, follow these internal linking best practices:
- Use descriptive anchor text: Instead of "Click here," use "View Product Details" or "Learn More About SEO."
- Link to important routes from multiple locations: Your homepage, category pages, and related content should all link to your most important pages.
- Avoid orphan pages: Every page should have at least one internal link pointing to it. Orphan URLs (URLs with no internal links) are difficult for search engine to discover.
- Use a logical site hierarchy: Organize your data in a clear hierarchy with category URLs linking to subcategory URLs and product pages.
- Limit the number of links per page: While there's no hard limit, having hundreds of links on a route can dilute link equity and make it harder for Googlebot to prioritize which routes are most important.
External Links and nofollow
For external links (links to other domains), use the rel attribute appropriately:
rel="nofollow": For links you don't want to pass PageRank to (user-generated content, paid links).rel="sponsored": For paid or sponsored links.rel="ugc": For user-generated information (comments, forum posts).- No rel attribute: For editorial links you want to pass PageRank to.
Example:
<a href="https://example.com" rel="nofollow">External Link</a>
<a href="https://partner.com" rel="sponsored">Sponsored Link</a>
<a href="https://blog.com/author/john">Author Profile</a>
Proper link implementation ensures that both search engines and users can navigate your site effectively. This is fundamental to JS SEO success.
7. Implement Proper Status Codes & Error Handling (404s, Soft 404s)
HTTP status codes tell search engines whether a document exists, has moved, or is permanently gone. Incorrect status codes can prevent URLs from being indexed or cause Google to waste crawl budget on non-existent pages.
Understanding HTTP Status Codes
The most important status codes for SEO are:
- 200 OK: The page exists and should be indexed. This is the status code for all your valid pages.
- 301 Moved Permanently: The page has permanently moved to a new URL. Google transfers ranking signals to the new URL and indexes the new URL instead of the old one.
- 302 Found (Temporary Redirect): The page has temporarily moved. Google keeps the old URL indexed but may follow the redirect. Use this sparingly; 301 is usually better for SEO.
- 404 Not Found: The URL does not exist. Google removes the document from its index and stops crawling it. This is the correct status for deleted pages.
- 410 Gone: The page is permanently gone and will not return. Similar to 404 but signals that the removal is intentional. search engine may remove 410 documents from its index faster than 404 pages.
- 500 Internal Server Error: The server encountered an error. the search engine will retry crawling the page later. Too many 500 errors can cause Googlebot to reduce its crawl rate.
- 503 Service Unavailable: The server is temporarily unavailable (maintenance or overload). Googlebot will retry later.
The Soft 404 Problem in JavaScript Applications
A soft 404 occurs when a document displays a "route not found" message to users but returns a 200 OK status code instead of a 404 status code.
This is a common problem in JavaScript applications because client-side routers often don't have access to HTTP status codes. The server returns 200 OK for all requests, and the JavaScript router decides whether to show a 404 route based on the URL.
Example of a soft 404:
// User navigates to /products/999 (product doesn't exist)
// Server returns: HTTP/1.1 200 OK
// JavaScript router shows: "Product not found" page
// Status code: 200 (wrong! should be 404)
search engine sees the 200 status code and assumes the document exists, so it indexes the "product not found" page. This wastes crawl budget and creates a poor user experience if the route appears in search results.
Fixing Soft 404s in JavaScript Applications
Solution 1: Server-side 404 handling (best for SSR/SSG)
If you're using server-side rendering or static site generation, you can return proper 404 status codes from the server.
Next.js:
// app/products/[id]/page.jsx
import { notFound } from 'next/navigation';
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
if (!product) {
notFound(); // Returns 404 status code
}
return <h1>{product.name}</h1>;
}
Nuxt:
// pages/products/[id].vue
<script setup>
const route = useRoute();
const { data: product, error } = await useFetch(`/api/products/${route.params.id}`);
if (error.value) {
throw createError({ statusCode: 404, statusMessage: 'Product Not Found' });
}
</script>
Solution 2: Redirect to a 404 route (for CSR)
If you're using client-side rendering and cannot control the server's status codes, redirect to a dedicated 404 page that returns a 404 status code from the server.
// In your client-side router
if (!productExists(productId)) {
window.location.href = '/404'; // Redirect to 404 page
}
// On the server, /404 returns a 404 status code
// In your Express server:
app.get('/404', (req, res) => {
res.status(404).sendFile('404.html');
});
This approach works, but it's not ideal because it requires a full page reload. A better solution is to use server-side rendering or a hybrid approach where your server can return proper status codes.
Handling Redirects Correctly
Redirects are necessary when URLs change, but they must be implemented correctly to preserve SEO value.
Server-side redirects (preferred):
Server-side redirects are faster and more reliable than client-side redirects. Configure them in your server or CDN.
Apache (.htaccess):
# 301 redirect from old URL to new URL
Redirect 301 /old-page https://example.com/new-page
# Redirect all HTTP to HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
Nginx:
# 301 redirect
server {
listen 80;
server_name example.com;
return 301 https://example.com$request_uri;
}
# Specific page redirect
location = /old-page {
return 301 /new-page;
}
Client-side redirects (use with caution):
If you must use client-side redirects (for example, in a purely client-rendered application), use window.location.href or your framework's router.
// React Router
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
function OldPage() {
const navigate = useNavigate();
useEffect(() => {
navigate('/new-page', { replace: true });
}, [navigate]);
return null;
}
// Or use meta refresh (not ideal)
<meta http-equiv="refresh" content="0;url=https://example.com/new-page" />
Client-side redirects require JS to execute, which means the search engine must render your route to see the redirect. This wastes crawl budget and can delay indexing of the target page. Always prefer server-side redirects when possible.
Redirect Chains and Loops
A redirect chain occurs when URL A redirects to URL B, which redirects to URL C, and so on. Redirect chains waste crawl budget and slow down page loads.
Example of a redirect chain:
http://example.com/old-page
→ 301 redirect to https://example.com/old-page
→ 301 redirect to https://www.example.com/old-page
→ 301 redirect to https://www.example.com/new-page
Fix redirect chains by updating the original redirect to point directly to the final destination:
http://example.com/old-page
→ 301 redirect to https://www.example.com/new-page
Redirect loops occur when URL A redirects to URL B, and URL B redirects back to URL A. This creates an infinite loop that browsers and crawlers cannot resolve.
Example of a redirect loop:
https://example.com/page-a → 301 to https://example.com/page-b
https://example.com/page-b → 301 to https://example.com/page-a
Fix redirect loops by removing one of the redirects or ensuring they point to different destinations.
Testing Status Codes and Redirects
Use these methods to verify your status codes and redirects are correct:
cURL:
# Check status code
curl -I https://example.com/page
# Follow redirects and show all status codes
curl -IL https://example.com/old-page
Browser Developer Tools:
- Open Chrome DevTools (F12).
- Go to the Network tab.
- Navigate to the page.
- Click on the first request (the document request).
- Check the "Status" column for the status code.
- If there's a redirect, you'll see multiple requests. Check each one.
Online tools:
- httpstatus.io: Check status codes for multiple URLs at once.
- Redirect Checker (various): Shows the full redirect chain for a URL.
- Screaming Frog: Crawls your site and shows status codes for all pages.
Google Search Console:
Check the Coverage report for routes with status code issues:
- "Page with redirect": Pages that redirect to other pages. Verify these redirects are intentional.
- "Not found (404)": Pages that return 404 errors. Update or remove internal links to these pages.
- "Server error (5xx)": Pages with server errors. Fix the underlying server issues.
Status Code Best Practices
- Use 301 for permanent redirects: When a document permanently moves to a new URL, use a 301 redirect to preserve search visibility value.
- Use 302 sparingly: Temporary redirects should only be used when you plan to restore the original URL. For most cases, 301 is better.
- Use 404 for deleted pages: When you delete a page, let it return a 404 status code. Don't redirect deleted pages to the homepage; this creates soft 404s.
- Use 410 for intentionally removed content: If you want to signal that content is permanently gone (like expired products), use 410 instead of 404.
- Avoid redirect chains: Update redirects to point directly to the final destination.
- Monitor 5xx errors: Server errors can hurt your site's crawl rate and indexing. Fix them quickly.
8. Generate Dynamic XML Sitemaps (Framework-Specific Solutions)
XML sitemaps help search engines discover all the URLs on your site. For JavaScript applications, generating sitemaps requires special consideration because your URLs may be dynamically generated or depend on data that changes frequently.
Why JavaScript Sites Need Sitemaps
JavaScript sites often have complex URL structures with dynamic routes, filter parameters, and pagination. Without a sitemap, search engines may struggle to discover all your pages, especially:
- Deeply nested pages: Pages that are many clicks away from the homepage.
- Recently added pages: New content that hasn't been linked from other pages yet.
- Filter and sort variations: E-commerce sites with thousands of product variations.
- Paginated content: Blog archives or product listings with many pages.
A sitemap provides a complete list of all your important pages, making it easier for search engines to find and index them.
Static vs. Dynamic Sitemaps
Static sitemaps: Pre-generated XML files that are updated manually or during your build process. These work well for sites with relatively stable content.
Dynamic sitemaps: Generated on-the-fly by your server or framework. These are necessary for sites with frequently changing information (e-commerce, news sites, user-generated content).
For most JS applications, you'll need dynamic sitemaps because your information changes more frequently than your build process runs.
Framework-Specific Sitemap Solutions
Next.js:
Next.js 13+ has built-in sitemap support using the sitemap.ts file.
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com';
// Static pages
const staticPages = [
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8 },
{ url: `${baseUrl}/contact`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.5 },
];
// Dynamic product pages
const products = await fetchAllProducts();
const productPages = products.map((product) => ({
url: `${baseUrl}/products/${product.slug}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'weekly',
priority: 0.9,
}));
// Dynamic blog pages
const blogPosts = await fetchAllBlogPosts();
const blogPages = blogPosts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.publishedAt),
changeFrequency: 'monthly',
priority: 0.7,
}));
return [...staticPages, ...productPages, ...blogPages];
}
This generates a sitemap at https://example.com/sitemap.xml that includes all your static and dynamic pages.
Nuxt:
Use the @nuxtjs/sitemap module for dynamic sitemaps.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
sitemap: {
sources: [
'/api/__sitemap__/urls'
]
}
});
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#imports';
export default defineSitemapEventHandler(async () => {
const products = await fetchAllProducts();
return products.map((product) => ({
loc: `/products/${product.slug}`,
lastmod: product.updatedAt,
changefreq: 'weekly',
priority: 0.9,
}));
});
React (Create React App, Vite, etc.):
For client-rendered React apps, generate your sitemap on the server or use a build-time solution.
Option 1: Server-side sitemap generation (Express):
// server.js
const express = require('express');
const { SitemapStream, streamToPromise } = require('sitemap');
const app = express();
app.get('/sitemap.xml', async (req, res) => {
const smStream = new SitemapStream({ hostname: 'https://example.com' });
// Add static pages
smStream.write({ url: '/', changefreq: 'daily', priority: 1.0 });
smStream.write({ url: '/about', changefreq: 'monthly', priority: 0.8 });
// Add dynamic pages
const products = await fetchAllProducts();
products.forEach(product => {
smStream.write({
url: `/products/${product.slug}`,
lastmod: product.updatedAt,
changefreq: 'weekly',
priority: 0.9
});
});
smStream.end();
const sitemap = await streamToPromise(smStream);
res.header('Content-Type', 'application/xml');
res.send(sitemap.toString());
});
app.listen(3000);
Option 2: Build-time sitemap generation:
If your material doesn't change frequently, generate the sitemap during your build process.
// scripts/generate-sitemap.js
const { SitemapStream, streamToPromise } = require('sitemap');
const { writeFileSync } = require('fs');
async function generateSitemap() {
const smStream = new SitemapStream({ hostname: 'https://example.com' });
const products = await fetchAllProducts();
products.forEach(product => {
smStream.write({
url: `/products/${product.slug}`,
lastmod: product.updatedAt
});
});
smStream.end();
const sitemap = await streamToPromise(smStream);
writeFileSync('public/sitemap.xml', sitemap.toString());
}
generateSitemap();
Add this script to your build process in package.json:
{
"scripts": {
"build": "react-scripts build && node scripts/generate-sitemap.js"
}
}
Vue:
For Vue apps, use a similar approach to React. Generate the sitemap on the server or during the build process using a library like sitemap or vue-sitemap.
Why JavaScript Sites Need Special Sitemap Handling
JavaScript SPAs generate pages dynamically from route definitions and API data. Unlike static sites, your URLs may not map to physical files — they exist only in your framework's router. This means sitemaps must be generated programmatically from the same data sources your app uses.
Key challenges for SPA sitemaps:
- Dynamic routes: Product pages, blog posts, and user profiles exist only as route patterns (e.g.,
/products/:slug), not physical files. - Data-driven content: URLs change as you add/remove content from your database or CMS.
- Client-only routing: Without SSR, there's no server to generate a sitemap on-the-fly — you must use build-time generation.
SPA-Specific Sitemap Rules
- Only include crawlable URLs: Exclude routes behind authentication, dashboard pages, or URLs that return client-side redirects.
- Match your canonical URLs: Sitemap URLs must exactly match what your canonical tags specify (covered in Factor 11).
- Use accurate lastmod from your CMS: Pull the actual content modification timestamp from your database, not the build time.
- Split by material type: Separate sitemaps for products, blog posts, and static URLs — each with its own update frequency.
Keeping Sitemaps in Sync with Your SPA
The biggest risk for JS sites is a stale sitemap. Set up automated regeneration:
- For SSG/ISR sites: Add a sitemap generation step to your build pipeline. Rebuild on material changes via webhooks from your CMS.
- For SSR sites: Generate the sitemap dynamically from your database on each request or cache it with a short TTL (e.g., 1 hour).
- For CSR-only sites: Use a cron job or CI/CD pipeline to regenerate the static sitemap file after information updates.
Submit your sitemap to Googlebot Search Console and monitor the Sitemaps report for indexing errors. A healthy sitemap for a JavaScript site should show all URLs returning 200 status codes with no crawl errors.
9. Ensure Content is DOM-Loaded (Not Action-Dependent, Not Behind Clicks/Scrolls)
Google can execute JS and see content that's dynamically loaded, but it cannot interact with your page. search engine doesn't click buttons, open accordions, scroll down, or trigger any user actions. If your material requires user interaction to appear, Google cannot see it.
Understanding the DOM Loading Process
When search engine renders your JS page, it follows this process:
- Fetch HTML: Googlebot downloads the initial HTML response.
- Parse HTML: Google identifies JavaScript and CSS file references.
- Fetch resources: Google downloads all JavaScript and CSS files.
- Execute JavaScript: search engine runs your JS code to build the DOM.
- Index content: Google indexes the final DOM state after all JS has executed.
The key is step 5: the search engine indexes the DOM state after JavaScript execution, but it does not trigger any user interactions. Content that's loaded by default (when the page loads) will be indexed. Content that requires a click, scroll, or other user action will not be indexed.
Common Action-Dependent Content Patterns
Pattern 1: Content behind tabs or accordions
<!-- Bad: Content hidden behind a tab -->
<div class="tabs">
<button onclick="showTab('details')">Product Details</button>
<button onclick="showTab('reviews')">Reviews</button>
<div id="details" style="display: none;">
<p>Product details here...</p>
</div>
<div id="reviews" style="display: none;">
<p>Customer reviews here...</p>
</div>
</div>
In this example, the "Product Details" and "Reviews" content is hidden by default and only shown when the user clicks a tab. Google will not see this information because it doesn't click tabs.
Fix: Render all data by default and use CSS to hide it visually. Google will still see the information in the DOM.
<!-- Good: Content in DOM, hidden with CSS -->
<div class="tabs">
<button onclick="showTab('details')">Product Details</button>
<button onclick="showTab('reviews')">Reviews</button>
<div id="details" class="tab-content">
<p>Product details here...</p>
</div>
<div id="reviews" class="tab-content">
<p>Customer reviews here...</p>
</div>
</div>
<style>
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
<script>
// Show first tab by default
document.getElementById('details').classList.add('active');
function showTab(tabId) {
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.getElementById(tabId).classList.add('active');
}
</script>
Now all content is in the DOM when the page loads, even though it's visually hidden. Googlebot can see and index all the content.
Pattern 2: Content loaded on scroll (infinite scroll)
// Bad: Content loaded only when user scrolls to the bottom
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
loadMoreProducts();
}
});
In this example, additional products are only loaded when the user scrolls to the bottom of the page. Googlebot doesn't scroll, so it will only see the initial set of products.
Fix: Provide a paginated version alongside infinite scroll, or render all information by default and use lazy loading for images only.
<!-- Option 1: Pagination alongside infinite scroll -->
<div id="products">
<!-- Initial products rendered by default -->
<div class="product">Product 1</div>
<div class="product">Product 2</div>
<div class="product">Product 3</div>
</div>
<button onclick="loadMoreProducts()">Load More</button>
<!-- Pagination for search engines -->
<nav class="pagination">
<a href="/products?page=1">1</a>
<a href="/products?page=2">2</a>
<a href="/products?page=3">3</a>
</nav>
Pattern 3: Content loaded after a click or interaction
<!-- Bad: Content loaded only after clicking a button -->
<button onclick="loadProductDetails()">Show Details</button>
<div id="product-details"></div>
<script>
function loadProductDetails() {
fetch('/api/product/123')
.then(response => response.json())
.then(data => {
document.getElementById('product-details').innerHTML = `
<h2>${data.name}</h2>
<p>${data.description}</p>
`;
});
}
</script>
In this example, the product details are only loaded when the user clicks the "Show Details" button. search engine doesn't click buttons, so it won't see the product details.
Fix: Render the information by default and use JS to enhance the experience.
<!-- Good: Content rendered by default -->
<div id="product-details">
<h2>Product Name</h2>
<p>Product description here...</p>
</div>
<button onclick="toggleDetails()">Toggle Details</button>
<script>
function toggleDetails() {
const details = document.getElementById('product-details');
details.style.display = details.style.display === 'none' ? 'block' : 'none';
}
</script>
Now the material is visible in the DOM when the route loads, even if it's visually hidden.
Testing Whether Content is DOM-Loaded
Use these methods to verify your content is accessible to Google:
Method 1: View Page Source
Right-click your page and select "View Page Source" (not "Inspect"). Search for your content in the HTML. If you can find it in the source code, it's in the initial HTML response and Google can see it.
If your content is not in the source code, it's being added by JS after the page loads. This is okay as long as the content is added automatically (not requiring user interaction).
Method 2: Use Google's URL Inspection Tool
- Go to Google Search Console.
- Enter your URL in the inspection tool.
- Click "View tested page" > "Screenshot."
- Compare the screenshot with what you see when you visit the page.
If content is missing from the screenshot that you see on the live page, it's likely behind user interactions that the search engine cannot perform.
Method 3: Use Chrome DevTools
- Open Chrome DevTools (F12).
- Go to the Console tab.
- Type
document.body.innerTextand press Enter. - This shows all text content in the DOM after JavaScript has executed.
- Search for your content. If it's there, the search engine can see it (as long as it's not behind user interactions).
Method 4: Disable JavaScript
- Open Chrome DevTools (F12).
- Go to Settings > Preferences > Debugger.
- Check "Disable JavaScript."
- Reload your page.
- See what content is still visible.
Content that's visible without JS is definitely crawlable. Content that disappears when JavaScript is disabled may still be crawlable if it's added automatically by JavaScript (not requiring user interaction).
Lazy Loading Images and Content
Lazy loading is a technique where images or information are only loaded when they come into the user's viewport. This improves document load performance but can cause rankings issues if not implemented correctly.
Lazy loading images (good):
Native lazy loading with the loading attribute is SEO-friendly because the image src is still in the HTML.
<img src="product.jpg" alt="Product Name" loading="lazy" />
Google can see the image URL in the src attribute and will index it, even though the image is lazy loaded.
Lazy loading images (bad):
<img data-src="product.jpg" alt="Product Name" class="lazy" />
<script>
// JavaScript replaces data-src with src when the image comes into view
document.querySelectorAll('img.lazy').forEach(img => {
img.src = img.dataset.src;
});
</script>
In this example, the image URL is in the data-src attribute, not the src attribute. Google may or may not see this, depending on whether it executes your JavaScript. This is less reliable than using the native loading attribute.
Fix: Use native lazy loading or ensure the src attribute is populated by default.
Progressive Enhancement vs. Graceful Degradation
Progressive enhancement: Start with a basic HTML URL that works without JavaScript, then add JavaScript enhancements for users with modern browsers.
<!-- Basic HTML that works without JavaScript -->
<form action="/search" method="get">
<input type="text" name="q" />
<button type="submit">Search</button>
</form>
<!-- JavaScript enhancement for AJAX search -->
<script>
document.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
// AJAX search without page reload
});
</script>
Graceful degradation: Build a full-featured JavaScript application, then ensure it still works (with reduced functionality) for users without JavaScript.
For SEO, progressive enhancement is generally better because it ensures your information is accessible to search engines from the start. However, modern JavaScript frameworks with server-side rendering (Next.js, Nuxt, Remix) make graceful degradation viable because they render information on the server before sending it to the browser.
Key Takeaway
All your important SEO data (text, images, links) must be in the DOM after JavaScript executes, without requiring any user interaction. Test this by viewing the route source, using Google's URL Inspection tool, and disabling JavaScript to verify your information is accessible.
10. Set Unique Meta Tags Per Route/Page (Title, Description, Canonical, Robots)
Every route on your site needs unique meta tags to tell search engines what the document is about and how to index it. JavaScript applications often struggle with this because meta tags need to be updated dynamically as users navigate between routes.
Essential Meta Tags for Every Page
Title tag: The most important on-route search visibility element. It appears in search results and browser tabs. Every page should have a unique, descriptive title.
<title>Product Name - Buy Online | Your Store</title>
Meta description: A brief summary of the document content. While not a direct ranking factor, it influences click-through rates from search results.
<meta name="description" content="Buy Product Name online. High-quality product with fast shipping and excellent customer service. Order now!" />
Canonical tag: Tells search engines which version of a route is the "canonical" (preferred) version. This prevents duplicate content issues when the same information is accessible via multiple URLs.
<link rel="canonical" href="https://example.com/products/123" />
Robots meta tag: Controls whether search engines should index the page and follow links on it.
<meta name="robots" content="index, follow" /> <!-- Default: index and follow links -->
<meta name="robots" content="noindex, follow" /> <!-- Don't index, but follow links -->
<meta name="robots" content="noindex, nofollow" /> <!-- Don't index and don't follow links -->
Viewport meta tag: Ensures your page renders correctly on mobile devices.
<meta name="viewport" content="width=device-width, initial-scale=1" />
Open Graph tags: Control how your page appears when shared on social media.
<meta property="og:title" content="Product Name" />
<meta property="og:description" content="Buy Product Name online." />
<meta property="og:image" content="https://example.com/product.jpg" />
<meta property="og:url" content="https://example.com/products/123" />
<meta property="og:type" content="product" />
The Problem with JavaScript Meta Tag Management
In a traditional server-rendered website, meta tags are set in the HTML for each page. In a JavaScript application, all routes share the same HTML file (index.html), so meta tags need to be updated dynamically as users navigate between routes.
If you don't update meta tags dynamically, all documents will have the same title, description, and other meta tags. This causes several SEO problems:
- Duplicate title tags: Google sees all pages as having the same title, making it difficult to understand what each document is about.
- Duplicate meta descriptions: All documents have the same description in search results, reducing click-through rates.
- Missing canonical tags: Without unique canonical tags, Google may treat different URLs as duplicates.
- Incorrect indexing: Without proper robots tags, routes you don't want indexed (like admin pages) may be indexed.
Framework-Specific Meta Tag Management
Next.js (App Router):
Next.js 13+ uses the Metadata API to manage meta tags per route.
// app/products/[id]/page.jsx
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.id);
return {
title: `${product.name} - Buy Online | Your Store`,
description: product.metaDescription,
canonical: `https://example.com/products/${product.slug}`,
openGraph: {
title: product.name,
description: product.metaDescription,
images: [{ url: product.imageUrl }],
},
};
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
return <h1>{product.name}</h1>;
}
The generateMetadata function runs on the server and returns metadata for the page. Next.js automatically injects the meta tags into the HTML.
Next.js (Pages Router):
For older Next.js projects using the Pages Router, use next/head.
// pages/products/[id].jsx
import Head from 'next/head';
export default function ProductPage({ product }) {
return (
<>
<Head>
<title>{`${product.name} - Buy Online | Your Store`}</title>
<meta name="description" content={product.metaDescription} />
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</Head>
<h1>{product.name}</h1>
</>
);
}
Nuxt:
Nuxt uses the useHead composable to manage meta tags.
// pages/products/[id].vue
<script setup>
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.id}`);
useHead({
title: () => `${product.value.name} - Buy Online | Your Store`,
meta: [
{ name: 'description', content: () => product.value.metaDescription },
{ property: 'og:title', content: () => product.value.name },
{ property: 'og:description', content: () => product.value.metaDescription },
{ property: 'og:image', content: () => product.value.imageUrl },
],
link: [
{ rel: 'canonical', href: () => `https://example.com/products/${product.value.slug}` }
]
});
</script>
<template>
<h1>{{ product.name }}</h1>
</template>
The useHead composable accepts functions for dynamic values, so meta tags are updated when the data changes.
React Router (with react-helmet):
For React apps using React Router, use react-helmet or react-helmet-async to manage meta tags.
import { Helmet } from 'react-helmet-async';
function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{`${product.name} - Buy Online | Your Store`}</title>
<meta name="description" content={product.metaDescription} />
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</Helmet>
<h1>{product.name}</h1>
</>
);
}
// Wrap your app with HelmetProvider
import { HelmetProvider } from 'react-helmet-async';
function App() {
return (
<HelmetProvider>
<Routes>
<Route path="/products/:id" element={<ProductPage />} />
</Routes>
</HelmetProvider>
);
}
Vue Router (with vue-meta):
For Vue apps using Vue Router, use vue-meta (built into Nuxt, or use it separately with Vue Router).
// With vue-meta 3
import { useMeta } from 'vue-meta';
export default {
setup() {
const { product } = useProduct();
useMeta({
title: () => `${product.value.name} - Buy Online | Your Store`,
meta: {
description: { content: () => product.value.metaDescription },
'og:title': { content: () => product.value.name },
'og:description': { content: () => product.value.metaDescription },
},
link: {
canonical: { href: () => `https://example.com/products/${product.value.slug}` }
}
});
return { product };
}
}
Meta Tag Best Practices
Title tag best practices:
- Keep titles between 50-60 characters to avoid truncation in search results.
- Include the primary keyword near the beginning of the title.
- Include your brand name, usually at the end.
- Make each title unique and descriptive of the route content.
- Use title case or sentence case for readability.
Meta description best practices:
- Keep descriptions between 150-160 characters to avoid truncation.
- Include the primary keyword naturally.
- Write compelling copy that encourages clicks (include a call-to-action).
- Make each description unique and accurate.
- Use active voice and speak directly to the user.
Canonical tag best practices:
- Always include a self-referencing canonical tag on every page.
- Point the canonical to the preferred version of the page (the version you want indexed).
- Use absolute URLs (full URLs with https://) in canonical tags.
- Ensure the canonical URL returns a 200 status code (don't canonicalize to a redirect or 404 page).
- Only canonicalize to URLs with similar information (don't canonicalize a product page to the homepage).
Robots meta tag best practices:
- Use
noindexfor URLs you don't want in search results (admin pages, user dashboards, thank you pages, internal search results). - Use
nofollowsparingly. Most URLs should allow link following to pass PageRank. - Combine directives as needed:
noindex, followmeans "don't index this page, but follow the links on it." - Don't use
noindex, nofollowunless you want the route completely hidden from search engines. This is rare; usually you want at least the links followed.
Testing Meta Tags
After implementing dynamic meta tags, verify they're working correctly:
- View Page Source: Right-click each document and select "View Page Source." Verify the title, meta description, and canonical tag are unique and correct for that page.
- Use Google's Rich Results Test: Enter your URL at search.google.com/test/rich-results. This shows you what meta tags search engine sees after rendering your page.
- Check in browser tabs: Open multiple pages in different tabs. Verify each tab shows a unique title (you may need to reload the route to see the updated title).
- Use a crawler: Run Screaming Frog or Sitebulb and export the title tags, meta descriptions, and canonical tags for all pages. Look for duplicates or missing tags.
- Check Google Search Console: Navigate to Appearance > Search results in GSC. Look for issues with title tags or meta descriptions.
11. Handle Canonical Tags Correctly (Avoid Duplicates, Self-Referencing)
Canonical tags tell search engines which version of a page is the "preferred" version when multiple URLs show the same content. Incorrect canonical tag implementation is one of the most common JS SEO mistakes and can cause significant indexing issues.
Canonical Tag Challenges in JavaScript SPAs
JavaScript applications face unique canonical tag problems that static sites don't encounter:
- Client-side tag injection: If your canonical tag is added by JavaScript after page load, search engines may not see it in the initial HTML response. Googlebot prefers canonical tags in the server-rendered HTML.
- Conflicting tags: Your initial HTML might have one canonical tag, but JavaScript changes it to a different URL. This conflict confuses search engines about which version to index.
- Dynamic route variations: SPAs often create multiple URL patterns for the same content through client-side routing, query parameters, or hash fragments.
Common SPA Canonical Mistakes
Mistake 1: Canonical tags only in rendered HTML
<!-- Initial HTML response -->
<html>
<head>
<title>Product Page</title>
<!-- No canonical tag here -->
</head>
<body>
<script src="app.js"></script>
</body>
</html>
<!-- After JavaScript renders -->
<html>
<head>
<title>Product Page</title>
<link rel="canonical" href="https://example.com/products/123" />
</head>
<body>
<h1>Product Name</h1>
</body>
</html>
Fix: Use server-side rendering or your framework's metadata API to inject the canonical tag during the initial HTML generation, not after client-side hydration.
Mistake 2: JS overriding canonical tags
<!-- Initial HTML has one canonical -->
<link rel="canonical" href="https://example.com/products/123" />
<!-- JavaScript adds a different canonical -->
<script>
document.querySelector('link[rel="canonical"]').href = 'https://example.com/products/123?ref=affiliate';
</script>
Fix: Ensure your canonical tags are consistent between initial HTML and rendered HTML. Don't override canonical tags with client-side JavaScript.
Implementing Canonical Tags in JavaScript Frameworks
Next.js:
// app/products/[id]/page.jsx
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.id);
return {
alternates: {
canonical: `https://example.com/products/${product.slug}`,
},
};
}
Nuxt:
// pages/products/[id].vue
<script setup>
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.id}`);
useHead({
link: [
{ rel: 'canonical', href: () => `https://example.com/products/${product.value.slug}` }
]
});
</script>
React Router (with react-helmet):
import { Helmet } from 'react-helmet-async';
function ProductPage({ product }) {
return (
<>
<Helmet>
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</Helmet>
<h1>{product.name}</h1>
</>
);\n}\n\n12. Implement Structured Data for JavaScript-Rendered Content
Structured data (schema markup) helps search engines understand your content and can unlock rich results. For JavaScript SPAs, the challenge is injecting structured data dynamically per route so Google sees it in the initial HTML response, not just after client-side rendering.
SPA-Specific Structured Data Challenges
JavaScript applications face unique structured data problems:
- Client-side injection timing: If structured data is added by JavaScript after page load, Google may not see it in the initial HTML. Structured data must be server-rendered or injected during the initial response.
- Dynamic content per route: Each SPA route needs its own structured data reflecting the current page's content. You can't use a single static JSON-LD block for all routes.
- Framework rendering lifecycle: Different frameworks (Next.js, Nuxt, React) handle metadata injection at different points in the rendering cycle. You must use the framework's official metadata API to ensure structured data is included in server-rendered HTML.
Dynamic Structured Data Injection in JavaScript Frameworks
Next.js:
// app/products/[id]/page.jsx
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.id);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.imageUrl,
description: product.description,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
},
};
return {
other: {
'script:ld+json': JSON.stringify(jsonLd),
},
};
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
return <h1>{product.name}</h1>;
}
Nuxt:
// pages/products/[id].vue
<script setup>
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.id}`);
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: () => JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value.name,
image: product.value.imageUrl,
description: product.value.description,
offers: {
'@type': 'Offer',
price: product.value.price,
priceCurrency: 'USD',
availability: product.value.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
},
}),
}
]
});
</script>
React (with react-helmet):
import { Helmet } from 'react-helmet-async';
function ProductPage({ product }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.imageUrl,
description: product.description,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
},
};
return (
<>
<Helmet>
<script type="application/ld+json">
{JSON.stringify(jsonLd)}
</script>
</Helmet>
<h1>{product.name}</h1>
</>
);
}
Structured Data Best Practices
- Follow Google's guidelines: Only use structured data types that are documented on schema.org and supported by Google. Don't mark up material that's not visible to users.
- Be accurate: The structured data must accurately reflect the information on the page. Don't use misleading or incorrect information to manipulate search results.
- Include all required properties: Each structured data type has required properties. Check Google's documentation for each type to ensure you include all required fields.
- Use the most specific type: If you're marking up a product, use
Product, notThing. Use the most specific type that applies to your content. - Test your structured data: Use Google's Rich Results Test tool to validate your structured data and see if it's eligible for rich results.
- Don't overuse structured data: Only mark up information that's relevant and useful. Don't add structured data just because you can.
Testing Structured Data
After implementing structured data, verify it's correct and eligible for rich results:
- Google Rich Results Test: Go to search.google.com/test/rich-results and enter your URL. This tool shows you what structured data Google detects and whether it's eligible for rich results.
- Schema.org Validator: Use schema.org/validator to validate your JSON-LD syntax.
- View Page Source: Right-click your URL and select "View Page Source." Search for
application/ld+jsonto verify your structured data is in the HTML. - Check Google Search Console: Navigate to Enhancements in GSC to see if Google is detecting your structured data and if there are any errors or warnings.
13. Optimize JavaScript Bundle Size (Code Splitting, Tree Shaking, Lazy Loading)
Large JavaScript bundles slow down page loads, hurt Core Web Vitals, and can delay Google's ability to render and index your content. Optimizing your JavaScript bundle size is essential for both user experience and SEO.
Why Bundle Size Matters for SEO
Google has stated that URL speed is a ranking factor, and Core Web Vitals (LCP, FID, CLS) are part of the document experience signal. Large JavaScript bundles directly impact these metrics:
- Largest Contentful Paint (LCP): Large JS files delay the rendering of your main content, hurting LCP.
- First Input Delay (FID) / Interaction to Next Paint (INP): Large JavaScript files take longer to parse and execute, delaying interactivity.
- Cumulative Layout Shift (CLS): If JavaScript dynamically injects information or ads, it can cause layout shifts.
Additionally, Google has a limited crawl budget for each site. If your documents take longer to load and render, Google can crawl fewer routes in the same amount of time.
Code Splitting
Code splitting breaks your JavaScript bundle into smaller chunks that are loaded on-demand. Instead of loading one large file with all your application code, you load only the code needed for the current page.
Next.js:
Next.js automatically splits code at the route level. Each URL loads only the JavaScript it needs.
// app/products/page.jsx
// This code is only loaded when the user visits /products
export default function ProductsPage() {
return <h1>Products</h1>;
}
// app/products/[id]/page.jsx
// This code is only loaded when the user visits /products/123
export default function ProductDetailPage() {
return <h1>Product Detail</h1>;
}
For components that are only used on specific pages, use dynamic imports with next/dynamic.
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false // Disable server-side rendering for this component
});
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart />
</div>
);
}
React (with React.lazy):
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
Vue (with defineAsyncComponent):
import { defineAsyncComponent } from 'vue';
const HeavyComponent = defineAsyncComponent(() => import('./HeavyComponent.vue'));
export default {
components: {
HeavyComponent
}
}
Webpack (for custom setups):
If you're using Webpack directly, configure code splitting in your Webpack config.
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
};
Tree Shaking
Tree shaking removes unused code from your JavaScript bundle. If you import a library but only use one function from it, tree shaking ensures only that function is included in your bundle.
Tree shaking works automatically with modern bundlers (Webpack, Rollup, esbuild) as long as you use ES6 module syntax (import and export).
// Good: Tree shaking can remove unused imports
import { formatDate } from './utils';
// formatDate is used, but formatTime is not imported, so it's removed
// Bad: Importing everything prevents tree shaking
import * as utils from './utils';
utils.formatDate(date); // Even though you only use formatDate, the entire utils module is included
Webpack tree shaking configuration:
// webpack.config.js
module.exports = {
mode: 'production', // Tree shaking only works in production mode
optimization: {
usedExports: true,
sideEffects: true,
},
};
Make sure your package.json includes "sideEffects": false if your library has no side effects (like modifying global variables or adding CSS).
{
"name": "my-library",
"sideEffects": false
}
Lazy Loading
Lazy loading delays the loading of non-critical resources until they're needed. This reduces the initial route load time and improves Core Web Vitals.
Lazy loading images:
Use the native loading attribute for images.
<img src="product.jpg" alt="Product" loading="lazy" />
Images with loading="lazy" are only loaded when they come into the user's viewport. This reduces initial route load time, especially for URLs with many images.
Lazy loading components:
Use dynamic imports to load components only when they're needed (as shown in the code splitting section).
Lazy loading third-party scripts:
Third-party scripts (analytics, chat widgets, social media embeds) can significantly increase your bundle size. Load them only when needed or after the route has loaded.
// Load analytics script after page load
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
script.async = true;
document.head.appendChild(script);
});
}
Analyzing Your Bundle Size
Use these tools to analyze your JavaScript bundle size and identify optimization opportunities:
Webpack Bundle Analyzer:
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
};
This generates an interactive treemap showing the size of each module in your bundle. Use it to identify large dependencies and optimization opportunities.
Next.js Bundle Analyzer:
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Your Next.js config
});
Run ANALYZE=true npm run build to generate a bundle analysis report.
Source Map Explorer (for Create React App):
npm install --save-dev source-map-explorer
// package.json
{
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'"
}
}
Run npm run build && npm run analyze to see a breakdown of your bundle size.
Common Bundle Size Optimization Techniques
- Replace large libraries with smaller alternatives: For example, replace Moment.js (300KB) with date-fns (7KB) or Day.js (2KB).
- Remove unused dependencies: Audit your
node_modulesand remove libraries you're not using. - Minify and compress: Ensure your bundler is minifying JavaScript (removing whitespace, shortening variable names) and your server is serving compressed files (gzip or Brotli).
- Use modern JavaScript features: Modern browsers support features like async/await, optional chaining, and nullish coalescing natively. You don't need to transpile these features for older browsers unless you need to support them.
- Avoid duplicate dependencies: Sometimes different libraries depend on different versions of the same package, causing duplicates in your bundle. Use tools like
npm dedupeoryarn dedupeto remove duplicates.
Monitoring Bundle Size Over Time
Add bundle size monitoring to your CI/CD pipeline to prevent bundle size regression.
Using size-limit:
npm install --save-dev size-limit
// package.json
{
"size-limit": [
{
"path": "dist/*.js",
"limit": "200 KB"
}
]
}
Run npm run size to check if your bundle exceeds the limit. Add this to your CI pipeline to fail builds that exceed the limit.
Core Web Vitals Optimization
After optimizing your bundle size, measure your Core Web Vitals to ensure improvements:
- Lighthouse: Run Lighthouse in Chrome DevTools to get a performance score and specific recommendations.
- PageSpeed Insights: Use pagespeed.web.dev to measure your site's performance on both mobile and desktop.
- Web Vitals Extension: Install the Web Vitals Chrome extension to measure real-user metrics as you browse your site.
- Chrome User Experience Report (CrUX): Check the CrUX data in Google Search Console to see real-user performance metrics for your site.
Focus on these metrics:
- Largest Contentful Paint (LCP): Should be under 2.5 seconds. Optimize by reducing bundle size, using a CDN, and optimizing images.
- Interaction to Next Paint (INP): Should be under 200 milliseconds. Optimize by reducing JavaScript execution time and breaking up long tasks.
- Cumulative Layout Shift (CLS): Should be under 0.1. Optimize by reserving space for dynamic content, avoiding layout shifts from fonts, and using size attributes for images and videos.
14. Test with Google's Rendering Tools (URL Inspection, Rich Results Test, Compare Raw vs Rendered)
Testing is critical to ensure your JavaScript rankings implementation is working correctly. search engine provides several tools to help you see what Googlebot sees when it crawls and renders your pages.
Google Search Console URL Inspection Tool
The URL Inspection tool is the most important tool for JavaScript SEO. It shows you exactly what search engine sees when it crawls and renders your page.
How to use it:
- Go to Googlebot Search Console.
- Enter your URL in the inspection bar at the top.
- search engine shows you information about the page, including whether it's indexed, when it was last crawled, and any issues.
- Click "View tested page" to see what Googlebot actually rendered.
- Choose between "Screenshot," "HTML," or "More info."
What to check:
- Screenshot: Compare the screenshot with what you see when you visit the page. If data is missing from the screenshot, the search engine cannot see it.
- HTML: This shows the rendered HTML after JavaScript execution. Search for your content (title, meta description, main content) to verify it's present.
- More info: This shows additional details like URL resources, load time, and any crawl errors.
Live Test vs. Indexed Version:
The URL Inspection tool shows two versions:
- Indexed version: The version search engine has in its index. This may be outdated if your page has changed since search engine last crawled it.
- Live test: A fresh crawl of your page. This shows what Google sees right now.
Always run a live test to see the current state of your page. If the live test shows different information than the indexed version, request re-indexing to update Google's index.
Requesting re-indexing:
- After running a live test, click "Request indexing."
- Google adds your URL to the priority crawl queue.
- It may take a few hours to a few days for Google to re-crawl and re-index your page.
Use this after making significant changes to your URL (updating content, fixing meta tags, adding structured data).
Google Rich Results Test
The Rich Results Test tool validates your structured data and shows you which rich results your page is eligible for.
How to use it:
- Go to search.google.com/test/rich-results.
- Enter your URL or paste your HTML code.
- Click "Test URL" or "Test Code."
- The tool shows you detected structured data and whether it's valid.
What to check:
- Detected structured data: Verify all your structured data is detected and valid.
- Eligible for rich results: The tool shows which rich result types your URL qualifies for (product, FAQ, article, etc.).
- Errors and warnings: Fix any errors (required properties missing) and address warnings (recommended properties missing).
Testing JavaScript-rendered structured data:
If your structured data is injected by JS (not in the initial HTML), the Rich Results Test tool will render your page and check the structured data after JavaScript execution. This simulates what Google does when it renders your page.
Comparing Raw HTML vs. Rendered HTML
One of the most important tests for JS rankings is comparing the raw HTML (before JS execution) with the rendered HTML (after JS execution). This shows you what information is added by JavaScript and what's in the initial HTML response.
Method 1: View Page Source vs. Inspect Element
- Right-click your page and select "View Page Source." This shows the raw HTML before JS execution.
- Right-click your route and select "Inspect" (or press F12). This shows the rendered DOM after JavaScript execution.
- Compare the two. Look for information that's in the rendered DOM but not in the raw HTML.
Method 2: Chrome DevTools Network Tab
- Open Chrome DevTools (F12).
- Go to the Network tab.
- Reload your page.
- Click on the first request (the document request).
- Go to the "Response" tab. This shows the raw HTML response from the server.
- Go to the "Elements" tab in DevTools. This shows the rendered DOM.
- Compare the two.
Method 3: cURL vs. Browser
# Get the raw HTML with cURL
curl https://example.com/page
Then compare this with what you see in your browser (after JavaScript execution).
What to look for:
- Title tag: Is it in the raw HTML, or is it added by JavaScript? If it's only in the rendered HTML, Googlebot may not see it reliably.
- Meta description: Same as the title tag. It should be in the raw HTML.
- Canonical tag: Should be in the raw HTML to avoid duplicate information issues.
- Main content: Ideally in the raw HTML, but acceptable if it's added by JS automatically (not requiring user interaction).
- Internal links: Should be in the raw HTML so Google can discover them without rendering.
- Structured data: Should be in the raw HTML for reliable detection.
Third-Party Testing Tools
In addition to Google's tools, use these third-party tools to test your JavaScript SEO:
Screaming Frog SEO Spider:
Screaming Frog can crawl your site with JavaScript rendering enabled or disabled. This allows you to compare the two versions and identify search visibility issues.
- Download and install Screaming Frog.
- Go to Configuration > Spider > Rendering.
- Choose "JavaScript" to enable rendering, or "Text" to disable it.
- Crawl your site.
- Export the data and compare the JavaScript-rendered version with the text-only version.
Sitebulb:
Sitebulb is another crawler that supports JavaScript rendering. It provides detailed reports on JavaScript SEO issues, including content only in rendered HTML, meta tags modified by JavaScript, and more.
Merkle's JavaScript SEO Auditor:
Merkle provides a free JS search visibility auditor tool that crawls your site with and without JavaScript rendering and compares the results. It shows you which URLs have search visibility issues related to JS rendering.
Technical SEO Tools (various):
- Rendertron: An open-source rendering service by the search engine that renders JS URLs and returns the rendered HTML. You can use it to test what search engine sees.
- Prerender.io: A service that pre-renders your JS pages for search engines. It shows you the pre-rendered HTML so you can verify it's correct.
- Greenfly: A tool that compares raw and rendered HTML and highlights differences.
Testing Checklist for JavaScript SEO
After implementing your JS SEO, run through this checklist to verify everything is working correctly:
- URL Inspection Tool: Run a live test on your most important pages. Verify the screenshot shows all your information and the HTML includes your title, meta description, and canonical tag.
- Rich Results Test: Validate your structured data and verify you're eligible for rich results.
- Raw vs. Rendered HTML: Compare the raw HTML with the rendered HTML. Verify critical search visibility elements (title, meta, canonical, main content) are in the raw HTML or are added automatically by JavaScript.
- Mobile-Friendly Test: Use Google's Mobile-Friendly Test tool to verify your route is mobile-friendly.
- PageSpeed Insights: Measure your Core Web Vitals and identify performance issues.
- Screaming Frog or Sitebulb: Crawl your site with JavaScript rendering enabled and disabled. Compare the results to identify URLs with SEO issues.
- Googlebot Search Console Coverage Report: Check for crawl errors, indexing issues, and routes with JavaScript rendering problems.
- Manual testing: Visit your most important documents and verify they load quickly, display correctly, and include all expected content.
Ongoing Monitoring
JS search visibility is not a one-time task. You need to monitor your site continuously to catch issues as they arise.
- Set up Google Search Console alerts: Get email notifications for critical errors like server errors (5xx), crawl anomalies, or security issues.
- Monitor Core Web Vitals: Track your LCP, INP, and CLS scores over time. Set up alerts for significant drops in performance.
- Regular crawls: Run Screaming Frog or Sitebulb crawls monthly to catch new search visibility issues.
- Code review: Review JS changes in your code review process. Ensure developers are following JavaScript SEO best practices.
- Automated testing: Set up automated tests to verify meta tags, structured data, and canonical tags are present and correct for each page.
15. Monitor Indexation & Rendering Delays (site: Queries, GSC Coverage, Render Queue Awareness)
JavaScript URLs often experience delays in indexing because Google must render them after the initial crawl. Understanding these delays and monitoring your indexation status helps you identify and fix SEO issues quickly.
Understanding Google's Rendering Queue
When the search engine crawls a JavaScript page, it follows this process:
- Crawl: Googlebot downloads the initial HTML response.
- Queue for rendering: Google adds the page to a rendering queue. This is where delays happen.
- Render: Google renders the document by executing the JS and building the DOM.
- Index: Googlebot indexes the rendered content.
The delay between step 2 and step 3 (the rendering queue) can range from a few seconds to several hours or even days, depending on Google's workload and the priority of your page.
Factors that affect rendering priority:
- Site authority: High-authority sites get faster rendering.
- Page importance: Pages with more internal links or external backlinks are prioritized.
- Freshness: Newly discovered documents may be rendered faster.
- Crawl budget: Sites with larger crawl budgets get more rendering resources.
For most sites, the rendering delay is not a significant issue. Google's documentation states that the median time to rendering is 5 seconds, and the 90th percentile is a few minutes. However, some sites may experience longer delays, especially if they have a large number of URLs or if Google is experiencing high demand.
Monitoring Indexation with site: Queries
Use site: queries in Google to check how many of your pages are indexed.
site:example.com
This shows all routes Google has indexed for your domain. Compare this number with the number of URLs you expect to be indexed.
Check specific sections:
site:example.com/products <!-- Check indexed product pages -->
site:example.com/blog <!-- Check indexed blog posts -->
site:example.com/about <!-- Check indexed about pages -->
Check for duplicate content:
site:example.com inurl:products <!-- Check for URL variations -->
site:example.com intitle:"Product Name" <!-- Check for duplicate titles -->
Limitations of site: queries:
- The number shown is an estimate, not an exact count.
- Results may not include all indexed pages.
- The data may be outdated (Google doesn't update it in real-time).
For more accurate data, use search engine Search Console's Coverage report.
Googlebot Search Console Coverage Report
The Coverage report in Google Search Console shows you the indexing status of all your pages. It's the most accurate source of indexation data.
How to access it:
- Go to Googlebot Search Console.
- Navigate to Pages > Coverage (or Indexing > Coverage in the old interface).
- You'll see a summary of indexed pages, excluded pages, and errors.
Key sections to monitor:
Indexed pages:
- "Indexed, not submitted in sitemap": Pages search engine found through links but not in your sitemap. This is normal and not a problem.
- "Indexed, submitted in sitemap": Pages the search engine found in your sitemap and indexed. This is good.
Excluded pages:
- "Excluded by 'noindex' tag": Pages you've explicitly told search engine not to index. This is expected for admin pages, user dashboards, etc.
- "Page with redirect": Pages that redirect to other pages. Verify these redirects are intentional.
- "Soft 404": Pages that return a 200 status code but show a "document not found" message. Fix these by returning proper 404 status codes.
- "Duplicate, submitted URL not selected as canonical": Pages Google considers duplicates of other pages. Check if your canonical tags are correct.
- "Duplicate, Google chose different canonical than user": Pages where the search engine chose a different canonical URL than you specified. Investigate why and fix your canonical tags if needed.
- "Crawled - currently not indexed": Pages Googlebot crawled but hasn't indexed yet. This may be due to quality issues, duplicate content, or rendering delays. Wait a few days and check again.
- "Discovered - currently not indexed": Pages Googlebot discovered but hasn't crawled yet. This is often due to crawl budget limitations. Improve your site's authority and internal linking to get these URLs crawled.
Errors:
- "Server error (5xx)": Pages with server errors. Fix these immediately, as they prevent indexing.
- "Not found (404)": Pages that return 404 errors. Remove internal links to these routes or redirect them to relevant pages.
Monitoring Rendering Delays
If you suspect your routes are experiencing rendering delays, use these methods to monitor and diagnose the issue:
Method 1: Check the "Crawled - currently not indexed" status
If many of your documents are in the "Crawled - currently not indexed" status, it may indicate rendering delays. search engine has crawled the documents but hasn't rendered or indexed them yet.
Action:
- Wait a few days and check again. Rendering delays are often temporary.
- If URLs remain in this status for weeks, investigate quality issues (thin content, duplicate content, low-quality pages).
- Improve your site's authority by building backlinks and improving content quality.
- Ensure your URLs are rendering correctly by testing with the URL Inspection tool.
Method 2: Compare crawl dates in GSC
- In the URL Inspection tool, check the "Last crawl" date for your pages.
- If Googlebot crawled your URLs recently but they're not indexed, it may indicate rendering delays.
- Request re-indexing to move your URLs to the priority crawl queue.
Method 3: Monitor indexation rate over time
- In the Coverage report, track the number of indexed URLs over time.
- If the indexation rate slows down or stops, it may indicate rendering issues or crawl budget problems.
- Check for technical issues (server errors, slow page loads, large JavaScript bundles) that may be slowing down rendering.
Improving Rendering Speed
If your URLs are experiencing rendering delays, use these techniques to improve rendering speed:
- Reduce JavaScript bundle size: Smaller bundles render faster. Use code splitting, tree shaking, and lazy loading to reduce your bundle size.
- Optimize server response time: Faster server responses mean faster crawling. Use a CDN, optimize your server, and enable caching.
- Improve document speed: Faster pages render faster. Optimize images, enable compression, and reduce render-blocking resources.
- Fix crawl errors: Server errors (5xx) and slow page loads can reduce your crawl budget. Fix these errors to improve crawl efficiency.
- Improve site authority: Higher-authority sites get more crawl budget and faster rendering. Build backlinks, improve information quality, and increase user engagement.
- Submit a sitemap: A sitemap helps Googlebot discover your URLs faster. Ensure your sitemap is up-to-date and submitted to the search engine Search Console.
- Use internal linking: Pages with more internal links are discovered and crawled faster. Improve your internal linking structure to ensure all important documents are linked.
When to Worry About Rendering Delays
Rendering delays are normal for JS pages, but you should investigate if:
- Pages remain in "Crawled - currently not indexed" status for more than 2 weeks.
- Your indexation rate has slowed down significantly or stopped.
- Important URLs (homepage, product pages, category pages) are not indexed.
- You see a large number of "Discovered - currently not indexed" pages.
For most sites, rendering delays are not a significant issue. Google's rendering infrastructure is designed to handle JavaScript URLs efficiently. However, if you're experiencing persistent delays, focus on improving your site's technical SEO, content quality, and authority.
Rendering Queue Myths
Myth 1: Googlebot only waits 5 seconds to render JavaScript pages.
This is false. Google's rendering timeout is not publicly documented, but tests have shown that search engine is patient and will wait for JS to execute. The 5-second myth likely comes from outdated information or confusion with the testing tools (which do have timeouts).
Myth 2: JS pages take weeks to be indexed.
While JavaScript URLs may take longer to index than static HTML pages, they don't typically take weeks. Google's documentation states that the median time to rendering is 5 seconds, and the 90th percentile is a few minutes. If your pages are taking weeks to index, there's likely a different issue (quality, duplicate content, crawl errors).
Myth 3: You need to use dynamic rendering to get JS URLs indexed quickly.
Dynamic rendering (serving pre-rendered HTML to search engines) is a workaround, not a best practice. Google recommends against dynamic rendering and instead recommends fixing the underlying JS SEO issues. Focus on server-side rendering, proper meta tags, and clean URLs instead.
Key Takeaways
JS rankings requires a combination of proper implementation, thorough testing, and ongoing monitoring. By following the 15 factors outlined in this guide, you can ensure your JS application is crawlable, indexable, and optimized for search engines.
Remember that JavaScript SEO is not about avoiding JavaScript. Modern JS frameworks (Next.js, Nuxt, Remix, SvelteKit) make it easier than ever to build fast, SEO-friendly applications. The key is understanding how search engines process JS and implementing best practices to ensure your information is accessible to crawlers.
Test your implementation with Google's tools, monitor your indexation status in search engine Search Console, and stay up-to-date with the latest JS SEO best practices. With the right approach, your JS application can rank just as well as a traditional server-rendered website.
Frequently asked questions about JavaScript SEO for SPAs
Can Google crawl and index JavaScript content?
Yes, Google can crawl and index JS content using a headless Chrome browser. However, other search engines (Bing, Yandex, Baidu) have limited JS support, so server-side rendering is still recommended for optimal SEO across all search engines. AI search engine support for JavaScript-rendered content also depends on server-rendered HTML for reliable extraction.
Do I need server-side rendering for JavaScript SEO?
Server-side rendering (SSR) is recommended but not required. Googlebot can index client-side rendered pages, but SSR ensures your information is in the initial HTML response, which is more reliable and works with all search engines. If using CSR, ensure your title, meta description, and main material are rendered without requiring user interaction.
How long does it take for JavaScript pages to be indexed?
JavaScript routes typically take seconds to a few minutes to be rendered and indexed. Google's median time to rendering is 5 seconds, and the 90th percentile is a few minutes. If documents take significantly longer, investigate large JavaScript bundles, server errors, or data quality problems.
What is the difference between SSR, SSG, and CSR?
SSR (Server-Side Rendering) generates HTML on the server per request. SSG (Static Site Generation) pre-builds HTML at deploy time. CSR (Client-Side Rendering) generates HTML in the browser using JavaScript. SSR and SSG are better for SEO because content is in the initial HTML. CSR works but requires search engine to render your documents first.