Skip to main content
Back to blog

SEO and metadata in Next.js App Router: the definitive guide

Ray MartínRay Martín
9 min read
SEO and metadata in Next.js App Router: the definitive guide

The Metadata API in Next.js App Router

Next.js App Router introduces a powerful Metadata API that gives you full control over your application's SEO from within your components. Unlike the old next/head approach, the new API integrates directly with Server Components, enabling both static and dynamic metadata generation with type safety.

Proper metadata is the foundation of search engine optimization. It tells search engines what your pages are about, how they should be indexed, and how they should appear in search results. Without well-configured metadata, even the best content will struggle to rank.

Static Metadata

The simplest way to add metadata is by exporting a metadata object from your page or layout file. This approach works for pages where the metadata does not depend on dynamic data like URL parameters or database content.

typescript
// app/[locale]/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Ray Martin — Fullstack Product Developer",
  description:
    "Portfolio and agency site for Ray Martin. Building modern web applications with Next.js, React, and TypeScript.",
  keywords: ["Next.js", "React", "TypeScript", "fullstack developer", "portfolio"],
  authors: [{ name: "Ray Martin", url: "https://raymartin.es" }],
  creator: "Ray Martin",
  publisher: "Ray Martin",
  metadataBase: new URL("https://raymartin.es"),
  alternates: {
    canonical: "/",
    languages: {
      "es": "/es",
      "en": "/en",
    },
  },
};

export default function HomePage() {
  return <main>...</main>;
}

Static metadata is evaluated at build time and embedded directly into the HTML. This is the most performant approach since no runtime computation is needed.

Dynamic Metadata with generateMetadata

For pages that depend on dynamic data — such as blog posts, product pages, or user profiles — use the generateMetadata function. This async function receives the page params and search params, allowing you to fetch data and construct metadata dynamically.

typescript
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";

interface PageProps {
  params: Promise<{ locale: string; slug: string }>;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { locale, slug } = await params;
  const post = await getPostBySlug(slug, locale);

  if (!post) {
    return {
      title: "Post Not Found",
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author],
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages: {
        "es": `/es/blog/${slug}`,
        "en": `/en/blog/${slug}`,
      },
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const { locale, slug } = await params;
  const post = await getPostBySlug(slug, locale);
  return <article>...</article>;
}

Next.js automatically deduplicates fetch calls made in both generateMetadata and the page component itself. This means you can safely call getPostBySlug in both places without making redundant network requests.

Open Graph Tags

Open Graph tags control how your pages appear when shared on social media platforms like Facebook, LinkedIn, and messaging apps. A well-configured Open Graph setup can dramatically increase click-through rates from social shares.

typescript
// app/[locale]/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  openGraph: {
    title: "Ray Martin — Fullstack Product Developer",
    description: "Building modern web applications with Next.js and React.",
    url: "https://raymartin.es",
    siteName: "Ray Martin Portfolio",
    locale: "es_ES",
    alternateLocale: "en_US",
    type: "website",
    images: [
      {
        url: "https://raymartin.es/og-image.jpg",
        width: 1200,
        height: 630,
        alt: "Ray Martin Portfolio Preview",
        type: "image/jpeg",
      },
      {
        url: "https://raymartin.es/og-image-square.jpg",
        width: 600,
        height: 600,
        alt: "Ray Martin Logo",
        type: "image/jpeg",
      },
    ],
  },
};

Key Open Graph best practices:

  • og:image dimensions: Use 1200x630 pixels for the primary image — this is the recommended size for most social platforms
  • og:title length: Keep titles under 60 characters to avoid truncation in social previews
  • og:description length: Aim for 120-160 characters for optimal display
  • og:type: Use "website" for landing pages and "article" for blog posts
  • og:locale: Set the correct locale for your primary language
  • alternateLocale: List all other supported languages

Dynamic Open Graph Images

Next.js allows you to generate Open Graph images dynamically using the ImageResponse API. This lets you create custom social preview images with text, styles, and even data from your content.

