SaaS MVP & Development

Next.js SEO: The Complete Technical Guide for SaaS Apps

Intermediate
Mansab
21 min

Master Next.js SEO with generateMetadata, sitemaps, OG images, JSON-LD, and i18n. Real code examples built for SaaS founders who need rankings.

nextjs seonextjs generatemetadatacore web vitals nextjsnextjs sitemap generatornextjs og image nextjs opengraphnextjs jsonldnextjs internationalizationnextjs headless cmscors nextjsnextjs rendering strategies
Next.js SEO: The Complete Technical Guide for SaaS Apps

Next.js SEO: The Complete Technical Guide for SaaS Apps

Next.js is the strongest React framework for SEO. But only when properly configured.

Out of the box, nothing is set up. No sitemap. No metadata. No structured data. No OG images. Every one of those gaps costs you rankings.

This guide covers everything. Rendering strategies, generateMetadata, sitemaps, robots.txt, canonical tags, JSON-LD, Core Web Vitals, image optimisation, i18n, and headless CMS SEO. With working code for every section.

It's written for SaaS founders and developers who want to build a Next.js app that ranks. Not a surface-level overview. Everything you need.

CodixFlow's take: We see the same SEO mistakes on almost every Next.js SaaS we audit. Most are avoidable. All of them are fixable. The earlier you catch them, the cheaper the fix.

If you're still deciding on your tech stack, read our SaaS MVP development guide first. Then come back here for the full SEO layer.

How Google Crawls and Indexes Your Next.js App

Understanding this is step one. Without it, the rest doesn't make sense.

Google uses a bot called Googlebot. It works in four stages:

Crawl: Googlebot discovers URLs. From sitemaps, Search Console, and links.

Index: It stores the page content in Google's database.

Render: It executes JavaScript to see the full page. This step is expensive. Google queues it separately.

Rank: It scores and serves pages based on relevance and quality.

The Next.js-specific risk: If your content depends on JavaScript to render, Googlebot may see an empty page on the first crawl. It gets queued for rendering later. That delay costs you. SSG and SSR solve this.

The goal is simple: send Googlebot a fully rendered HTML page. Every time. No waiting for JavaScript.

Rendering Strategy: The Most Important SEO Decision

Pick the wrong rendering method and your content never gets indexed. This single decision outweighs everything else.

Next.js gives you four options.

MethodWhen HTML is BuiltSEO Safe?Best Use Case
SSG — Static Site GenerationAt build time✅ Best optionMarketing pages, blog, pricing, docs
ISR — Incremental Static RegenBuild + revalidation✅ YesBlog posts, CMS content, product pages
SSR — Server-Side RenderingOn each request✅ Yes (slower TTFB)User-specific data, live feeds
CSR — Client-Side RenderingIn the browser⚠️ RiskyDashboards, auth-only views
Next.js rendering strategies compared by SEO impact — 2026

Static Site Generation (SSG)

SSG is the best rendering method for SEO. Pages are built at compile time. Googlebot gets pure HTML instantly. No rendering queue. No JavaScript dependency.

Use SSG for: marketing pages, pricing, blog, documentation.

TypeScript
// app/blog/[slug]/page.tsx — SSG with generateStaticParams
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)
  return <article>{/* render post */}</article>
}

Every blog post is pre-rendered at build time. Fast. Crawlable. Indexed immediately.

Incremental Static Regeneration (ISR)

ISR extends SSG. Pages are built at compile time. They revalidate after a set interval. New content gets picked up without a full rebuild.

Best for: CMS-driven content, large blogs, product catalogues.

app/blog/[slug]/page.tsxTypeScript
// app/blog/[slug]/page.tsx — ISR with revalidation
export const revalidate = 3600 // revalidate every hour

export default async function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)
  return <article>{/* render post */}</article>
}

Server-Side Rendering (SSR)

SSR generates HTML on every request. Googlebot sees full HTML. But Time to First Byte (TTFB) is slower than SSG.

