Initial commit: Portfolio Website
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>
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user