Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
662 lines
16 KiB
Markdown
662 lines
16 KiB
Markdown
# Next.js SEO & Metadata Optimization
|
||
|
||
**Meta-Description:** SEO für Next.js 15. Metadata API, Open Graph, Twitter Cards und dynamische Meta-Tags für bessere Suchmaschinenplatzierung.
|
||
|
||
**Keywords:** Next.js SEO, Metadata API, Open Graph, Twitter Cards, Meta Tags, generateMetadata, SEO Optimization
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
**Next.js 15** bietet eine revolutionäre **Metadata API** für SEO. Type-safe, automatisch optimiert und perfekt für dynamische Inhalte – von Blogs bis E-Commerce.
|
||
|
||
---
|
||
|
||
## Metadata API Overview
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ NEXT.JS METADATA SYSTEM │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Static Metadata: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ export const metadata = { │ │
|
||
│ │ title: 'Page Title', │ │
|
||
│ │ description: 'Page description' │ │
|
||
│ │ } │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Dynamic Metadata: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ export async function generateMetadata({ params }) │ │
|
||
│ │ return { title: await getTitle(params.id) } │ │
|
||
│ │ } │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Inheritance: │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ layout.tsx (Base) │ │
|
||
│ │ ↓ │ │
|
||
│ │ page.tsx (Override/Extend) │ │
|
||
│ │ ↓ │ │
|
||
│ │ Final <head> (Merged & Deduplicated) │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Generated: │
|
||
│ ├── <title> │
|
||
│ ├── <meta name="description"> │
|
||
│ ├── <meta property="og:*"> │
|
||
│ ├── <meta name="twitter:*"> │
|
||
│ ├── <link rel="canonical"> │
|
||
│ └── <script type="application/ld+json"> │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Static Metadata
|
||
|
||
```typescript
|
||
// app/layout.tsx
|
||
import type { Metadata } from 'next';
|
||
|
||
export const metadata: Metadata = {
|
||
// Basis
|
||
title: {
|
||
default: 'Meine Website',
|
||
template: '%s | Meine Website' // Für Child Pages
|
||
},
|
||
description: 'Die beste Website für ...',
|
||
|
||
// Keywords (weniger wichtig für Google, aber für andere)
|
||
keywords: ['Next.js', 'React', 'SEO'],
|
||
|
||
// Autoren
|
||
authors: [{ name: 'Max Mustermann', url: 'https://example.com' }],
|
||
creator: 'Max Mustermann',
|
||
publisher: 'Meine Firma',
|
||
|
||
// Robots
|
||
robots: {
|
||
index: true,
|
||
follow: true,
|
||
googleBot: {
|
||
index: true,
|
||
follow: true,
|
||
'max-video-preview': -1,
|
||
'max-image-preview': 'large',
|
||
'max-snippet': -1
|
||
}
|
||
},
|
||
|
||
// Icons
|
||
icons: {
|
||
icon: '/favicon.ico',
|
||
shortcut: '/favicon-16x16.png',
|
||
apple: '/apple-touch-icon.png'
|
||
},
|
||
|
||
// Manifest
|
||
manifest: '/site.webmanifest',
|
||
|
||
// Open Graph
|
||
openGraph: {
|
||
type: 'website',
|
||
locale: 'de_DE',
|
||
url: 'https://example.com',
|
||
siteName: 'Meine Website',
|
||
title: 'Meine Website',
|
||
description: 'Die beste Website für ...',
|
||
images: [
|
||
{
|
||
url: 'https://example.com/og-image.jpg',
|
||
width: 1200,
|
||
height: 630,
|
||
alt: 'Meine Website'
|
||
}
|
||
]
|
||
},
|
||
|
||
// Twitter
|
||
twitter: {
|
||
card: 'summary_large_image',
|
||
title: 'Meine Website',
|
||
description: 'Die beste Website für ...',
|
||
images: ['https://example.com/twitter-image.jpg'],
|
||
creator: '@username'
|
||
},
|
||
|
||
// Verification
|
||
verification: {
|
||
google: 'google-site-verification-code',
|
||
yandex: 'yandex-verification-code',
|
||
other: {
|
||
'msvalidate.01': 'bing-verification-code'
|
||
}
|
||
},
|
||
|
||
// Alternates (für i18n)
|
||
alternates: {
|
||
canonical: 'https://example.com',
|
||
languages: {
|
||
'de-DE': 'https://example.com/de',
|
||
'en-US': 'https://example.com/en'
|
||
}
|
||
},
|
||
|
||
// Category
|
||
category: 'technology'
|
||
};
|
||
|
||
export default function RootLayout({
|
||
children
|
||
}: {
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<html lang="de">
|
||
<body>{children}</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Dynamic Metadata
|
||
|
||
```typescript
|
||
// app/blog/[slug]/page.tsx
|
||
import type { Metadata, ResolvingMetadata } from 'next';
|
||
import { getBlogPost, getAllPosts } from '@/lib/blog';
|
||
import { notFound } from 'next/navigation';
|
||
|
||
interface PageProps {
|
||
params: Promise<{ slug: string }>;
|
||
}
|
||
|
||
// Dynamische Metadata Generation
|
||
export async function generateMetadata(
|
||
{ params }: PageProps,
|
||
parent: ResolvingMetadata
|
||
): Promise<Metadata> {
|
||
const { slug } = await params;
|
||
const post = await getBlogPost(slug);
|
||
|
||
if (!post) {
|
||
return {
|
||
title: 'Not Found'
|
||
};
|
||
}
|
||
|
||
// Parent Metadata für Merge
|
||
const previousImages = (await parent).openGraph?.images || [];
|
||
|
||
return {
|
||
title: post.title,
|
||
description: post.excerpt,
|
||
authors: [{ name: post.author.name }],
|
||
|
||
openGraph: {
|
||
title: post.title,
|
||
description: post.excerpt,
|
||
type: 'article',
|
||
publishedTime: post.publishedAt,
|
||
modifiedTime: post.updatedAt,
|
||
authors: [post.author.name],
|
||
tags: post.tags,
|
||
images: [
|
||
{
|
||
url: post.coverImage,
|
||
width: 1200,
|
||
height: 630,
|
||
alt: post.title
|
||
},
|
||
...previousImages
|
||
]
|
||
},
|
||
|
||
twitter: {
|
||
card: 'summary_large_image',
|
||
title: post.title,
|
||
description: post.excerpt,
|
||
images: [post.coverImage]
|
||
},
|
||
|
||
alternates: {
|
||
canonical: `https://example.com/blog/${slug}`
|
||
}
|
||
};
|
||
}
|
||
|
||
// Static Params für Build-Zeit Generation
|
||
export async function generateStaticParams() {
|
||
const posts = await getAllPosts();
|
||
return posts.map(post => ({ slug: post.slug }));
|
||
}
|
||
|
||
export default async function BlogPost({ params }: PageProps) {
|
||
const { slug } = await params;
|
||
const post = await getBlogPost(slug);
|
||
|
||
if (!post) {
|
||
notFound();
|
||
}
|
||
|
||
return (
|
||
<article>
|
||
<h1>{post.title}</h1>
|
||
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||
</article>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Dynamic OG Images
|
||
|
||
```typescript
|
||
// app/blog/[slug]/opengraph-image.tsx
|
||
import { ImageResponse } from 'next/og';
|
||
import { getBlogPost } from '@/lib/blog';
|
||
|
||
export const runtime = 'edge';
|
||
export const alt = 'Blog Post Image';
|
||
export const size = { width: 1200, height: 630 };
|
||
export const contentType = 'image/png';
|
||
|
||
export default async function OGImage({
|
||
params
|
||
}: {
|
||
params: { slug: string };
|
||
}) {
|
||
const post = await getBlogPost(params.slug);
|
||
|
||
// Font laden
|
||
const interBold = fetch(
|
||
new URL('./Inter-Bold.ttf', import.meta.url)
|
||
).then(res => res.arrayBuffer());
|
||
|
||
return new ImageResponse(
|
||
(
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'flex-start',
|
||
justifyContent: 'flex-end',
|
||
backgroundColor: '#1a1a2e',
|
||
padding: 60,
|
||
backgroundImage: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
|
||
}}
|
||
>
|
||
{/* Logo */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: 60,
|
||
left: 60,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 16
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
backgroundColor: '#00d9ff',
|
||
borderRadius: 8
|
||
}}
|
||
/>
|
||
<span style={{ color: '#ffffff', fontSize: 24 }}>
|
||
Meine Website
|
||
</span>
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div
|
||
style={{
|
||
fontSize: 64,
|
||
fontWeight: 'bold',
|
||
color: '#ffffff',
|
||
lineHeight: 1.2,
|
||
maxWidth: '80%'
|
||
}}
|
||
>
|
||
{post?.title || 'Blog Post'}
|
||
</div>
|
||
|
||
{/* Meta */}
|
||
<div
|
||
style={{
|
||
marginTop: 24,
|
||
display: 'flex',
|
||
gap: 24,
|
||
color: '#888888',
|
||
fontSize: 24
|
||
}}
|
||
>
|
||
<span>{post?.author.name}</span>
|
||
<span>•</span>
|
||
<span>{post?.readingTime} min read</span>
|
||
</div>
|
||
</div>
|
||
),
|
||
{
|
||
...size,
|
||
fonts: [
|
||
{
|
||
name: 'Inter',
|
||
data: await interBold,
|
||
style: 'normal',
|
||
weight: 700
|
||
}
|
||
]
|
||
}
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Sitemap Generation
|
||
|
||
```typescript
|
||
// app/sitemap.ts
|
||
import { MetadataRoute } from 'next';
|
||
import { getAllPosts } from '@/lib/blog';
|
||
import { getAllProducts } from '@/lib/products';
|
||
|
||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||
const baseUrl = 'https://example.com';
|
||
|
||
// Static Pages
|
||
const staticPages: MetadataRoute.Sitemap = [
|
||
{
|
||
url: baseUrl,
|
||
lastModified: new Date(),
|
||
changeFrequency: 'daily',
|
||
priority: 1.0
|
||
},
|
||
{
|
||
url: `${baseUrl}/about`,
|
||
lastModified: new Date(),
|
||
changeFrequency: 'monthly',
|
||
priority: 0.8
|
||
},
|
||
{
|
||
url: `${baseUrl}/contact`,
|
||
lastModified: new Date(),
|
||
changeFrequency: 'yearly',
|
||
priority: 0.5
|
||
}
|
||
];
|
||
|
||
// Blog Posts
|
||
const posts = await getAllPosts();
|
||
const blogPages: MetadataRoute.Sitemap = posts.map(post => ({
|
||
url: `${baseUrl}/blog/${post.slug}`,
|
||
lastModified: new Date(post.updatedAt),
|
||
changeFrequency: 'weekly',
|
||
priority: 0.7
|
||
}));
|
||
|
||
// Products
|
||
const products = await getAllProducts();
|
||
const productPages: MetadataRoute.Sitemap = products.map(product => ({
|
||
url: `${baseUrl}/products/${product.slug}`,
|
||
lastModified: new Date(product.updatedAt),
|
||
changeFrequency: 'daily',
|
||
priority: 0.9
|
||
}));
|
||
|
||
return [...staticPages, ...blogPages, ...productPages];
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Robots.txt
|
||
|
||
```typescript
|
||
// app/robots.ts
|
||
import { MetadataRoute } from 'next';
|
||
|
||
export default function robots(): MetadataRoute.Robots {
|
||
const baseUrl = 'https://example.com';
|
||
|
||
return {
|
||
rules: [
|
||
{
|
||
userAgent: '*',
|
||
allow: '/',
|
||
disallow: ['/admin/', '/api/', '/private/']
|
||
},
|
||
{
|
||
userAgent: 'Googlebot',
|
||
allow: '/',
|
||
disallow: ['/admin/']
|
||
}
|
||
],
|
||
sitemap: `${baseUrl}/sitemap.xml`,
|
||
host: baseUrl
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## JSON-LD Structured Data
|
||
|
||
```typescript
|
||
// components/JsonLd.tsx
|
||
import Script from 'next/script';
|
||
|
||
interface ArticleJsonLdProps {
|
||
title: string;
|
||
description: string;
|
||
publishedTime: string;
|
||
modifiedTime: string;
|
||
authorName: string;
|
||
images: string[];
|
||
url: string;
|
||
}
|
||
|
||
export function ArticleJsonLd({
|
||
title,
|
||
description,
|
||
publishedTime,
|
||
modifiedTime,
|
||
authorName,
|
||
images,
|
||
url
|
||
}: ArticleJsonLdProps) {
|
||
const jsonLd = {
|
||
'@context': 'https://schema.org',
|
||
'@type': 'Article',
|
||
headline: title,
|
||
description,
|
||
image: images,
|
||
datePublished: publishedTime,
|
||
dateModified: modifiedTime,
|
||
author: {
|
||
'@type': 'Person',
|
||
name: authorName
|
||
},
|
||
publisher: {
|
||
'@type': 'Organization',
|
||
name: 'Meine Website',
|
||
logo: {
|
||
'@type': 'ImageObject',
|
||
url: 'https://example.com/logo.png'
|
||
}
|
||
},
|
||
mainEntityOfPage: {
|
||
'@type': 'WebPage',
|
||
'@id': url
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Script
|
||
id="article-jsonld"
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Verwendung in Page
|
||
export default function BlogPost({ post }) {
|
||
return (
|
||
<>
|
||
<ArticleJsonLd
|
||
title={post.title}
|
||
description={post.excerpt}
|
||
publishedTime={post.publishedAt}
|
||
modifiedTime={post.updatedAt}
|
||
authorName={post.author.name}
|
||
images={[post.coverImage]}
|
||
url={`https://example.com/blog/${post.slug}`}
|
||
/>
|
||
<article>...</article>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## SEO Komponenten Library
|
||
|
||
```typescript
|
||
// lib/seo.ts
|
||
import { Metadata } from 'next';
|
||
|
||
interface SEOConfig {
|
||
title: string;
|
||
description: string;
|
||
canonical?: string;
|
||
image?: string;
|
||
type?: 'website' | 'article' | 'product';
|
||
publishedTime?: string;
|
||
modifiedTime?: string;
|
||
author?: string;
|
||
tags?: string[];
|
||
noindex?: boolean;
|
||
}
|
||
|
||
export function generateSEO(config: SEOConfig): Metadata {
|
||
const {
|
||
title,
|
||
description,
|
||
canonical,
|
||
image = 'https://example.com/default-og.jpg',
|
||
type = 'website',
|
||
publishedTime,
|
||
modifiedTime,
|
||
author,
|
||
tags,
|
||
noindex = false
|
||
} = config;
|
||
|
||
return {
|
||
title,
|
||
description,
|
||
|
||
robots: noindex ? {
|
||
index: false,
|
||
follow: false
|
||
} : {
|
||
index: true,
|
||
follow: true
|
||
},
|
||
|
||
alternates: canonical ? {
|
||
canonical
|
||
} : undefined,
|
||
|
||
openGraph: {
|
||
title,
|
||
description,
|
||
type,
|
||
url: canonical,
|
||
images: [{ url: image, width: 1200, height: 630 }],
|
||
...(publishedTime && { publishedTime }),
|
||
...(modifiedTime && { modifiedTime }),
|
||
...(author && { authors: [author] }),
|
||
...(tags && { tags })
|
||
},
|
||
|
||
twitter: {
|
||
card: 'summary_large_image',
|
||
title,
|
||
description,
|
||
images: [image]
|
||
}
|
||
};
|
||
}
|
||
|
||
// Verwendung
|
||
export const metadata = generateSEO({
|
||
title: 'Mein Artikel',
|
||
description: 'Beschreibung...',
|
||
canonical: 'https://example.com/artikel',
|
||
type: 'article',
|
||
author: 'Max Mustermann'
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## SEO Checklist
|
||
|
||
| Element | Empfehlung |
|
||
|---------|------------|
|
||
| **Title** | 50-60 Zeichen, Keyword am Anfang |
|
||
| **Description** | 150-160 Zeichen, CTA |
|
||
| **H1** | Nur eine pro Seite |
|
||
| **Images** | Alt-Text, optimierte Größe |
|
||
| **URLs** | Kurz, beschreibend, Bindestrich |
|
||
| **Canonical** | Immer setzen |
|
||
| **OG Image** | 1200x630px |
|
||
| **Sitemap** | Alle wichtigen Seiten |
|
||
| **robots.txt** | Korrekt konfiguriert |
|
||
| **JSON-LD** | Strukturierte Daten |
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Next.js SEO bietet:
|
||
|
||
1. **Metadata API**: Type-safe, automatisch optimiert
|
||
2. **Dynamic OG Images**: Generiert on-the-fly
|
||
3. **Sitemap/Robots**: File-based Generation
|
||
4. **JSON-LD**: Strukturierte Daten Support
|
||
|
||
SEO-Optimierung war nie einfacher.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "SEO dashboard showing metadata configuration, Next.js interface"
|
||
2. "Open Graph preview on social media, rich snippet display"
|
||
3. "Sitemap visualization with page hierarchy, SEO structure"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [Next.js Metadata Documentation](https://nextjs.org/docs/app/api-reference/functions/generate-metadata)
|
||
- [Strapi Next.js SEO Guide](https://strapi.io/blog/nextjs-seo)
|
||
- [Medium SEO Guide](https://medium.com/@thomasaugot/the-complete-guide-to-seo-optimization-in-next-js-15-1bdb118cffd7)
|
||
- [Google Search Central](https://developers.google.com/search/docs)
|