Use SSR for: pages with real-time data, user-personalised content.

Client-Side Rendering (CSR)

CSR renders in the browser using JavaScript. Googlebot sees empty HTML on the first crawl.

Never use CSR for pages you want to rank. Dashboards and authenticated views are fine. Landing pages, blog posts, and pricing pages are not.

The most common mistake: building the entire SaaS as a single-page app. Every page renders client-side. Googlebot sees blank HTML. Six months later, nothing ranks.

HTTP Status Codes That Affect SEO

Googlebot reads status codes on every request. They directly affect whether your pages get indexed.

Status CodeMeaningSEO Impact
200 OKPage loaded successfully✅ Required for indexing
301 / 308 RedirectPermanent redirect✅ Passes link equity to new URL
302 RedirectTemporary redirect⚠️ Doesn't pass equity — use 301 instead
404 Not FoundPage doesn't existNeutral if used correctly — bad if widespread
410 GonePage permanently deletedTells Googlebot: stop crawling this forever
500 Server ErrorUnexpected server failure❌ Googlebot may retry, but rankings drop
503 UnavailableServer temporarily downUse during planned downtime to protect rankings
HTTP status codes and their SEO impact in Next.js apps

Setting Status Codes in Next.js

Permanent redirect (308):

TypeScript
// next.config.js
module.exports = {
  async redirects() {
    return [
      {
        source: '/old-page',
        destination: '/new-page',
        permanent: true, // triggers 308
      },
    ]
  },
}

Note: Next.js uses 308 (not 301) for permanent redirects by default. 308 is the modern version. It doesn't allow changing the request method from POST to GET. For SEO purposes, both pass link equity equally.

Custom 404 page:

TypeScript
// app/not-found.tsx
export default function NotFound() {
  return (
    <main>
      <h1>404 — Page Not Found</h1>
      <p>This page doesn't exist.</p>
    </main>
  )
}

Custom 500 page:

TypeScript
// app/error.tsx
'use client'

export default function Error({
  reset,
}: {
  reset: () => void
}) {
  return (
    <main>
      <h1>500 — Something went wrong</h1>
      <button onClick={reset}>Try again</button>
    </main>
  )
}

Return 410 for permanently deleted content:

TypeScript
// app/deleted-post/page.tsx
import { notFound } from 'next/navigation'

export default function DeletedPost() {
  // Use this for content that will never return
  notFound() // returns 404 by default
  // For a true 410, handle in middleware or route handler
}

generateMetadata: Titles, Descriptions, and Tags

generateMetadata is the App Router's metadata API. It replaced the old <Head> component.

Every page.tsx file needs metadata. Not just layout.tsx. Every single page.

The most common SEO mistake we see: metadata set once in layout.tsx. All 40+ pages share one title tag. Google flags them as duplicates. Rankings never come.

Static Metadata

For pages with fixed content:

TypeScript
// app/pricing/page.tsx
export const metadata = {
  title: 'Pricing — YourSaaS',
  description:
    'Simple pricing for SaaS teams. Start free, scale as you grow.',
  openGraph: {
    title: 'Pricing — YourSaaS',
    description: 'Simple pricing for SaaS teams.',
    url: 'https://yoursaas.com/pricing',
    type: 'website',
  },
}

Dynamic Metadata

For pages driven by a CMS or database:

TypeScript
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts'

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  return {
    title: `${post.title} | YourSaaS Blog`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [
        {
          url: post.ogImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
      type: 'article',
      publishedTime: post.publishedAt,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
    },
  }
}

Meta Robots Tags

Control indexing at the page level:

TypeScript
// app/settings/page.tsx — block from indexing
export const metadata = {
  robots: {
    index: false,
    follow: false,
  },
}

Use noindex for: settings pages, internal search results, filtered product pages with no results, admin areas.

Next.js Sitemap Generator

A sitemap tells Google which pages exist. Without one, large sites get partially crawled. New pages take longer to index.

Option 1: sitemap.ts (App Router)

