M-Bus Gateway
← Tilbage til blog
· Next.js· SEO· metadata· OpenGraph· JSON-LD· sitemap· TypeScript· Vercel

Next.js SEO metadata og OpenGraph — komplet guide

Next.js 14 Metadata API: title templates, OpenGraph, Twitter Cards, JSON-LD strukturerede data, sitemap.ts, robots.ts og canonical URLs for maksimal SEO.

Af M-Bus Gateway

Next.js 14 App Router har et kraftfuldt Metadata API der eliminerer behovet for react-helmet. Her er den komplette opsætning til en SaaS marketing-site.


Root layout metadata

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://mbus-gateway.dk"),
  title: {
    default: "M-Bus Gateway — Automatisk varmeafregning",
    template: "%s | M-Bus Gateway",
  },
  description:
    "SaaS-platform til automatisk varme- og vandafregning i udlejningsejendomme. " +
    "wM-Bus gateway, fordelingsregnskab, PDF-afregning og lejerportal.",
  keywords: [
    "varmeafregning",
    "varmemåler",
    "fordelingsregnskab",
    "BEK 563",
    "wM-Bus gateway",
    "HCA",
    "udlejer",
  ],
  authors: [{ name: "M-Bus Gateway" }],
  creator: "M-Bus Gateway ApS",
  publisher: "M-Bus Gateway ApS",
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      "max-video-preview": -1,
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
  openGraph: {
    type: "website",
    locale: "da_DK",
    url: "https://mbus-gateway.dk",
    siteName: "M-Bus Gateway",
    title: "M-Bus Gateway — Automatisk varmeafregning",
    description: "Erstat Techem, ista og Brunata med din egen gateway.",
    images: [
      {
        url: "/og-image.png",
        width: 1200,
        height: 630,
        alt: "M-Bus Gateway platform",
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
    title: "M-Bus Gateway — Automatisk varmeafregning",
    description: "Erstat Techem, ista og Brunata med din egen gateway.",
    images: ["/og-image.png"],
  },
  alternates: {
    canonical: "https://mbus-gateway.dk",
    languages: {
      "da-DK": "https://mbus-gateway.dk",
      "sv-SE": "https://mbus-gateway.se",
      "nb-NO": "https://mbus-gateway.no",
    },
  },
};

Per-side metadata med generateMetadata

// app/blog/[slug]/page.tsx

import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";

interface Props {
  params: { slug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);

  if (!post) {
    return { title: "Artikel ikke fundet" };
  }

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      type: "article",
      title: post.title,
      description: post.description,
      url: `https://mbus-gateway.dk/blog/${params.slug}`,
      publishedTime: post.date,
      authors: [post.author],
      tags: post.tags,
      images: [
        {
          url: post.ogImage ?? "/og-blog-default.png",
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    alternates: {
      canonical: `https://mbus-gateway.dk/blog/${params.slug}`,
    },
  };
}

JSON-LD strukturerede data

// components/JsonLd.tsx

interface BlogPostingSchema {
  title: string;
  description: string;
  datePublished: string;
  author: string;
  url: string;
  tags: string[];
}

export function BlogPostingJsonLd({
  title,
  description,
  datePublished,
  author,
  url,
  tags,
}: BlogPostingSchema) {
  const schema = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: title,
    description,
    datePublished,
    dateModified: datePublished,
    author: {
      "@type": "Organization",
      name: author,
      url: "https://mbus-gateway.dk",
    },
    publisher: {
      "@type": "Organization",
      name: "M-Bus Gateway",
      logo: {
        "@type": "ImageObject",
        url: "https://mbus-gateway.dk/logo.png",
      },
    },
    url,
    keywords: tags.join(", "),
    inLanguage: "da-DK",
    isPartOf: {
      "@type": "Blog",
      name: "M-Bus Gateway Blog",
      url: "https://mbus-gateway.dk/blog",
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}

// FAQPage schema:
export function FaqJsonLd({ items }: { items: { q: string; a: string }[] }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify({
          "@context": "https://schema.org",
          "@type": "FAQPage",
          mainEntity: items.map(({ q, a }) => ({
            "@type": "Question",
            name: q,
            acceptedAnswer: { "@type": "Answer", text: a },
          })),
        }),
      }}
    />
  );
}

Sitemap med sitemap.ts

// app/sitemap.ts

import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/blog";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();

  const staticPages: MetadataRoute.Sitemap = [
    { url: "https://mbus-gateway.dk", lastModified: new Date(), priority: 1.0, changeFrequency: "weekly" },
    { url: "https://mbus-gateway.dk/priser", lastModified: new Date(), priority: 0.9, changeFrequency: "monthly" },
    { url: "https://mbus-gateway.dk/platform", lastModified: new Date(), priority: 0.9, changeFrequency: "monthly" },
    { url: "https://mbus-gateway.dk/blog", lastModified: new Date(), priority: 0.8, changeFrequency: "daily" },
    { url: "https://mbus-gateway.dk/sammenlign", lastModified: new Date(), priority: 0.8, changeFrequency: "monthly" },
    { url: "https://mbus-gateway.dk/for-private-udlejere", lastModified: new Date(), priority: 0.9, changeFrequency: "monthly" },
    { url: "https://mbus-gateway.dk/for-ejendomsadministratorer", lastModified: new Date(), priority: 0.9, changeFrequency: "monthly" },
    { url: "https://mbus-gateway.dk/kontakt", lastModified: new Date(), priority: 0.7, changeFrequency: "yearly" },
  ];

  const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `https://mbus-gateway.dk/blog/${post.slug}`,
    lastModified: new Date(post.date),
    priority: 0.7,
    changeFrequency: "monthly" as const,
  }));

  return [...staticPages, ...blogPages];
}

robots.ts

// app/robots.ts

import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/portal/", "/api/", "/_next/"],
      },
    ],
    sitemap: "https://mbus-gateway.dk/sitemap.xml",
    host: "https://mbus-gateway.dk",
  };
}

OpenGraph image generation

// app/blog/[slug]/opengraph-image.tsx

import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/blog";

export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function OgImage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);

  return new ImageResponse(
    (
      <div
        style={{
          background: "#0f172a",
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: "60px",
          fontFamily: "sans-serif",
        }}
      >
        <div style={{ color: "#3b82f6", fontSize: 20, marginBottom: 20 }}>
          M-BUS GATEWAY BLOG
        </div>
        <div style={{ color: "#f8fafc", fontSize: 52, fontWeight: 700, lineHeight: 1.2 }}>
          {post?.title ?? "Blog"}
        </div>
        <div style={{ color: "#94a3b8", fontSize: 24, marginTop: 24 }}>
          {post?.description}
        </div>
      </div>
    ),
    { ...size }
  );
}

Konklusion

Next.js 14 Metadata API giver fuld kontrol over titel-templates, OpenGraph, Twitter Cards, JSON-LD og sitemap via TypeScript-filer — ingen third-party libraries nødvendig. generateMetadata() på blog-sider sikrer at hvert indlæg har unikke, dynamisk genererede metadata. ImageResponse til OG-billeder kører på Edge Runtime og caches af Vercel CDN.

Se Next.js hjemmeside guide eller blog SEO guide.