Skip to content
All essays
WebFebruary 22, 202411 min

SEO for Single Page Apps

Make your SPA search-engine friendly with proper SSR, SSG, and SEO best practices.

Ü
Ümit Uz
Mobile & Full Stack Developer

Single Page Apps present unique SEO challenges. Here's how to make them search-engine friendly.

The SPA SEO Problem

Traditional SPAs render content client-side, leaving search engines with empty HTML.

html
<!-- What search engines see -->
<div id="root"></div>
<script src="app.js"></script>

<!-- What users see after JavaScript runs -->
<div id="root">
  <h1>My Awesome Content</h1>
  <p>Rich content...</p>
</div>

Solution: Server-Side Rendering (SSR)

Next.js Pages Router

javascript
// pages/blog/[slug].js
export async function getServerSideProps(context) {
  const { slug } = context.params;
  const post = await fetchPost(slug);

  return {
    props: {
      post,
    },
  };
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Next.js App Router

javascript
// app/blog/[slug]/page.js
export async function generateMetadata({ params }) {
  const post = await fetchPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.ogImage],
    },
  };
}

export default async function BlogPost({ params }) {
  const post = await fetchPost(params.slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Static Site Generation (SSG)

Pre-render pages at build time for best performance.

javascript
// Next.js SSG
export async function getStaticProps({ params }) {
  const post = await fetchPost(params.slug);

  return {
    props: { post },
    revalidate: 60, // Incremental Static Regeneration
  };
}

export async function getStaticPaths() {
  const posts = await fetchAllPosts();

  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),
    fallback: 'blocking', // Server-render new pages on demand
  };
}

Meta Tags Optimization

Essential Meta Tags

javascript
import Head from 'next/head';

function BlogPost({ post }) {
  return (
    <>
      <Head>
        {/* Primary */}
        <title>{post.title} | My Site</title>
        <meta name="description" content={post.excerpt} />

        {/* Canonical URL */}
        <link rel="canonical" href={`https://mysite.com/blog/${post.slug}`} />

        {/* Open Graph */}
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={post.ogImage} />
        <meta property="og:url" content={`https://mysite.com/blog/${post.slug}`} />
        <meta property="og:type" content="article" />

        {/* Twitter Card */}
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={post.title} />
        <meta name="twitter:description" content={post.excerpt} />
        <meta name="twitter:image" content={post.twitterImage} />

        {/* Article-specific */}
        <meta property="article:published_time" content={post.publishedAt} />
        <meta property="article:author" content={post.author.name} />
        <meta property="article:section" content="Technology" />

        {/* Additional */}
        <meta name="keywords" content={post.tags?.join(', ') || ''} />
      </Head>
      <article>{/* Content */}</article>
    </>
  );
}

Structured Data (JSON-LD)

Help search engines understand your content.

javascript
// Blog post structured data
const structuredData = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  description: post.excerpt,
  image: post.ogImage,
  datePublished: post.publishedAt,
  dateModified: post.updatedAt,
  author: {
    '@type': 'Person',
    name: post.author.name,
    url: post.author.url,
  },
  publisher: {
    '@type': 'Organization',
    name: 'My Company',
    logo: {
      '@type': 'ImageObject',
      url: 'https://mysite.com/logo.png',
    },
  },
};

function BlogPost({ post }) {
  return (
    <>
      <Head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(structuredData),
          }}
        />
      </Head>
      <article>{/* Content */}</article>
    </>
  );
}

Sitemap Generation

Create XML sitemaps for search engines.

javascript
// pages/sitemap.xml.js
import { getAllPosts } from '../lib/api';

function generateSiteMap(posts) {
  return `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      <url>
        <loc>https://mysite.com</loc>
        <changefreq>daily</changefreq>
        <priority>1.0</priority>
      </url>
      <url>
        <loc>https://mysite.com/blog</loc>
        <changefreq>daily</changefreq>
        <priority>0.9</priority>
      </url>
      ${posts
        .map(
          (post) => `
      <url>
        <loc>https://mysite.com/blog/${post.slug}</loc>
        <lastmod>${post.updatedAt}</lastmod>
        <changefreq>weekly</changefreq>
        <priority>0.7</priority>
      </url>
      `
        )
        .join('')}
    </urlset>
  `;
}

export async function getServerSideProps({ res }) {
  const posts = await getAllPosts();

  res.setHeader('Content-Type', 'text/xml');
  res.write(generateSiteMap(posts));
  res.end();

  return { props: {} };
}

