35% OFF
Ends14d 00:00:00
Shop
Back to Blog
nextjsseotypescriptwebdev

SEO & Metadata in Next.js 15 — The Patterns I Actually Use

Pixel Anas··6 min read

How the Next.js 15 Metadata API, generateMetadata, sitemaps, and structured data fit together — and the mistakes that quietly kill click-through rate.

Good technical SEO doesn't win you rankings by itself. What it does is make sure a page that deserves to rank isn't held back by a missing title tag, a broken canonical, or a meta description that gives Google nothing to work with.

Here's the exact metadata setup I use in every Next.js 15 project, and the mistakes that quietly cost clicks even when a page is already ranking.


1. Static Metadata — The Baseline

Every page can export a metadata object directly:

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

export const metadata: Metadata = {
  title: 'About — Pixel Anas',
  description: 'Full-stack Next.js developer building SaaS products and premium templates.',
};

export default function AboutPage() {
  return <div>...</div>;
}

This replaces manually writing <title> and <meta> tags into a <head> — Next.js injects them for you, and it composes correctly with layouts (more on that next).


2. Dynamic Metadata for Data-Driven Pages

Static metadata doesn't work for a blog post or product page where the title depends on the content itself. generateMetadata runs before the page renders and can fetch the same data your page needs:

// app/blog/[slug]/page.tsx
import { Metadata } from 'next';

interface 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 { title: 'Post not found' };
  }

  return {
    title: `${post.title} — Pixel Anas`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
      type: 'article',
      publishedTime: post.publishedAt,
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  // ...
}

This is exactly the gap that leaves impressions with zero clicks — a generic or missing description means Google either writes its own snippet or shows nothing compelling. post.excerpt here should be written like ad copy, not just the first sentence of the post.


3. Title Templates — Consistent Branding Without Repeating Yourself

Set a template once in the root layout, and every page's title composes into it automatically:

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    default: 'Pixel Anas — Full-Stack Developer',
    template: '%s — Pixel Anas',
  },
  description: 'Full-stack Next.js developer building SaaS products and premium templates.',
};

Now a page just exports title: 'About', and Next.js renders it as About — Pixel Anas. Change the brand suffix once in the layout instead of editing every page when you rebrand.


4. Canonical URLs — Fixing the www / non-www Split

This is a common one: pixelanas.com and www.pixelanas.com can end up indexed as two separate pages, splitting the ranking signal that should be consolidated on one URL. Metadata handles half the fix:

export const metadata: Metadata = {
  alternates: {
    canonical: 'https://www.pixelanas.com/about',
  },
};

But the canonical tag alone isn't enough — you also need an actual redirect so both versions resolve to one:

// next.config.ts
const nextConfig = {
  async redirects() {
    return [
      {
        source: '/:path*',
        has: [{ type: 'host', value: 'pixelanas.com' }],
        destination: 'https://www.pixelanas.com/:path*',
        permanent: true,
      },
    ];
  },
};

export default nextConfig;

Without the redirect, both URLs stay live and crawlable, and Google treats them as competing pages instead of one canonical one — which is exactly the kind of split that shows up as two rows for the same page in Search Console.


5. sitemap.ts — Generated, Not Hand-Written

Next.js 15 lets you generate sitemap.xml from a file instead of maintaining it by hand:

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

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

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

  return [
    {
      url: 'https://www.pixelanas.com',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: 'https://www.pixelanas.com/templates',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.9,
    },
    ...postEntries,
  ];
}

Visiting /sitemap.xml now renders this automatically. Every new blog post added to the database shows up in the sitemap on the next build — no manual XML editing, no forgetting to add a new page.


6. robots.ts — Controlling What Gets Crawled

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

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/api/', '/dashboard/'],
    },
    sitemap: 'https://www.pixelanas.com/sitemap.xml',
  };
}

Pointing sitemap here means crawlers discover your sitemap directly from robots.txt, without needing it manually submitted anywhere first.


7. Structured Data — Give Google the Answer Directly

JSON-LD doesn't change what users see, but it helps Google understand exactly what a page is — which can unlock rich results like star ratings, article dates, or breadcrumbs in the search listing:

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    datePublished: post.publishedAt,
    author: { '@type': 'Person', name: 'Anas' },
  };

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

dangerouslySetInnerHTML here is fine — this is your own generated JSON, not user input, so there's no injection risk in this specific case.


The Mistake That Costs the Most: Writing Descriptions for Search Engines, Not Humans

Everything above gets the metadata infrastructure right. It won't fix a description like "Blog post about MongoDB and Next.js integration patterns and best practices" — technically accurate, keyword-present, and something nobody clicks on.

A description that pulls clicks reads like a reason to click: what the reader gets, phrased like you're talking to a person scanning search results, not a bot indexing keywords. High impressions with a flat click-through rate is almost always a description problem, not a ranking problem — the page is already being shown, it just isn't convincing anyone once they see it.


Summary

| Pattern | Fixes | |---------|-------| | generateMetadata | Per-page dynamic titles/descriptions from real content | | Title templates in root layout | Consistent branding without repeating the suffix everywhere | | alternates.canonical + redirect | www / non-www and http/https split-indexing | | sitemap.ts | Auto-updating sitemap as content is added | | robots.ts | Crawl control + sitemap discovery in one file | | JSON-LD structured data | Rich result eligibility (dates, breadcrumbs, ratings) | | Human-written descriptions | The actual click-through-rate lever, not the infrastructure |

I use this exact metadata setup — layout-level templates, generateMetadata for dynamic pages, generated sitemap and robots files — on pixelanas.com itself and on every template I build.

Get the templates: https://pixelanas.gumroad.com


Anas — full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751