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

Se Next.js hjemmeside guide eller SEO blog strategi.