· 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.