Clean, built-in, zero dependencies:

TypeScript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()

  const postEntries = posts.map((post) => ({
    url: `https://yoursaas.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'monthly' as const,
    priority: 0.7,
  }))

  return [
    {
      url: 'https://yoursaas.com',
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 1,
    },
    {
      url: 'https://yoursaas.com/pricing',
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 0.9,
    },
    ...postEntries,
  ]
}

Next.js serves this at /sitemap.xml automatically.

Option 2: next-sitemap (Large Apps)

For apps with hundreds of pages, use next-sitemap for automated generation:

Bash
npm install next-sitemap
JavaScript
// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: process.env.SITE_URL || 'https://yoursaas.com',
  generateRobotsTxt: true,
  exclude: [
    '/dashboard/*',
    '/admin/*',
    '/api/*',
    '/settings/*',
  ],
  sitemapSize: 7000,
}
JSON
{
  "scripts": {
    "build": "next build",
    "postbuild": "next-sitemap"
  }
}

Runs after every build. Sitemap always stays current.

robots.txt in Next.js

Block private routes from Googlebot. Always.

Your SaaS has routes Google should never index: dashboards, API endpoints, admin panels, user account pages.

TypeScript
// app/robots.ts
import { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: [
          '/dashboard/',
          '/admin/',
          '/api/',
          '/settings/',
          '/account/',
        ],
      },
      {
        userAgent: 'Googlebot',
        allow: '/',
        disallow: ['/dashboard/', '/admin/'],
      },
    ],
    sitemap: 'https://yoursaas.com/sitemap.xml',
  }
}

This auto-generates at /robots.txt. No manual file needed.

Important: robots.txt prevents crawling. It does not prevent indexing. If a page is already indexed and you add it to robots.txt, it may stay in the index. To remove an indexed page, use noindex in the page's metadata.

Canonical Tags: Prevent Duplicate Content

A canonical tag tells Google which URL is the original. Without it, duplicates compete against each other. Rankings suffer.

When you need canonical tags:

  • Same content on multiple URLs (e.g. /products/shirt and /shirt)
  • UTM parameters creating unique URLs (?utm_source=google)
  • Pagination variants (/blog?page=2)
  • HTTP vs HTTPS versions

Set canonicals via generateMetadata:

TypeScript
// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}) {
  return {
    alternates: {
      canonical: `https://yoursaas.com/blog/${params.slug}`,
    },
  }
}
TypeScript
// app/pricing/page.tsx
export const metadata = {
  alternates: {
    canonical: 'https://yoursaas.com/pricing',
  },
}

Note: Canonical tags are recommendations, not directives. Google may choose to override them. They are still essential for managing duplicate content at scale.

OG Images and OpenGraph

OG images appear when your pages are shared on LinkedIn, Slack, and X. They affect click-through rate. Low click-through rate signals poor relevance to Google. Everything is connected.

Static OG Image

Drop opengraph-image.png into any route folder. Next.js handles the rest.

Dynamic OG Images

Generate unique images per page programmatically:

TypeScript
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'

export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function OGImage({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  return new ImageResponse(
    (
      <div
        style={{
          background: '#0f172a',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: '80px',
        }}
      >
        <p
          style={{
            color: '#94a3b8',
            fontSize: '24px',
            marginBottom: '20px',
          }}
        >
          YourSaaS Blog
        </p>
        <h1
          style={{
            color: '#ffffff',
            fontSize: '56px',
            lineHeight: 1.2,
            margin: 0,
          }}
        >
          {post.title}
        </h1>
      </div>
    )
  )
}

Every blog post now has a unique, branded OG image. Generated at request time. No design tool. No manual work.

JSON-LD Structured Data

JSON-LD is how you communicate page content to Google in a machine-readable format. It powers rich results — FAQ dropdowns, star ratings, article metadata — directly in the SERP.

The most useful schema types for SaaS apps:

  • Article — blog posts and guides
  • FAQPage — FAQ sections
  • SoftwareApplication — product landing page
  • BreadcrumbList — site navigation structure
  • Organization — company identity for brand SERPs

Article Schema (Blog Posts)

TypeScript
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts'

export default async function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    image: post.ogImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Organization',
      name: 'YourSaaS',
      url: 'https://yoursaas.com',
    },
    publisher: {
      '@type': 'Organization',
      name: 'YourSaaS',
      logo: {
        '@type': 'ImageObject',
        url: 'https://yoursaas.com/logo.png',
      },
    },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* content */}</article>
    </>
  )
}

