Next.js

Next.js SEO: A Practical Guide for the App Router

Next.js SEO: A Practical Guide for the App Router

Next.js SEO in the App Router comes down to 4 things: render your content on the server so crawlers receive real HTML, set titles, descriptions and canonicals with the Metadata API, generate your sitemap and robots file from app/sitemap.ts and app/robots.ts, and keep pages fast enough to pass Core Web Vitals. Next.js gives you the tools for all of it, but none of it happens automatically if the site is built carelessly.

We build most of our client sites and platforms in Next.js, and we also get called in to fix Next.js sites that look great but barely appear in Google. The cause is nearly always one of the issues below. This guide is written for developers, and for site owners who want to check what their developer has done.

What Next.js SEO means

Next.js SEO is the set of practices that make a Next.js site easy for search engines to crawl, render, understand and rank. Next.js is a React framework. Plain React apps traditionally render in the browser, so the first HTML response can be almost empty. Next.js solves that by rendering on the server. SEO in Next.js is mostly about using those server features correctly and adding the metadata search engines need.

All examples here use the App Router, meaning the app directory. The code is TypeScript and simplified to show only the SEO part.

Rendering: why it matters most

Google can render JavaScript, but rendering can happen later than the first crawl, and other search engines, social link previews and many AI crawlers may not run JavaScript reliably. The safest approach is to send complete HTML in the first response.

In the App Router, components are Server Components by default. They render on the server, so their content is in the HTML. You have 3 main options for when that rendering happens:

  • Static rendering (SSG): the page is built ahead of time. Best for pages that rarely change, such as service pages. Fast and cheap to serve.
  • Incremental static regeneration (ISR): static pages that regenerate in the background after a set interval. Good for blogs, product listings and catalogs.
  • Dynamic rendering (SSR): the page renders on every request. Needed for truly per request content, at a higher server cost.

For a blog or catalog, static pages with regeneration are usually the right balance:

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

// Regenerate each page at most once an hour
export const revalidate = 3600;

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

The client only trap

Adding 'use client' does not by itself hide content from Google. Client Components are still rendered to HTML on the server for the first load. The real problem is fetching your main content in the browser, inside useEffect, so the server sends a loading state instead of the text:

'use client';
import { useEffect, useState } from 'react';

// Avoid this pattern for content you want indexed
export default function Page() {
  const [post, setPost] = useState<{ title: string } | null>(null);

  useEffect(() => {
    fetch('/api/post').then((res) => res.json()).then(setPost);
  }, []);

  if (!post) return <p>Loading...</p>;
  return <h1>{post.title}</h1>;
}

The server HTML for this page contains "Loading..." and nothing else. Fetch indexable content in a Server Component instead, and pass the data down to Client Components only for the interactive parts.

The Metadata API

The App Router replaces hand written head tags with the Metadata API. You export a metadata object or a generateMetadata function from a layout.tsx or page.tsx file, and Next.js renders the right tags. Both only work in Server Components.

Site defaults in the root layout

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://www.example.com'),
  title: {
    default: 'Example Studio',
    template: '%s | Example Studio',
  },
  description: 'Custom web development for growing businesses.',
  openGraph: {
    siteName: 'Example Studio',
    type: 'website',
  },
};

metadataBase lets every other page use relative URLs for canonicals and Open Graph images. The title template means a page that sets its title to "Services" renders as "Services | Example Studio".

Static pages

// app/services/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Services',
  description: 'Web development, ecommerce and SEO services.',
  alternates: {
    canonical: '/services',
  },
};

Dynamic pages with generateMetadata

For blog posts, products or any page driven by data, use generateMetadata so each page gets its own title, description and canonical:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts';

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
      type: 'article',
    },
  };
}

export default async function Page({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

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

In recent Next.js versions params is a Promise and must be awaited, as shown. Older versions passed a plain object, so match the pattern to the version you run. Both functions call getPost, so wrap that function in React's cache, or rely on fetch request memoization, so the data loads only once per request.

Calling notFound() for a missing post returns a real 404. Without it you risk serving an empty page with a 200 status, which search engines tend to treat as a soft 404.

Canonical URLs

A canonical tells search engines which URL is the main version of a page. Next.js sites often create duplicates through tracking parameters, trailing slash differences, filter parameters, or the same content served on both www and non www hosts.

  • Set alternates.canonical on every indexable page, as in the examples above.
  • Pick one host and redirect the other, at the server or with redirects() in next.config.
  • Keep the trailingSlash setting consistent and do not mix both forms in internal links.
  • For multilingual sites, add alternates.languages so each language version points to the others.
  • For pages you do not want indexed, such as internal search results, set robots: { index: false } in their metadata.

sitemap.ts and robots.ts

Next.js generates sitemap.xml and robots.txt from special files in the app directory.

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

const baseUrl = 'https://www.example.com';

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

  const postUrls = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
  }));

  return [
    { url: baseUrl },
    { url: `${baseUrl}/services` },
    ...postUrls,
  ];
}

Only include canonical, indexable URLs that return a 200 status. Use a real lastModified date from your data rather than new Date(), which tells search engines every page changed on every build. Google has said it ignores the priority and change frequency values, so we usually leave them out. A single sitemap file is limited to 50,000 URLs, and Next.js provides generateSitemaps for splitting very large sites into several files.

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

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: '/admin/',
    },
    sitemap: 'https://www.example.com/sitemap.xml',
  };
}

