M-Bus Gateway
← Tilbage til blog
· 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.

Se Next.js SEO guide eller React PWA guide.