FAQ Schema

TypeScript
// app/faq/page.tsx
const faqItems = [
  {
    question: 'How does YourSaaS work?',
    answer: 'YourSaaS connects to your existing tools...',
  },
  {
    question: 'Is there a free trial?',
    answer: 'Yes — 14 days, no credit card required.',
  },
]

export default function FAQPage() {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: faqItems.map((item) => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: {
        '@type': 'Answer',
        text: item.answer,
      },
    })),
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {/* FAQ content */}
    </>
  )
}

Software Application Schema (Product Page)

TypeScript
// app/page.tsx — homepage / product landing page
const softwareJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'SoftwareApplication',
  name: 'YourSaaS',
  applicationCategory: 'BusinessApplication',
  operatingSystem: 'Web',
  offers: {
    '@type': 'Offer',
    price: '0',
    priceCurrency: 'USD',
    description: 'Free trial available',
  },
  aggregateRating: {
    '@type': 'AggregateRating',
    ratingValue: '4.8',
    reviewCount: '214',
  },
}

URL Structure and Routing for SEO

URL structure directly affects rankings. Good URLs are semantic, consistent, and keyword-focused.

Rules:

  • Use words, not IDs. /blog/nextjs-seo-guide beats /blog/post-4721
  • Use hyphens between words. Never underscores.
  • Keep slugs short. 3–5 words maximum.
  • Never include dates in URLs. They signal staleness. Hard to update.
  • Never use parameters in crawlable URLs. /products?category=saas is worse than /products/saas

App Router Route Structure

Bash
app/
├── page.tsx               → yoursaas.com/
├── pricing/page.tsx       → yoursaas.com/pricing
├── blog/
│   ├── page.tsx           → yoursaas.com/blog
│   └── [slug]/page.tsx    → yoursaas.com/blog/[slug]
├── guides/
│   └── [slug]/page.tsx    → yoursaas.com/guides/[slug]
└── not-found.tsx          → 404 page

Dynamic Routes with Fallback

TypeScript
// app/blog/[slug]/page.tsx — with fallback for new posts
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

// New posts not in generateStaticParams get SSR'd on first visit
// then cached — equivalent to fallback: 'blocking' in Pages Router
export const dynamicParams = true

On-Page SEO: Headings and Internal Links

Rankings depend on more than technical setup. Page structure matters too.

Heading Hierarchy

Use one H1 per page. It should match the page title closely.

H2 for main sections. H3 for sub-points within sections. Never skip levels.

TypeScript
// Correct heading structure
export default function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>      {/* One per page */}
      <h2>First Major Section</h2>
      <h3>Sub-point within that section</h3>
      <h2>Second Major Section</h2>
    </article>
  )
}

Internal Links

Internal links pass authority between pages. They help Google understand your site's structure. They keep readers on your site longer.

Always use descriptive anchor text. Never "click here" or "read more."

TypeScript
// components/NavLink.tsx
import Link from 'next/link'

// ✅ Correct — descriptive anchor text
export function NavLink() {
  return (
    <Link href="/guides/nextjs-saas-development">
      our full Next.js SaaS development guide
    </Link>
  )
}

// ❌ Wrong — tells Google nothing
export function BadNavLink() {
  return <Link href="/guides/nextjs-saas-development">click here</Link>
}

Why this matters: Google's PageRank algorithm scores pages based on the quantity and quality of links pointing to them. Every well-linked internal page passes authority upward. Anchor text tells Google what the destination page is about.