Do not block the /_next/ folder or any API routes your pages need in order to render. If Google cannot load the scripts and styles, it may not see the page the way users do. Also remember that robots.txt controls crawling, not indexing. Use a noindex robots directive for pages that must stay out of search results, and do not block those pages in robots.txt, or crawlers will never see the directive.

Structured data

Structured data in JSON-LD helps search engines understand what a page is about, and it can make pages eligible for rich results. Render it as a script tag inside your Server Component:

export default async function Page({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.authorName },
    image: post.coverImage,
  };

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\u003c'),
        }}
      />
      {/* post content */}
    </article>
  );
}

The replace call escapes the less than character, so content from your database cannot break out of the script tag. Match the schema type to the page: Organization or LocalBusiness on the homepage, Article for posts, Product for products, and FAQPage only where the content genuinely fits. Validate with Google's Rich Results Test. Valid markup makes a page eligible for rich results, but it never guarantees them.

Images

The next/image component resizes images, serves modern formats where the browser supports them, and lazy loads images below the fold by default. Always set width and height, or use fill inside a sized container, so the browser reserves space and the layout does not jump.

import Image from 'next/image';

export function TeamPhoto() {
  return (
    <Image
      src="/images/team.jpg"
      alt="Our team reviewing a website before launch"
      width={1200}
      height={800}
      sizes="(max-width: 768px) 100vw, 800px"
    />
  );
}
  • Write descriptive alt text for meaningful images, and leave alt empty for purely decorative ones.
  • Set the sizes attribute on responsive images so phones do not download desktop sized files.
  • Tell Next.js to load the main above the fold image early, because it is often the Largest Contentful Paint element. The prop for this has changed between versions, so check the Image docs for the version you run.
  • Use descriptive file names, such as riyadh-office-fitout.jpg, not IMG_4432.jpg.

Core Web Vitals in Next.js

Next.js gives you a fast starting point, but it is easy to lose. These are the problems we fix most often:

  • Too much client JavaScript: marking whole layouts or pages with 'use client' ships more code to the browser and slows interaction. Keep 'use client' on the smallest interactive components.
  • Heavy third party scripts: chat widgets, tag managers and ad scripts. Load them with next/script and a deferred loading strategy where possible.
  • Layout shift from fonts: use next/font, which self hosts fonts and reduces shift when they load.
  • Unsized images and embeds: always reserve the space.
  • Slow dynamic pages: render statically or with regeneration whenever the content allows.

Check real user data in the Core Web Vitals report in Search Console, not only lab scores from Lighthouse.

Next.js SEO checklist

  • Indexable content rendered on the server, not fetched in useEffect
  • metadataBase and a title template set in the root layout
  • Unique title, description and canonical on every indexable page
  • generateMetadata used for dynamic routes
  • notFound() returning real 404s for missing content
  • app/sitemap.ts listing only canonical 200 URLs with real dates
  • app/robots.ts pointing to the sitemap and not blocking rendering resources
  • One host and one trailing slash style, with redirects for the rest
  • JSON-LD structured data validated in the Rich Results Test
  • next/image with sizes and alt text, and the hero image loaded early
  • next/font for web fonts
  • 'use client' limited to interactive components
  • Sitemap submitted and key pages checked with URL Inspection in Search Console

Common questions

Is Next.js good for SEO?

Yes, when it is used properly. Server rendering, the Metadata API and built in sitemap and robots support give it everything a search engine needs. A Next.js site can still perform badly in search if content is fetched only in the browser or metadata is missing.

Does 'use client' hurt SEO in Next.js?

Not on its own, because Client Components are still rendered to HTML on the server for the initial load. It hurts when the main content loads in the browser after the page mounts, or when too much JavaScript slows the page down. Keep content in Server Components and interactivity in small Client Components.

Should I use SSR or SSG for SEO?

Both produce full HTML, so both work for search engines. Static rendering is faster and cheaper to serve, which helps Core Web Vitals, so use it wherever content does not change per request. Use regeneration for content that updates regularly and dynamic rendering only where it is truly needed.

Do I still need next-seo with the App Router?

For most projects, no. The built in Metadata API covers titles, descriptions, canonicals, Open Graph, robots directives and alternate languages. Packages like next-seo became popular with the older Pages Router, before this API existed.

How do I add a canonical URL in Next.js?

Set alternates.canonical in the page's metadata export, or in the object returned from generateMetadata. With metadataBase set in the root layout, you can use a relative path such as /services and Next.js builds the full URL.

If your Next.js site is not showing up in search the way it should, or you are planning a new build and want SEO right from the first commit, see how we approach Next.js and React development or get in touch. We will review the rendering, metadata and performance and tell you what to fix.

Saqib Zahoor
Saqib Zahoor
Full Stack Web Developer

Founder and lead full stack developer, 6+ years building sites and web apps for clients worldwide. 230+ projects shipped, 4.9★ on Fiverr, 5.0★ on Upwork, PSEB registered.

Let's talk

Need help building this?

Tell us what you want to build. We will give you an honest plan, a clear timeline, and a fair price. No pressure.

Chat with usReplies in minutes