· Next.js· App Router· React Server Components· caching· TypeScript· SSG· SSR· blog
Next.js App Router data fetching — server components og caching
Next.js 14+ App Router data fetching: React Server Components, fetch() caching, revalidation, Suspense streaming, parallel fetching og blog-generering med generateStaticParams.
Af M-Bus Gateway
Next.js App Router introducerer React Server Components og ny fetch()-caching. Her er mønstrene brugt i M-Bus Gateway's hjemmeside.
Server Components — grundprincip
// website/app/blog/[slug]/page.tsx
// Server Component: kører kun på server — ingen bundle til browser
import { getPost } from "../../lib/blog/posts";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface Props {
params: { slug: string };
}
// Metadata genereres server-side (SEO):
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return { title: "Ikke fundet" };
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
publishedTime: post.date,
},
};
}
// Statisk generering af alle blog-sider ved build:
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
// Server Component — direkte asynkron:
export default async function BlogPost({ params }: Props) {
const post = await getPost(params.slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>{formatDate(post.date)}</time>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}
fetch() caching og revalidation
// website/lib/api.ts
// Cache i 1 time, revalidér ved build:
async function fetchProducts() {
const res = await fetch("https://api.stripe.com/v1/products", {
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
next: { revalidate: 3600 }, // ISR: Revalidér efter 1 time
});
return res.json();
}
// Cache kun ved build (statisk):
async function fetchStaticData() {
const res = await fetch("https://api.example.com/static-content", {
cache: "force-cache", // Standard: statisk cache
});
return res.json();
}
// Aldrig cache (dynamisk, altid frisk data):
async function fetchDynamicData() {
const res = await fetch("https://api.example.com/realtime", {
cache: "no-store",
});
return res.json();
}
// Tag-baseret revalidation:
async function fetchWithTag() {
const res = await fetch("https://api.example.com/blog-posts", {
next: { tags: ["blog"] }, // revalidateTag("blog") invaliderer
});
return res.json();
}
// Webhook trigger revalidation:
// POST /api/revalidate?tag=blog → revalidateTag("blog")
Parallel data fetching
// website/app/page.tsx — Forside med parallelle fetches
export default async function HomePage() {
// Start alle fetches parallelt (ikke sekventielt):
const [products, posts, testimonials] = await Promise.all([
fetchProducts(),
getLatestPosts(3),
fetchTestimonials(),
]);
return (
<>
<HeroSection />
<ProductGrid products={products} />
<BlogPreview posts={posts} />
<Testimonials items={testimonials} />
</>
);
}
Suspense streaming
// website/app/blog/page.tsx — Blog-liste med Suspense
import { Suspense } from "react";
// Langsom komponent (DB eller ekstern API):
async function BlogList() {
const posts = await getAllPosts(); // Kan tage tid
return (
<div className="blog-grid">
{posts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
</div>
);
}
// Skeleton vist mens BlogList loader:
function BlogListSkeleton() {
return (
<div className="blog-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="blog-card skeleton" />
))}
</div>
);
}
export default function BlogPage() {
return (
<main>
<h1>Blog</h1>
<Suspense fallback={<BlogListSkeleton />}>
<BlogList />
</Suspense>
</main>
);
}
Route Handlers (API routes)
// website/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: "Ugyldig secret" }, { status: 401 });
}
const tag = request.nextUrl.searchParams.get("tag");
if (!tag) {
return NextResponse.json({ error: "Mangler tag" }, { status: 400 });
}
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}
// website/app/api/newsletter/route.ts
export async function POST(request: NextRequest) {
const { email } = await request.json();
if (!email || !email.includes("@")) {
return NextResponse.json({ error: "Ugyldig email" }, { status: 400 });
}
await subscribeToNewsletter(email);
return NextResponse.json({ success: true });
}
generateStaticParams for blog
// website/app/blog/[slug]/page.tsx
import { getAllPosts } from "../../lib/blog/posts";
export async function generateStaticParams() {
const posts = await getAllPosts();
// Returnér alle slugs — Next.js pre-renders alle sider ved build
return posts.map((post) => ({
slug: post.slug,
}));
}
// Dynamisk fallback: Nye artikler der ikke var ved build-tid:
export const dynamicParams = true; // true = ISR fallback
// ELLER: Strict statisk (404 for ukendte slugs):
// export const dynamicParams = false;
MDX rendering server-side
// website/lib/blog/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import remarkHtml from "remark-html";
const POSTS_DIR = path.join(process.cwd(), "content/blog");
export async function getPost(slug: string) {
const filePath = path.join(POSTS_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const { data: frontmatter, content } = matter(raw);
// Server-side rendering af Markdown → HTML:
const result = await remark().use(remarkHtml).process(content);
return {
slug,
title: frontmatter.title as string,
description: frontmatter.description as string,
date: frontmatter.date as string,
tags: (frontmatter.tags as string[]) ?? [],
contentHtml: result.toString(),
};
}
Konklusion
App Router Server Components giver asynkron data fetching direkte i komponenter — ingen getServerSideProps eller getStaticProps. fetch() caching med next: { revalidate } giver ISR. Promise.all() for parallelle fetches undgår sekventiel N+1 fetching. generateStaticParams pre-renderer alle blog-sider ved build for maksimal SEO-performance.