typescript
// app/[locale]/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const alt = "Blog post cover image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: { slug: string };
}) {
  const post = await fetch(
    `https://raymartin.es/api/posts/${params.slug}`
  ).then((res) => res.json());

  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          alignItems: "flex-start",
          width: "100%",
          height: "100%",
          padding: "60px",
          background: "linear-gradient(135deg, #0c4a6e 0%, #082f49 100%)",
          color: "white",
          fontFamily: "Inter, sans-serif",
        }}
      >
        <div style={{ fontSize: 48, fontWeight: 700, marginBottom: 20 }}>
          {post.title}
        </div>
        <div style={{ fontSize: 24, opacity: 0.8 }}>
          {post.excerpt}
        </div>
        <div style={{ fontSize: 20, marginTop: "auto", opacity: 0.6 }}>
          raymartin.es
        </div>
      </div>
    ),
    { ...size }
  );
}

Twitter Card Metadata

Twitter (X) uses its own set of meta tags to generate card previews when links are shared. Next.js supports both summary and summary_large_image card types.

typescript
// app/[locale]/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  twitter: {
    card: "summary_large_image",
    title: "Ray Martin — Fullstack Product Developer",
    description: "Building modern web applications with Next.js and React.",
    site: "@raymartin_dev",
    creator: "@raymartin_dev",
    images: {
      url: "https://raymartin.es/twitter-card.jpg",
      alt: "Ray Martin Portfolio Preview",
      width: 1200,
      height: 630,
    },
  },
};

Tip: If Twitter card tags are not provided, Twitter will fall back to Open Graph tags. However, explicitly setting Twitter metadata gives you more control over how your content appears on the platform.

JSON-LD Structured Data

JSON-LD structured data helps search engines understand the content and context of your pages. It enables rich results in Google Search, including breadcrumbs, article cards, FAQs, and organization knowledge panels.

Article Schema

typescript
// app/[locale]/blog/[slug]/page.tsx
import Script from "next/script";

interface ArticleJsonLd {
  "@context": "https://schema.org";
  "@type": "Article";
  headline: string;
  description: string;
  image: string[];
  datePublished: string;
  dateModified: string;
  author: { "@type": "Person"; name: string; url: string };
  publisher: {
    "@type": "Organization";
    name: string;
    logo: { "@type": "ImageObject"; url: string };
  };
}

function getArticleJsonLd(post: BlogPost): ArticleJsonLd {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    image: [post.coverImage],
    datePublished: post.publishedAt,
    dateModified: post.updatedAt || post.publishedAt,
    author: {
      "@type": "Person",
      name: "Ray Martin",
      url: "https://raymartin.es",
    },
    publisher: {
      "@type": "Organization",
      name: "Ray Martin Dev",
      logo: {
        "@type": "ImageObject",
        url: "https://raymartin.es/logo.png",
      },
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const post = await getPostBySlug(params.slug);

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(getArticleJsonLd(post)),
        }}
      />
      <article>...</article>
    </>
  );
}
typescript
// components/common/Breadcrumbs.tsx
interface BreadcrumbItem {
  name: string;
  url: string;
}

function getBreadcrumbJsonLd(items: BreadcrumbItem[]) {
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: items.map((item, index) => ({
      "@type": "ListItem",
      position: index + 1,
      name: item.name,
      item: item.url,
    })),
  };
}

export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(getBreadcrumbJsonLd(items)),
        }}
      />
      <nav aria-label="Breadcrumb">
        <ol className="flex items-center gap-2 text-sm">
          {items.map((item, index) => (
            <li key={item.url}>
              {index < items.length - 1 ? (
                <a href={item.url}>{item.name}</a>
              ) : (
                <span aria-current="page">{item.name}</span>
              )}
            </li>
          ))}
        </ol>
      </nav>
    </>
  );
}

Organization Schema

typescript
// app/[locale]/layout.tsx
const organizationJsonLd = {
  "@context": "https://schema.org",
  "@type": "Organization",
  name: "Ray Martin Dev",
  url: "https://raymartin.es",
  logo: "https://raymartin.es/logo.png",
  sameAs: [
    "https://github.com/raymartin",
    "https://linkedin.com/in/raymartin",
    "https://twitter.com/raymartin_dev",
  ],
  contactPoint: {
    "@type": "ContactPoint",
    contactType: "customer service",
    availableLanguage: ["Spanish", "English"],
  },
};