Next.js Internationalization (i18n) SEO

Building for multiple countries? i18n SEO is not optional.

The problem: without hreflang tags, Google doesn't know which locale to show in which country. You end up with your German page ranking in the US. Your English page buried in Germany.

Setting Up Locale Routes

TypeScript
// app/[locale]/layout.tsx
export async function generateStaticParams() {
  return [
    { locale: 'en' },
    { locale: 'de' },
    { locale: 'fr' },
    { locale: 'es' },
  ]
}

export async function generateMetadata({
  params,
}: {
  params: { locale: string }
}) {
  const baseUrl = 'https://yoursaas.com'

  return {
    alternates: {
      canonical: `${baseUrl}/${params.locale}`,
      languages: {
        en: `${baseUrl}/en`,
        de: `${baseUrl}/de`,
        fr: `${baseUrl}/fr`,
        es: `${baseUrl}/es`,
        'x-default': `${baseUrl}/en`,
      },
    },
  }
}

Next.js renders these as <link rel="alternate" hreflang="..." /> tags automatically.

CodixFlow's recommendation: Plan your i18n URL structure before you write a single line of content. Retrofitting it later means a full URL restructure — and losing every backlink and ranking you've built.

Next.js with a Headless CMS

Pairing Next.js with Sanity, Contentful, or Strapi is a strong SaaS architecture. But it introduces a specific SEO risk.

The risk: If CMS content is fetched client-side, Googlebot sees empty HTML.

Always fetch CMS content server-side. Always.

TypeScript
// app/blog/[slug]/page.tsx — correct server-side CMS fetch
import { client } from '@/lib/sanity'

// Pre-render all posts at build time
export async function generateStaticParams() {
  const slugs: string[] = await client.fetch(
    `*[_type == "post"].slug.current`
  )
  return slugs.map((slug) => ({ slug }))
}

// Each post fetched on the server — not the browser
export default async function Post({
  params,
}: {
  params: { slug: string }
}) {
  const post = await client.fetch(
    `*[_type == "post" && slug.current == $slug][0]{
      title,
      excerpt,
      body,
      publishedAt,
      "ogImage": mainImage.asset->url
    }`,
    { slug: params.slug }
  )

  return <article>{/* render post content */}</article>
}

With ISR for live CMS updates:

TypeScript
// Add this for ISR — revalidates every hour
export const revalidate = 3600

New CMS content gets picked up automatically. Googlebot sees fresh, static HTML.

CORS in Next.js and SEO

CORS doesn't directly affect rankings. But it can break pages indirectly.

If your frontend fetches from a separate API and that API returns CORS errors, the page fails to load fully. Googlebot crawls a broken page. Content is missing. Rankings drop.

Configure CORS correctly in your API routes:

TypeScript
// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server'

const ALLOWED_ORIGIN = 'https://yoursaas.com'

export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin')

  const data = { items: [] } // your data here

  const response = NextResponse.json(data)

  if (origin === ALLOWED_ORIGIN) {
    response.headers.set('Access-Control-Allow-Origin', ALLOWED_ORIGIN)
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
  }

  return response
}

export async function OPTIONS(request: NextRequest) {
  return new NextResponse(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  })
}

Never use Access-Control-Allow-Origin: * in production. It's a security risk. And it's unnecessary when you control both ends.

Core Web Vitals: The Performance Layer of SEO

Google uses Core Web Vitals as a ranking signal. Three metrics. Each one measurable. Each one improvable.

MetricWhat It MeasuresGood ScorePoor Score
LCP — Largest Contentful PaintLoad time of the largest visible element≤ 2.5s> 4.0s
INP — Interaction to Next PaintResponsiveness to user interactions≤ 200ms> 500ms
CLS — Cumulative Layout ShiftVisual stability — how much the page shifts≤ 0.1> 0.25
Core Web Vitals thresholds — 2026 (INP replaced FID in March 2024)