export default function Sitemap() {
  return null;
}

Robots.txt

Control crawler access.

javascript
// pages/robots.txt.js
export default function robots() {
  return `
    User-agent: *
    Allow: /

    Sitemap: https://mysite.com/sitemap.xml
  `;
}

Performance and SEO

Core Web Vitals

Optimize for Google's performance metrics.

javascript
// Next.js Image component for optimization
import Image from 'next/image';

function BlogPost({ post }) {
  return (
    <Image
      src={post.coverImage}
      alt={post.title}
      width={1200}
      height={630}
      priority // LCP image
      placeholder="blur"
    />
  );
}

Lazy Loading

Defer offscreen content.

javascript
import dynamic from 'next/dynamic';

const CommentSection = dynamic(() => import('./CommentSection'), {
  loading: () => <p>Loading comments...</p>,
  ssr: false, // Client-side only
});

URL Structure

SEO-Friendly URLs

javascript
// Good: Descriptive and clean
/blog/modern-css-features-guide
/blog/2024/03/building-scalable-react-apps

// Bad: Non-descriptive
/blog/post?id=123
/article/abc-xyz-456

Pagination

Handle pagination correctly.

javascript
function BlogPage({ posts, page, totalPages }) {
  return (
    <>
      <Head>
        {page > 1 && (
          <link
            rel="prev"
            href={`/blog?page=${page - 1}`}
          />
        )}
        {page < totalPages && (
          <link
            rel="next"
            href={`/blog?page=${page + 1}`}
          />
        )}
      </Head>
      {/* Content */}
    </>
  );
}

Internal Linking

Build site structure with internal links.

javascript
function RelatedPosts({ posts }) {
  return (
    <section>
      <h2>Related Posts</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link href={`/blog/${post.slug}`}>
              <a>{post.title}</a>
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

404 Pages

Custom 404 pages improve user experience.

javascript
// pages/404.js
export default function Custom404() {
  return (
    <>
      <Head>
        <title>Page Not Found | My Site</title>
      </Head>
      <div style={{ textAlign: 'center', padding: '4rem' }}>
        <h1>404 - Page Not Found</h1>
        <p>The page you're looking for doesn't exist.</p>
        <Link href="/">
          <a>Go Home</a>
        </Link>
      </div>
    </>
  );
}

Monitoring and Analytics

Google Search Console

Monitor your site's performance.

javascript
// Verify ownership
// Add meta tag to your app
<meta name="google-site-verification" content="YOUR_VERIFICATION_CODE" />

Analytics Integration

Track user behavior.

javascript
// pages/_app.js
import { useEffect } from 'react';
import { useRouter } from 'next/router';

export default function App({ Component, pageProps }) {
  const router = useRouter();

  useEffect(() => {
    const handleRouteChange = (url) => {
      window.gtag('config', 'GA_MEASUREMENT_ID', {
        page_path: url,
      });
    };

    router.events.on('routeChangeComplete', handleRouteChange);

    return () => {
      router.events.off('routeChangeComplete', handleRouteChange);
    };
  }, [router.events]);

  return <Component {...pageProps} />;
}

Best Practices

DO

  • Use SSR/SSG for critical content
  • Implement proper meta tags
  • Add structured data
  • Create XML sitemaps
  • Optimize Core Web Vitals
  • Use semantic HTML
  • Build internal links
  • Monitor Search Console

DON'T

  • Rely solely on client-side rendering
  • Skip meta tags
  • Ignore mobile optimization
  • Create duplicate content
  • Forget about accessibility
  • Neglect page speed
  • Use hash-based routing
  • Skip analytics

Tools

SEO Auditing

bash
# Lighthouse SEO audit
lighthouse https://mysite.com --only-categories=seo

# Screaming Frog spider
# Ahrefs site audit
# SEMrush site audit

Testing

bash
# Test structured data
https://search.google.com/test/rich-results

# Test mobile-friendliness
https://search.google.com/test/mobile-friendly

# Test page speed
https://pagespeed.web.dev/

Conclusion

SEO for SPAs requires deliberate effort. Use SSR or SSG for critical content, implement comprehensive meta tags, add structured data, and optimize performance. Monitor your performance in Search Console and iterate based on data. The investment pays off in better search visibility and organic traffic.

Related essays

Next essay
Frontend Build Tools Comparison