Sitemap Generation with sitemap.ts

A sitemap tells search engines which pages exist on your site and how frequently they change. Next.js App Router supports sitemap generation through a sitemap.ts file in the app/ directory.

typescript
// app/sitemap.ts
import type { MetadataRoute } from "next";

const baseUrl = "https://raymartin.es";
const locales = ["es", "en"];

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const staticRoutes = [
    "",
    "/services",
    "/experience",
    "/skills",
    "/about",
    "/projects",
  ];

  const staticEntries = locales.flatMap((locale) =>
    staticRoutes.map((route) => ({
      url: `${baseUrl}/${locale}${route}`,
      lastModified: new Date(),
      changeFrequency: "monthly" as const,
      priority: route === "" ? 1.0 : 0.8,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}${route}`])
        ),
      },
    }))
  );

  // Dynamic blog entries
  const posts = await getAllPosts();
  const blogEntries = locales.flatMap((locale) =>
    posts.map((post) => ({
      url: `${baseUrl}/${locale}/blog/${post.slug}`,
      lastModified: new Date(post.updatedAt || post.publishedAt),
      changeFrequency: "weekly" as const,
      priority: 0.6,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}/blog/${post.slug}`])
        ),
      },
    }))
  );

  return [...staticEntries, ...blogEntries];
}

The sitemap is automatically served at /sitemap.xml. For large sites with thousands of pages, consider using the generateSitemaps function to create multiple sitemap files with an index.

Robots.txt Configuration with robots.ts

The robots.ts file controls which pages search engines can crawl. Place it in the app/ directory alongside your sitemap.

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

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/_next/"],
      },
      {
        userAgent: "Googlebot",
        allow: "/",
        disallow: "/api/",
      },
    ],
    sitemap: "https://raymartin.es/sitemap.xml",
  };
}

Important: Always include your sitemap URL in the robots.txt output. This ensures search engines can discover your sitemap automatically without needing to manually submit it in Google Search Console.

Canonical URLs and Hreflang Tags

Canonical URLs prevent duplicate content issues by telling search engines which version of a page is the "original." For multilingual sites, hreflang tags inform search engines about the relationship between pages in different languages.

typescript
// app/[locale]/layout.tsx
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;

  return {
    metadataBase: new URL("https://raymartin.es"),
    alternates: {
      canonical: `/${locale}`,
      languages: {
        "es": "/es",
        "en": "/en",
        "x-default": "/es",
      },
    },
  };
}

This generates the following HTML in the <head>:

html
<link rel="canonical" href="https://raymartin.es/es" />
<link rel="alternate" hreflang="es" href="https://raymartin.es/es" />
<link rel="alternate" hreflang="en" href="https://raymartin.es/en" />
<link rel="alternate" hreflang="x-default" href="https://raymartin.es/es" />
  • canonical: Points to the current page's URL — prevents search engines from treating locale variants as duplicates
  • hreflang: Maps each language to its corresponding URL so Google serves the right version to each user
  • x-default: Specifies the fallback page for users whose language is not explicitly supported

Core Web Vitals Optimization

Core Web Vitals are a set of metrics that Google uses as ranking signals. Optimizing these metrics directly impacts your search engine rankings and user experience.

LCP — Largest Contentful Paint

LCP measures how quickly the largest visible element loads. Target under 2.5 seconds.

typescript
// Optimize LCP with priority images and font preloading
import Image from "next/image";
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap", // Prevents font-related layout shifts
});

export function HeroBanner() {
  return (
    <section className={inter.className}>
      <Image
        src="/images/hero.webp"
        alt="Hero banner showing modern web development workspace"
        width={1200}
        height={600}
        priority
        fetchPriority="high"
        sizes="100vw"
        quality={85}
      />
      <h1>Fullstack Product Developer</h1>
    </section>
  );
}
  • priority: Preloads the image and disables lazy loading
  • fetchPriority="high": Tells the browser to prioritize this resource
  • next/font: Self-hosts fonts and eliminates external network requests
  • display: "swap": Shows fallback text while the font loads

