Appearance
Kapitel 11: Praxisprojekt - Persönlicher Blog
📖 Lernziele
In diesem Kapitel lernen Sie:
- ✅ Einen persönlichen Blog mit Next.js 14 erstellen
- ✅ App Router für Routing verwenden
- ✅ Daten mit Server Components abfragen
- ✅ Suche und Filterung implementieren
- ✅ Metadaten für SEO konfigurieren
- ✅ Responsive Design mit Tailwind CSS
11.1 Anforderungsanalyse & Projektinitialisierung
🎯 Projektziel
Einen funktionalen persönlichen Blog mit folgenden Funktionen:
- ✅ Startseite mit Blog-Liste
- ✅ Detailseite für jeden Blog-Artikel
- ✅ Suche nach Artikeln
- ✅ Kategorien-Filter
- ✅ SEO-optimierte Metadaten
- ✅ Responsive Design
📦 Projekt erstellen
Schritt 1: Projekt initialisieren
bash
npx create-next-app@latest mein-blogKonfiguration:
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias? … NoSchritt 2: Projekt starten
bash
cd mein-blog
pnpm dev11.2 Routing & Layout aufbauen
📂 Verzeichnisstruktur
app/
├── layout.js # Globales Layout
├── page.js # Startseite (/)
├── blog/
│ ├── page.js # Blog-Liste (/blog)
│ └── [slug]/
│ └── page.js # Blog-Detail (/blog/[slug])
├── über/
│ └── page.js # Über mich Seite (/über)
└── globals.css # Globales CSS🎨 Globales Layout (app/layout.js)
jsx
import './globals.css';
import Link from 'next/link';
export const metadata = {
title: 'Mein Blog | Persönliche Webseite',
description: 'Ein Blog über Webentwicklung und Technologie',
};
export default function RootLayout({ children }) {
return (
<html lang="de">
<body>
<header className="bg-white shadow">
<nav className="container mx-auto px-4 py-6">
<div className="flex justify-between items-center">
<Link href="/" className="text-2xl font-bold text-gray-800">
Mein Blog
</Link>
<div className="space-x-4">
<Link href="/" className="text-gray-600 hover:text-gray-900">
Startseite
</Link>
<Link href="/blog" className="text-gray-600 hover:text-gray-900">
Blog
</Link>
<Link href="/über" className="text-gray-600 hover:text-gray-900">
Über mich
</Link>
</div>
</div>
</nav>
</header>
<main className="container mx-auto px-4 py-8">
{children}
</main>
<footer className="bg-gray-800 text-white py-8">
<div className="container mx-auto px-4 text-center">
<p>© 2024 Mein Blog. Alle Rechte vorbehalten.</p>
</div>
</footer>
</body>
</html>
);
}11.3 Datenabfrage & Anzeige (Server Components)
📝 Blog-Daten (Mock-Daten)
Datei: lib/posts.js
javascript
export const posts = [
{
slug: 'erster-blog-artikel',
title: 'Mein erster Blog-Artikel',
excerpt: 'Dies ist eine Zusammenfassung meines ersten Blog-Artikels...',
content: 'Hier ist der vollständige Inhalt des Artikels...',
date: '2024-01-15',
category: 'Webentwicklung',
image: '/images/blog1.jpg',
},
{
slug: 'nextjs-tutorial',
title: 'Next.js 14 Tutorial für Anfänger',
excerpt: 'Lernen Sie Next.js 14 von Grund auf...',
content: 'Next.js 14 ist ein fantastisches Framework...',
date: '2024-02-20',
category: 'Tutorials',
image: '/images/blog2.jpg',
},
{
slug: 'tailwind-css-tipps',
title: 'Tailwind CSS Tipps & Tricks',
excerpt: 'Verbessern Sie Ihren Workflow mit Tailwind CSS...',
content: 'Tailwind CSS ist ein utility-first CSS-Framework...',
date: '2024-03-10',
category: 'Webentwicklung',
image: '/images/blog3.jpg',
},
];
export function getPosts() {
return posts.sort((a, b) => new Date(b.date) - new Date(a.date));
}
export function getPostBySlug(slug) {
return posts.find((post) => post.slug === slug);
}
export function getCategories() {
return [...new Set(posts.map((post) => post.category))];
}🏠 Startseite (app/page.js)
jsx
import Link from 'next/link';
import { getPosts } from '@/lib/posts';
export default function HomePage() {
const posts = getPosts();
const latestPosts = posts.slice(0, 3);
return (
<div>
{/* Hero-Sektion */}
<section className="bg-gradient-to-r from-blue-500 to-purple-600 text-white py-20 mb-12 rounded-lg">
<div className="text-center">
<h1 className="text-5xl font-bold mb-4">Willkommen auf meinem Blog</h1>
<p className="text-xl mb-8">Entdecken Sie Artikel über Webentwicklung und Technologie</p>
<Link
href="/blog"
className="bg-white text-blue-600 px-6 py-3 rounded-lg font-semibold hover:bg-gray-100"
>
Artikel ansehen
</Link>
</div>
</section>
{/* Neueste Artikel */}
<section>
<h2 className="text-3xl font-bold mb-8">Neueste Artikel</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{latestPosts.map((post) => (
<article key={post.slug} className="border rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition">
<img src={post.image} alt={post.title} className="w-full h-48 object-cover" />
<div className="p-6">
<div className="text-sm text-gray-500 mb-2">{post.date}</div>
<h3 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</Link>
</h3>
<p className="text-gray-600">{post.excerpt}</p>
</div>
</article>
))}
</div>
</section>
</div>
);
}11.4 Blog-Liste & Suche
📄 Blog-Liste (app/blog/page.js)
jsx
import Link from 'next/link';
import { getPosts, getCategories } from '@/lib/posts';
export default function BlogPage({ searchParams }) {
const posts = getPosts();
const categories = getCategories();
const selectedCategory = searchParams?.category;
const searchQuery = searchParams?.q?.toLowerCase() || '';
// Filterung
let filteredPosts = posts;
if (selectedCategory) {
filteredPosts = filteredPosts.filter((post) => post.category === selectedCategory);
}
if (searchQuery) {
filteredPosts = filteredPosts.filter(
(post) =>
post.title.toLowerCase().includes(searchQuery) ||
post.excerpt.toLowerCase().includes(searchQuery)
);
}
return (
<div>
<h1 className="text-4xl font-bold mb-8">Blog-Artikel</h1>
{/* Such- und Filterformular */}
<form className="mb-8 space-y-4 md:space-y-0 md:flex md:space-x-4">
<input
type="text"
name="q"
placeholder="Suchen..."
defaultValue={searchQuery}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<select
name="category"
defaultValue={selectedCategory || ''}
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Kategorien</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Filtern
</button>
</form>
{/* Artikelliste */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{filteredPosts.map((post) => (
<article key={post.slug} className="border rounded-lg overflow-hidden shadow hover:shadow-lg transition">
<img src={post.image} alt={post.title} className="w-full h-48 object-cover" />
<div className="p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">{post.date}</span>
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{post.category}
</span>
</div>
<h2 className="text-2xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600">{post.excerpt}</p>
</div>
</article>
))}
</div>
{filteredPosts.length === 0 && (
<p className="text-center text-gray-500 py-12">Keine Artikel gefunden.</p>
)}
</div>
);
}11.5 Blog-Detailseite
📄 Detailseite (app/blog/[slug]/page.js)
jsx
import { getPostBySlug, getPosts } from '@/lib/posts';
import { notFound } from 'next/navigation';
export default function BlogPost({ params }) {
const post = getPostBySlug(params.slug);
if (!post) {
notFound();
}
return (
<article className="max-w-4xl mx-auto">
<img src={post.image} alt={post.title} className="w-full h-64 object-cover rounded-lg mb-8" />
<div className="mb-4">
<span className="text-sm text-gray-500">{post.date}</span>
<span className="mx-2">•</span>
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{post.category}
</span>
</div>
<h1 className="text-4xl font-bold mb-6">{post.title}</h1>
<div className="prose max-w-none">
<p className="text-lg leading-relaxed">{post.content}</p>
</div>
</article>
);
}
// Metadaten für SEO
export async function generateMetadata({ params }) {
const post = getPostBySlug(params.slug);
if (!post) {
return {};
}
return {
title: `${post.title} | Mein Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image],
},
};
}
// Statische Generierung für bessere Performance
export async function generateStaticParams() {
const posts = getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}11.6 SEO-Optimierung
🔍 Metadaten-Konfiguration
In app/layout.js (global):
jsx
export const metadata = {
title: {
default: 'Mein Blog',
template: '%s | Mein Blog',
},
description: 'Ein Blog über Webentwicklung und Technologie',
robots: {
index: true,
follow: true,
},
};In app/blog/[slug]/page.js (pro Artikel):
jsx
export async function generateMetadata({ params }) {
const post = getPostBySlug(params.slug);
if (!post) {
return {};
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.date,
},
};
}11.7 Responsive Design mit Tailwind CSS
📱 Responsive Anpassungen
Grid-Layout (responsive):
jsx
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Artikel-Karten */}
</div>Navigation (mobile-optimiert):
jsx
{/* In layout.js */}
<header className="bg-white shadow">
<nav className="container mx-auto px-4 py-6">
<div className="flex justify-between items-center">
<Link href="/" className="text-2xl font-bold">
Mein Blog
</Link>
{/* Mobile Menu Button (optional mit useState) */}
<div className="hidden md:flex space-x-4">
<Link href="/">Startseite</Link>
<Link href="/blog">Blog</Link>
<Link href="/über">Über mich</Link>
</div>
</div>
</nav>
</header>11.8 Code-Erklärung & Optimierung
✅ Best Practices
| Aspekt | Empfehlung |
|---|---|
| Server Components | Standardmäßig verwenden (bessere Performance) |
| Metadaten | generateMetadata() für SEO verwenden |
| Bilder | next/image Komponente für Optimierung |
| Statische Generierung | generateStaticParams() für bessere Performance |
| Tailwind CSS | Utility-Classes für schnelles Styling |
🚀 Performance-Optimierung
Bildoptimierung:
jsx
import Image from 'next/image';
<Image
src={post.image}
alt={post.title}
width={800}
height={400}
className="w-full h-48 object-cover"
/>Lazy Loading:
jsx
// Next.js lädt Komponenten automatisch lazy mit dynamic imports
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('@/components/HeavyComponent'));📝 Zusammenfassung
In diesem Kapitel haben Sie gelernt:
| Konzept | Erklärung |
|---|---|
| Projektsetup | Next.js 14 mit App Router und Tailwind CSS |
| Routing | Dateisystem-basiertes Routing (app/, app/blog/[slug]) |
| Server Components | Datenabfrage in Server Components |
| Suche & Filterung | Such- und Filterfunktionen implementieren |
| SEO | Metadaten mit generateMetadata() optimieren |
| Responsive Design | Tailwind CSS für mobile Optimierung |
✅ Nächste Schritte
- ✅ Übung: Fügen Sie Kommentare zu Blog-Artikeln hinzu
- ✅ Übung: Implementieren Sie Paginierung für die Blog-Liste
- ✅ Weiter geht's: Kapitel 12 - Praxisprojekt: E-Commerce (Fortgeschritten)
🎯 Selbsttest
Frage 1: Wie erstellt man eine dynamische Route im App Router?
Antwort anzeigen
Durch Erstellen eines Ordners mit eckigen Klammern: `app/blog/[slug]/page.js` erstellt die Route `/blog/[slug]`.Frage 2: Was ist der Vorteil von Server Components bei der Datenabfrage?
Antwort anzeigen
Daten werden auf dem Server abgerufen, was bessere SEO und schnellere Ladezeiten ermöglicht. Kein Client-seitiges JavaScript nötig.Frage 3: Wie optimiert man Bilder in Next.js?
Antwort anzeigen
Verwenden Sie die `next/image` Komponente (`import Image from 'next/image'`). Sie bietet automatische Optimierung, Lazy Loading und WebP-Konvertierung.🚀 Weiter zu Kapitel 12: Praxisprojekt - E-Commerce (Fortgeschritten)