Note: FID (First Input Delay) was replaced by INP (Interaction to Next Paint) as an official Core Web Vital in March 2024. If you're reading older guides referencing FID, they're now outdated.

What Moves the Needle

  • For LCP: Optimise images. Remove render-blocking resources. Use SSG or ISR. Preload the LCP image.
  • For INP: Reduce JavaScript execution time. Use dynamic imports. Defer non-critical scripts.
  • For CLS: Always set explicit width and height on images. Reserve space for ads and embeds. Avoid injecting content above existing content.

Optimising Images with next/image

Unoptimised images are the single biggest LCP killer in Next.js apps.

The next/image component handles everything automatically:

  • Resizes for each viewport size
  • Converts to WebP (when browser supports it)
  • Lazy loads by default
  • Prevents CLS with reserved space
TypeScript
// app/blog/[slug]/page.tsx
import Image from 'next/image'

export default function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      {/* Hero image — set priority for LCP element */}
      <Image
        src={post.heroImage}
        alt={post.title}
        width={1200}
        height={630}
        priority // preloads the LCP image
      />
      {/* In-article images — lazy loaded by default */}
      <Image
        src={post.bodyImage}
        alt={post.bodyImageAlt}
        width={800}
        height={450}
      />
    </article>
  )
}

Use priority on your LCP image only. The hero image. The first visible image. One image per page. Using priority on multiple images defeats the purpose.

Never use a raw <img> tag for any meaningful image. It bypasses all Next.js optimisations.

Dynamic Imports for Better INP

Large JavaScript bundles block the main thread. They hurt INP scores. They delay interaction response time.

Dynamic imports split your bundle. Code loads only when needed.

Dynamically Importing Libraries

TypeScript
// app/search/page.tsx
'use client'

export default function SearchPage() {
  const handleSearch = async (value: string) => {
    // Fuse.js only loads when user starts searching
    const Fuse = (await import('fuse.js')).default

    const fuse = new Fuse(items, {
      keys: ['name'],
      threshold: 0.3,
    })

    return fuse.search(value).map((r) => r.item)
  }

  return <input onChange={(e) => handleSearch(e.target.value)} />
}

Dynamically Importing Components

The modal JavaScript only loads when open becomes true. Not on initial page load.

TypeScript
// app/page.tsx
import dynamic from 'next/dynamic'

// Modal only loads when user clicks the button
// Not included in the initial bundle
const HeavyModal = dynamic(() => import('@/components/HeavyModal'), {
  ssr: false, // disable SSR for client-only components
  loading: () => <p>Loading...</p>,
})

export default function Home() {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      {open && <HeavyModal onClose={() => setOpen(false)} />}
    </>
  )
}

Font Optimization

Custom fonts add network requests. Those requests delay rendering. Delayed rendering hurts LCP and FCP.

Next.js automatically inlines font CSS at build time. One less network roundtrip.

TypeScript
// app/layout.tsx
import { Inter, Space_Grotesk } from 'next/font/google'

// Fonts are downloaded at build time and served from your domain
// No external request to Google Fonts at runtime
const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // prevents invisible text during font load
  variable: '--font-inter',
})