CLS — Cumulative Layout Shift

CLS measures unexpected visual movement. Target under 0.1.

typescript
// Prevent CLS by reserving space for dynamic content
export function VideoEmbed({ videoId }: { videoId: string }) {
  return (
    <div className="relative w-full" style={{ aspectRatio: "16/9" }}>
      <iframe
        src={`https://www.youtube.com/embed/${videoId}`}
        title="Embedded video"
        className="absolute inset-0 w-full h-full"
        loading="lazy"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
        allowFullScreen
      />
    </div>
  );
}

// Always specify width and height for images
<Image
  src="/images/photo.jpg"
  alt="Description"
  width={800}
  height={450}
/>

INP — Interaction to Next Paint

INP measures responsiveness to user interactions. Target under 200ms.

typescript
"use client";

import { useTransition } from "react";

export function SearchFilter({ onFilter }: { onFilter: (q: string) => void }) {
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Wrap heavy state updates in startTransition to keep the UI responsive
    startTransition(() => {
      onFilter(e.target.value);
    });
  };

  return (
    <div>
      <input
        type="search"
        onChange={handleChange}
        placeholder="Search..."
        aria-label="Search"
      />
      {isPending && <span className="text-sm text-gray-500">Filtering...</span>}
    </div>
  );
}

Image Optimization for SEO

Images play a critical role in SEO. Search engines evaluate alt text, file names, loading performance, and responsive sizing. Next.js provides built-in tools to handle all of these aspects.

typescript
// Comprehensive image SEO configuration
import Image from "next/image";

export function ProjectCard({ project }: { project: Project }) {
  return (
    <article>
      <Image
        src={project.image}
        alt={`Screenshot of ${project.title} — ${project.shortDescription}`}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        loading="lazy"
        className="rounded-lg"
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </article>
  );
}
  • Alt text: Write descriptive, unique alt text that explains the image content — avoid keyword stuffing
  • File names: Use descriptive, kebab-case names like nextjs-dashboard-screenshot.webp
  • sizes attribute: Helps the browser pick the optimal image size for the viewport, reducing bandwidth
  • loading="lazy": Defers loading of below-fold images until they are near the viewport
  • WebP/AVIF formats: Next.js automatically serves modern formats when supported by the browser
  • Responsive images: The sizes prop combined with Next.js image optimization serves appropriately sized images

Monitoring SEO with Google Search Console

Google Search Console is essential for monitoring your site's search performance and identifying SEO issues. After deploying your Next.js application, set up Search Console to track indexing, performance, and any crawl errors.

Verification Methods

typescript
// app/[locale]/layout.tsx — HTML tag verification
import type { Metadata } from "next";

export const metadata: Metadata = {
  verification: {
    google: "your-google-verification-code",
    yandex: "your-yandex-verification-code",
    other: {
      "msvalidate.01": "your-bing-verification-code",
    },
  },
};

Key Metrics to Monitor

  1. Index Coverage: Check which pages are indexed and identify any crawl errors or pages excluded from the index
  2. Performance Report: Track impressions, clicks, CTR, and average position for your target keywords
  3. Core Web Vitals Report: Monitor real-user data for LCP, CLS, and INP across mobile and desktop
  4. Mobile Usability: Ensure all pages pass mobile-friendliness tests
  5. Sitemaps: Submit your sitemap and verify all pages are being discovered
  6. Rich Results: Validate that your JSON-LD structured data generates rich results correctly

Pro tip: Use the URL Inspection tool to test individual pages. It shows you exactly how Googlebot sees your page, including rendered HTML, detected structured data, and any indexing issues. For Next.js applications using Server Components, the rendered HTML that Googlebot sees includes all server-rendered content, which is excellent for SEO.

Combining proper metadata configuration, structured data, performance optimization, and continuous monitoring creates a strong SEO foundation. The Next.js Metadata API makes it straightforward to implement all these best practices within your component architecture, keeping your SEO concerns close to the content they describe.

Share:

Related articles