· Sanity· CMS· Next.js· GROQ· blog· headless CMS· ISR· TypeScript· SEO
Sanity CMS med Next.js — blog og indhold til SaaS-hjemmeside
Sanity.io headless CMS med Next.js: GROQ-queries, portable text rendering, live preview, ISR og migration fra MDX-filer til CMS-drevet blog.
Af M-Bus Gateway
M-Bus Gateway hjemmesiden kører i dag med MDX-filer som blog-backend. Her er vejen til Sanity CMS for non-technical content-redigering og bedre workflow.
Sanity vs. MDX-filer: Hvornår skifte?
MDX-filer (nuværende):
✅ Simpelt — filer i git
✅ Gratis
✅ TypeScript-venlig
✅ AI-agenter kan skrive direkte
✗ Kræver pull request for at opdatere indhold
✗ Ingen live preview
✗ Ikke-tekniske redaktører kan ikke bruge det
Sanity CMS:
✅ Web-baseret editor (ikke-tekniske redaktører)
✅ Live preview i browser
✅ Struktureret indhold (validering)
✅ Versionering og draft-workflow
✅ Asset management (billeder auto-optimeret)
✗ Gratis op til 3 brugere (derefter betalt)
✗ Ekstra opsætning
✗ GROQ-query-language (nyt at lære)
Konklusion: MDX er fint for tekniske teams.
Sanity giver mening når marketing/kunder skal redigere.
Sanity opsætning
# Opret Sanity-projekt:
npm create sanity@latest -- --project-id <dit-project-id> \
--dataset production --template clean
# I Next.js-projektet:
npm install next-sanity @sanity/image-url @portabletext/react
// website/sanity/schema/blogPost.ts
import { defineType, defineField } from "sanity";
export const blogPostType = defineType({
name: "blogPost",
title: "Blog-artikel",
type: "document",
fields: [
defineField({ name: "title", type: "string", title: "Titel" }),
defineField({ name: "slug", type: "slug", title: "URL-slug",
options: { source: "title" } }),
defineField({ name: "publishedAt", type: "datetime", title: "Publiceringsdato" }),
defineField({ name: "description", type: "text", title: "Meta-beskrivelse",
validation: (r) => r.max(160) }),
defineField({
name: "tags",
type: "array",
title: "Tags",
of: [{ type: "string" }],
}),
defineField({
name: "body",
type: "array",
title: "Indhold",
of: [
{ type: "block" }, // Rig tekst
{ type: "image" }, // Billeder
{
type: "code", // Kode-blokke
options: { language: "python" },
},
],
}),
],
preview: {
select: { title: "title", subtitle: "publishedAt" },
},
});
GROQ-queries
// website/sanity/queries.ts
import { groq } from "next-sanity";
// Liste af alle publicerede blogposts:
export const allPostsQuery = groq`
*[_type == "blogPost" && defined(slug.current) && publishedAt < now()]
| order(publishedAt desc) {
_id,
title,
slug,
publishedAt,
description,
tags,
}
`;
// Enkelt blogpost (med body):
export const postBySlugQuery = groq`
*[_type == "blogPost" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
description,
tags,
body,
}
`;
// Posts med specifikt tag:
export const postsByTagQuery = groq`
*[_type == "blogPost" && $tag in tags && publishedAt < now()]
| order(publishedAt desc) {
_id,
title,
slug,
publishedAt,
description,
}
`;
Next.js integration med ISR
// website/app/blog/[slug]/page.tsx
import { client } from "@/sanity/client";
import { postBySlugQuery, allPostsQuery } from "@/sanity/queries";
import { PortableText } from "@portabletext/react";
// ISR: Rebuild kun ved ændringer (ikke SSR ved hvert request):
export const revalidate = 3600; // Max 1 time gammel
export async function generateStaticParams() {
const posts = await client.fetch(allPostsQuery);
return posts.map((post: any) => ({ slug: post.slug.current }));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await client.fetch(postBySlugQuery, { slug: params.slug });
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString("da-DK")}
</time>
<PortableText
value={post.body}
components={{
types: {
code: ({ value }) => (
<pre><code className={`language-${value.language}`}>{value.code}</code></pre>
),
},
}}
/>
</article>
);
}
On-demand ISR: Sanity webhook
// website/app/api/revalidate/route.ts
// Sanity kalder denne webhook når indhold ændres:
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (secret !== process.env.SANITY_REVALIDATE_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
const body = await req.json();
// Revalidér den specifikke post-URL:
if (body._type === "blogPost" && body.slug?.current) {
revalidatePath(`/blog/${body.slug.current}`);
revalidatePath("/blog"); // Revalidér blog-index
}
return NextResponse.json({ revalidated: true });
}
Sanity webhook-opsætning:
URL: https://mbus-gateway.dk/api/revalidate?secret=<SANITY_REVALIDATE_SECRET>
Trigger: publish / unpublish / delete
Filter: _type == "blogPost"
Migration fra MDX til Sanity
# Script til at importere eksisterende MDX-filer til Sanity:
# website/scripts/import-mdx-to-sanity.ts
import fs from "fs";
import matter from "gray-matter";
import { createClient } from "@sanity/client";
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: "production",
token: process.env.SANITY_WRITE_TOKEN!,
apiVersion: "2024-01-01",
useCdn: false,
});
const blogDir = "./content/blog";
for (const file of fs.readdirSync(blogDir)) {
const { data, content } = matter(fs.readFileSync(`${blogDir}/${file}`, "utf-8"));
await client.create({
_type: "blogPost",
title: data.title,
slug: { _type: "slug", current: file.replace(".mdx", "") },
publishedAt: data.date,
description: data.description,
tags: data.tags,
// body: Kræver MDX → Portable Text konvertering (kompleks)
});
}
Konklusion
Sanity CMS er det rigtige valg når ikke-tekniske redaktører skal opdatere indhold uden pull requests. GROQ-queries er mere fleksible end filbaserede queries, og on-demand ISR via webhook giver sub-sekund opdateringer i produktion. MDX-baseret blog er stadig det rigtige valg for AI-agenter og tekniske teams.