const spaceGrotesk = Space_Grotesk({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-heading',
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${spaceGrotesk.variable}`}
    >
      <body>{children}</body>
    </html>
  )
}

display: 'swap' ensures text remains visible while the font loads. No invisible text. Better FCP.

Third-Party Scripts

Analytics, chat widgets, and ad scripts are the most common cause of poor Core Web Vitals scores. They load on the main thread. They delay interactivity.

Use next/script to control when they load:

TypeScript
// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}

        {/* Loads after page is interactive — safe for analytics */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
          strategy="afterInteractive"
        />

        {/* Loads during browser idle time — use for chat widgets */}
        <Script
          src="https://cdn.example.com/chat-widget.js"
          strategy="lazyOnload"
        />
      </body>
    </html>
  )
}

Three strategy options:

  • beforeInteractive — loads before hydration. Use only for critical scripts.
  • afterInteractive — loads after page is interactive. Use for analytics.
  • lazyOnload — loads during browser idle time. Use for chat widgets.

Never load third-party scripts in <head> without the Script component. They block rendering. LCP scores drop. Rankings follow.

Measuring and Monitoring SEO Performance

You can't improve what you don't measure. Use these tools:

Google Search Console — the most important tool. Shows which queries you rank for, click-through rates, indexing errors, and Core Web Vitals from real users.

Lighthouse — run in Chrome DevTools. Simulates a page audit. Gives actionable scores and improvement suggestions.

Bash
# Run Lighthouse from CLI
npm install -g lighthouse
lighthouse https://yoursaas.com --output html --output-path ./report.html

PageSpeed Insights — Google's online tool. Combines Lighthouse data with real user data from the Chrome User Experience Report (CrUX).

Custom Web Vitals reporting:

TypeScript
// app/layout.tsx — report vitals to your analytics
'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    // Send to your analytics endpoint
    fetch('/api/vitals', {
      method: 'POST',
      body: JSON.stringify(metric),
      headers: { 'Content-Type': 'application/json' },
    })
  })

  return null
}

What We See in Production

We've audited the SEO configuration of dozens of Next.js SaaS apps. The same problems appear every time.

A client came to us with six months of published content. Zero organic traffic. Their entire site used client-side rendering. They'd built a React SPA and migrated it to Next.js without changing the data-fetching pattern. Every page called an API in useEffect. Googlebot saw blank HTML on every crawl.

Switching to Server Components and generateStaticParams took two days. Rankings started appearing within six weeks.

The second pattern: teams using layout.tsx for all metadata. Forty-plus pages. One title tag. Google treated the entire blog as a single duplicate page.

The pattern we keep seeing: SEO treated as a launch checklist item, not a build-time requirement. By launch, the wrong patterns are baked in everywhere. Do the SEO setup in week one. Before you write a single piece of content.

If you want a Next.js SaaS built with this layer correctly from the start, see how CodixFlow approaches SaaS MVP development.

Quick Reference: Next.js SEO Checklist

Rendering:

  • Every public page uses SSG, ISR, or SSR — never CSR
  • No useEffect data fetching on content that needs to rank

Metadata:

  • Every page.tsx has metadata or generateMetadata
  • Title under 60 chars. Description 140–155 chars.
  • Primary keyword in both

Technical:

  • sitemap.ts or next-sitemap configured
  • robots.ts blocks private routes
  • Canonical tags on all pages
  • No duplicate title tags across the site

OpenGraph:

  • OG title, description, and image on every page
  • Twitter card tags set

Structured Data:

  • Article schema on blog posts
  • FAQPage schema on FAQ sections
  • SoftwareApplication schema on product landing page

Performance:

  • next/image for all images
  • priority on the LCP image only
  • next/font for all web fonts
  • next/script with correct strategy for third-party scripts
  • Dynamic imports for heavy components and libraries

URLs:

  • Clean slugs — words, not IDs
  • No dates in URLs
  • No parameters in crawlable URLs
  • Consistent URL pattern across content types

FAQ

Next.js SEO — Frequently Asked Questions

Next.js gives you every tool you need to rank. The framework is not the problem. Implementation is.

Set up generateMetadata on every page. Add sitemaps, robots.txt, and canonical tags. Generate OG images. Add JSON-LD to posts and landing pages. Use SSG or ISR for all public content. Optimise images, fonts, and third-party scripts. Do it before launch. Not after six months of wondering why nothing ranks.

If you'd rather have someone who's done this dozens of times build the technical foundation for you, see how CodixFlow builds production-ready Next.js SaaS apps.

Need Help Implementing This?

Our team can help you implement these concepts in your SaaS product or automation workflows.

Ready to Build Your SaaS MVP?

Let's turn your idea into a reality. We specialize in SaaS MVP development and AI automation.

Trusted by 50+ founders • 4-8 week delivery • Fixed-price projects