<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged headless-cms</title>
<link>https://coderlegion.com/tag/headless-cms</link>
<description>Powered by Question2Answer</description>
<item>
<title>How to Integrate Sanity CMS with Next.js 16 App Router</title>
<link>https://coderlegion.com/23495/how-to-integrate-sanity-cms-with-next-js-16-app-router</link>
<description>&lt;p&gt;Managing content as a developer used to mean one of two things: hardcoding everything into your codebase or wrestling with a clunky WordPress dashboard. Neither feels great when your client wants to update a blog post at 10pm on a Friday without touching a single line of code.&lt;/p&gt;
&lt;p&gt;That's exactly where &lt;strong&gt;Sanity CMS&lt;/strong&gt; shines. It's a headless CMS with a fully customizable studio, a powerful query language called GROQ, and first-class support for Next.js. And in 2026, with Next.js 16's App Router fully matured, the two work together beautifully.&lt;/p&gt;
&lt;p&gt;In this guide, you'll set up Sanity CMS inside a Next.js 16 App Router project from scratch: creating a project, defining a schema, writing GROQ queries, typing everything with TypeScript, and rendering rich text content with live updates. By the end, you'll have a working blog listing page and a dynamic post page, both powered entirely by Sanity.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-sanity-over-other-headless-cms-options-&quot;&gt;Why Sanity Over Other Headless CMS Options?&lt;/h3&gt;
&lt;p&gt;There are plenty of headless CMS choices out there — Contentful, Strapi, Payload — so why Sanity?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sanity Studio runs inside your Next.js app&lt;/strong&gt; at &lt;code&gt;/studio&lt;/code&gt;, so there's no separate admin URL to manage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GROQ&lt;/strong&gt; (Graph-Relational Object Queries) is more flexible and readable than GraphQL for content queries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-time collaboration&lt;/strong&gt; — multiple editors can work on content simultaneously.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free tier is genuinely generous&lt;/strong&gt; for most personal projects and small clients.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;An embedded Sanity Studio running at &lt;code&gt;/studio&lt;/code&gt; inside your Next.js app&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;post&lt;/code&gt; schema with title, slug, excerpt, cover image, and rich text body&lt;/li&gt;
&lt;li&gt;GROQ queries to fetch all posts and a single post by slug&lt;/li&gt;
&lt;li&gt;Fully typed Sanity responses with TypeScript&lt;/li&gt;
&lt;li&gt;A blog listing page and a dynamic post page using &lt;code&gt;generateStaticParams&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Real-time content updates using Sanity Live&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-sanity-exactly-&quot;&gt;What Is Sanity, Exactly?&lt;/h3&gt;
&lt;p&gt;Sanity is a &lt;strong&gt;headless CMS&lt;/strong&gt; — meaning the content editor (Studio) is completely decoupled from how your content gets displayed. You configure Studio with code, content gets stored remotely in what Sanity calls the Content Lake, and your Next.js app queries that content using GROQ (Sanity's own query language).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Think of Sanity Studio as a &quot;window&quot; into your remote content. The Studio configuration lives in your repo, but the actual blog posts, images, and data live on Sanity's servers — not in your codebase.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before we start, make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Node.js 20+&lt;/strong&gt; installed&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Next.js 16&lt;/strong&gt; project with the App Router (TypeScript + Tailwind)&lt;/li&gt;
&lt;li&gt;A free account at &lt;a rel=&quot;nofollow&quot; href=&quot;https://sanity.io&quot;&gt;sanity.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;h3 id=&quot;basic-familiarity-with-next-js-file-based-routing&quot;&gt;Basic familiarity with Next.js file-based routing&lt;/h3&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-1-create-a-sanity-project&quot;&gt;Step 1: Create a Sanity Project&lt;/h3&gt;
&lt;p&gt;First, let's create a new Sanity project from the CLI. If you don't have the Sanity CLI installed yet, run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g @sanity/cli
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then inside your &lt;strong&gt;Next.js project root&lt;/strong&gt;, initialize Sanity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx sanity@latest init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CLI will walk you through a series of prompts. Here's exactly what to choose:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;✔ Create a new project or select an existing one → Create new project
✔ Project name → whatever you like (e.g. my-blog)
✔ Select organization or create new
✔ Use the default dataset configuration? → Yes
✔ Configure Sanity MCP and agent skills for these editors? → Claude Code, GitHub Copilot CLI, VS Code
✔ Would you like to add configuration files for a Sanity project in this Next.js folder? → Yes
✔ Do you want to use TypeScript? → Yes
✔ Would you like an embedded Sanity Studio? → Yes
✔ What route do you want to use for the Studio? → /studio
✔ Select project template to use → Clean project with no predefined schema types
✔ Would you like to add the project ID and dataset to your .env.local file? → Yes
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Picking the &quot;blog&quot; template instead of &quot;Clean project&quot; gives you pre-built schema types (&lt;code&gt;post&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;). That's fine if you want a head start, but this guide builds the &lt;code&gt;post&lt;/code&gt; schema manually so you understand exactly what each field does — so we go with the clean template.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Once it finishes, your project folder should look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;.
├── .env.local
├── sanity.cli.ts
├── sanity.config.ts
├── (...and all your Next.js files)
└── src
    ├── app
    │   └── studio
    │       └── [[...tool]]
    │           └── page.tsx
    └── sanity
        ├── lib
        │   ├── client.ts
        │   ├── image.ts
        │   └── live.ts
        ├── schemaTypes
        │   └── index.ts
        ├── env.ts
        └── schema.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-just-got-created-&quot;&gt;What Just Got Created?&lt;/h4&gt;
&lt;p&gt;A few of these files matter more than others:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;src/sanity/lib/client.ts&lt;/code&gt; — a configured Sanity Client instance used to query content&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/sanity/lib/live.ts&lt;/code&gt; — exports &lt;code&gt;sanityFetch&lt;/code&gt; and &lt;code&gt;SanityLive&lt;/code&gt;, which power real-time content updates&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/sanity/env.ts&lt;/code&gt; — reads your project ID and dataset from &lt;code&gt;.env.local&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sanity.config.ts&lt;/code&gt; — configures Studio itself: schema types, plugins, and the studio route&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/studio/[[...tool]]/page.tsx&lt;/code&gt; — the catch-all route that renders the entire Studio UI at &lt;code&gt;/studio&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your &lt;code&gt;.env.local&lt;/code&gt; should now contain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;NEXT_PUBLIC_SANITY_PROJECT_ID=&quot;your-project-id&quot;
NEXT_PUBLIC_SANITY_DATASET=&quot;production&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; because a project ID and dataset name aren't sensitive — they're needed by the browser to talk to Sanity's API directly when previewing content.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-install-next-sanity&quot;&gt;Step 2: Install next-sanity&lt;/h3&gt;
&lt;p&gt;The CLI usually installs this for you, but if it's missing, add it manually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-sanity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;next-sanity&lt;/code&gt; is a toolkit of utilities built specifically for Next.js — it wraps Sanity Client with conventions for data fetching, caching, and live content updates so you're not wiring all of that together yourself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-create-a-post-schema&quot;&gt;Step 3: Create a Post Schema&lt;/h3&gt;
&lt;p&gt;Schemas in Sanity define what content editors are &lt;em&gt;allowed&lt;/em&gt; to create in Studio — think of them as the shape of your content, similar to a database model.&lt;/p&gt;
&lt;p&gt;Create a new schema file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/schemaTypes/post.ts
import { defineField, defineType } from &quot;sanity&quot;;

export const postSchema = defineType({
  name: &quot;post&quot;,
  title: &quot;Blog Post&quot;,
  type: &quot;document&quot;,
  fields: [
    defineField({
      name: &quot;title&quot;,
      title: &quot;Title&quot;,
      type: &quot;string&quot;,
      validation: (Rule) =&amp;gt; Rule.required(),
    }),
    defineField({
      name: &quot;slug&quot;,
      title: &quot;Slug&quot;,
      type: &quot;slug&quot;,
      options: { source: &quot;title&quot; }, // auto-generates slug from title
      validation: (Rule) =&amp;gt; Rule.required(),
    }),
    defineField({
      name: &quot;excerpt&quot;,
      title: &quot;Excerpt&quot;,
      type: &quot;text&quot;,
      rows: 3,
    }),
    defineField({
      name: &quot;coverImage&quot;,
      title: &quot;Cover Image&quot;,
      type: &quot;image&quot;,
      options: { hotspot: true }, // lets editors set a focal point
    }),
    defineField({
      name: &quot;publishedAt&quot;,
      title: &quot;Published At&quot;,
      type: &quot;datetime&quot;,
    }),
    defineField({
      name: &quot;body&quot;,
      title: &quot;Body&quot;,
      type: &quot;array&quot;,
      of: [{ type: &quot;block&quot; }, { type: &quot;image&quot; }], // rich text + inline images
    }),
  ],
  orderings: [
    {
      title: &quot;Published Date, New&quot;,
      name: &quot;publishedAtDesc&quot;,
      by: [{ field: &quot;publishedAt&quot;, direction: &quot;desc&quot; }],
    },
  ],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A quick breakdown of what's happening:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;defineType&lt;/code&gt; registers a new document type — editors will see &quot;Blog Post&quot; as a creatable content type in Studio&lt;/li&gt;
&lt;li&gt;&lt;code&gt;defineField&lt;/code&gt; describes each individual field and its validation rules&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;slug&lt;/code&gt; field with &lt;code&gt;options: { source: &quot;title&quot; }&lt;/code&gt; auto-generates a URL-friendly slug from the title as the editor types&lt;/li&gt;
&lt;li&gt;&lt;code&gt;body&lt;/code&gt; uses &lt;code&gt;type: &quot;array&quot;&lt;/code&gt; with &lt;code&gt;block&lt;/code&gt; and &lt;code&gt;image&lt;/code&gt; — this is Sanity's &lt;strong&gt;Portable Text&lt;/strong&gt; format, a structured way of representing rich text that isn't tied to HTML&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &lt;code&gt;Rule.required()&lt;/code&gt; enforces validation inside Studio itself. Editors literally cannot publish a post without a title or slug — no backend validation needed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-register-the-schema&quot;&gt;Step 4: Register the Schema&lt;/h3&gt;
&lt;p&gt;Defining a schema isn't enough — Studio needs to know it exists. Register it in the schema index:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/schemaTypes/index.ts
import { type SchemaTypeDefinition } from &quot;sanity&quot;;
import { postSchema } from &quot;./post&quot;;

export const schema: { types: SchemaTypeDefinition[] } = {
  types: [postSchema],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting this step is the #1 reason a new schema &quot;doesn't show up&quot; in Studio. Defining the type isn't enough — it has to be added to this &lt;code&gt;types&lt;/code&gt; array.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now start your dev server and visit &lt;code&gt;http://localhost:3000/studio&lt;/code&gt;. You should see &quot;Blog Post&quot; listed as a content type, ready to create your first post.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-write-groq-queries&quot;&gt;Step 5: Write GROQ Queries&lt;/h3&gt;
&lt;p&gt;GROQ (Graph-Relational Object Queries) is Sanity's query language, purpose-built for querying structured, nested JSON-like content.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you've used SQL or MongoDB queries before, GROQ will feel different at first, but it's actually closer to JavaScript object/array syntax than SQL.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Create a queries file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/queries.ts
import { groq } from &quot;next-sanity&quot;;

// Fetch all posts for the blog listing page
export const allPostsQuery = groq`
  *[_type == &quot;post&quot;] | order(publishedAt desc) {
    _id,
    title,
    slug,
    excerpt,
    publishedAt,
    coverImage {
      asset-&amp;gt; { _id, url }
    }
  }
`;

// Fetch a single post by slug
export const postBySlugQuery = groq`
  *[_type == &quot;post&quot; &amp;amp;&amp;amp; slug.current == $slug][0] {
    _id,
    title,
    slug,
    excerpt,
    publishedAt,
    coverImage {
      asset-&amp;gt; { _id, url }
    },
    body
  }
`;

// Fetch all slugs for generateStaticParams
export const allPostSlugsQuery = groq`
  *[_type == &quot;post&quot;] { &quot;slug&quot;: slug.current }
`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's what each part of the first query means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*[_type == &quot;post&quot;]&lt;/code&gt; — selects every document where the type equals &lt;code&gt;&quot;post&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;| order(publishedAt desc)&lt;/code&gt; — sorts results by publish date, newest first&lt;/li&gt;
&lt;li&gt;&lt;code&gt;{ ... }&lt;/code&gt; — a projection, meaning &quot;only return these fields,&quot; similar to selecting specific columns in SQL&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coverImage { asset-&amp;gt; { _id, url } }&lt;/code&gt; — the &lt;code&gt;-&amp;gt;&lt;/code&gt; is a &lt;strong&gt;dereference&lt;/strong&gt;, following the image asset reference to pull its actual URL&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The second query uses &lt;code&gt;$slug&lt;/code&gt; as a parameter and &lt;code&gt;[0]&lt;/code&gt; to grab just the first matching result, since slugs are unique.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-add-typescript-types&quot;&gt;Step 6: Add TypeScript Types&lt;/h3&gt;
&lt;p&gt;Since GROQ queries return plain JSON, TypeScript has no way to infer their shape automatically. Define the types yourself so the rest of your app stays type-safe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/types.ts
export interface SanitySlug {
  current: string;
}

export interface SanityImageAsset {
  _id: string;
  url: string;
}

export interface SanityImage {
  asset: SanityImageAsset;
}

export interface Post {
  _id: string;
  title: string;
  slug: SanitySlug;
  excerpt?: string;
  publishedAt: string;
  coverImage?: SanityImage;
  body?: unknown[]; // Portable Text blocks
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Sanity also offers a code generation tool (&lt;code&gt;sanity typegen&lt;/code&gt;) that can generate these types automatically from your schema and queries. Writing them by hand works fine for smaller schemas, but it's worth exploring once your content model grows.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-build-the-blog-listing-page&quot;&gt;Step 7: Build the Blog Listing Page&lt;/h3&gt;
&lt;p&gt;With queries and types ready, fetching content becomes straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/page.tsx
import { sanityFetch } from &quot;@/sanity/lib/live&quot;;
import { allPostsQuery } from &quot;@/sanity/queries&quot;;
import { Post } from &quot;@/sanity/types&quot;;
import Link from &quot;next/link&quot;;

export default async function BlogPage() {
  const { data: posts } = await sanityFetch({ query: allPostsQuery });

  return (
    &amp;lt;main className=&quot;max-w-3xl mx-auto px-4 py-12&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-4xl font-bold mb-8&quot;&amp;gt;Blog&amp;lt;/h1&amp;gt;
      &amp;lt;ul className=&quot;space-y-8&quot;&amp;gt;
        {(posts as Post[]).map((post) =&amp;gt; (
          &amp;lt;li key={post._id} className=&quot;border-b pb-8&quot;&amp;gt;
            &amp;lt;Link href={`/blog/${post.slug.current}`}&amp;gt;
              &amp;lt;h2 className=&quot;text-2xl font-semibold hover:underline&quot;&amp;gt;
                {post.title}
              &amp;lt;/h2&amp;gt;
            &amp;lt;/Link&amp;gt;
            {post.excerpt &amp;amp;&amp;amp; (
              &amp;lt;p className=&quot;mt-2 text-gray-600&quot;&amp;gt;{post.excerpt}&amp;lt;/p&amp;gt;
            )}
            {post.publishedAt &amp;amp;&amp;amp; (
              &amp;lt;time className=&quot;text-sm text-gray-400&quot;&amp;gt;
                {new Date(post.publishedAt).toLocaleDateString(&quot;en-US&quot;, {
                  year: &quot;numeric&quot;,
                  month: &quot;long&quot;,
                  day: &quot;numeric&quot;,
                })}
              &amp;lt;/time&amp;gt;
            )}
          &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice we're using &lt;code&gt;sanityFetch&lt;/code&gt; from &lt;code&gt;@/sanity/lib/live&lt;/code&gt; instead of calling the Sanity client directly. That's intentional — &lt;code&gt;sanityFetch&lt;/code&gt; is a wrapper that ties into Next.js's caching and revalidation system, and it's also what enables the live-update behavior in the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-8-enable-sanity-live-in-the-root-layout&quot;&gt;Step 8: Enable Sanity Live in the Root Layout&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SanityLive&lt;/code&gt; is a component that subscribes to Sanity's Live Content API, so changes you publish in Studio show up on your site without a manual rebuild or redeploy.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import { SanityLive } from &quot;@/sanity/lib/live&quot;;

export default function RootLayout({
  children,
}: Readonly&amp;lt;{
  children: React.ReactNode;
}&amp;gt;) {
  return (
    &amp;lt;html
      lang=&quot;en&quot;
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    &amp;gt;
      &amp;lt;body className=&quot;min-h-full flex flex-col&quot;&amp;gt;
        {children}
        &amp;lt;SanityLive /&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SanityLive&lt;/code&gt; doesn't render any visible UI — it just sits in the background and triggers revalidation whenever content changes in the Content Lake.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Placing &lt;code&gt;SanityLive&lt;/code&gt; only on specific pages instead of the root layout. Since it needs to listen for changes globally, the root layout is the correct place for it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-9-style-rich-text-with-tailwind-typography&quot;&gt;Step 9: Style Rich Text with Tailwind Typography&lt;/h3&gt;
&lt;p&gt;Portable Text renders as plain HTML elements with no default styling, so headings, paragraphs, and lists will look unstyled out of the box. The Tailwind Typography plugin fixes this in one line.&lt;/p&gt;
&lt;p&gt;Install it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @tailwindcss/typography
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the plugin to your global CSS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* app/globals.css */
@import &quot;tailwindcss&quot;;

@plugin &quot;@tailwindcss/typography&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You'll use the &lt;code&gt;prose&lt;/code&gt; class on any container wrapping Portable Text content in the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-10-build-the-dynamic-post-page&quot;&gt;Step 10: Build the Dynamic Post Page&lt;/h3&gt;
&lt;p&gt;This is where everything comes together — fetching a single post by slug, pre-rendering it at build time, and rendering its rich text body with &lt;code&gt;PortableText&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
import { sanityFetch } from &quot;@/sanity/lib/live&quot;;
import { allPostSlugsQuery, postBySlugQuery } from &quot;@/sanity/queries&quot;;
import { Post } from &quot;@/sanity/types&quot;;
import { PortableText } from &quot;next-sanity&quot;;
import Image from &quot;next/image&quot;;
import { notFound } from &quot;next/navigation&quot;;

// Tell Next.js which slugs to pre-render at build time
export async function generateStaticParams() {
  const { data: slugs } = await sanityFetch({ query: allPostSlugsQuery });
  return (slugs as { slug: string }[]).map(({ slug }) =&amp;gt; ({ slug }));
}

interface PageProps {
  params: Promise&amp;lt;{ slug: string }&amp;gt;;
}

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;
  const { data } = await sanityFetch({ query: postBySlugQuery, params: { slug } });

  if (!data) notFound(); // renders your app/not-found.tsx

  const post = data as Post;

  return (
    &amp;lt;article className=&quot;max-w-3xl mx-auto px-4 py-12&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-4xl font-bold mb-4&quot;&amp;gt;{post.title}&amp;lt;/h1&amp;gt;

      {post.publishedAt &amp;amp;&amp;amp; (
        &amp;lt;time className=&quot;text-sm text-gray-400 block mb-8&quot;&amp;gt;
          {new Date(post.publishedAt).toLocaleDateString(&quot;en-US&quot;, {
            year: &quot;numeric&quot;,
            month: &quot;long&quot;,
            day: &quot;numeric&quot;,
          })}
        &amp;lt;/time&amp;gt;
      )}

      {post.coverImage?.asset?.url &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;relative mb-8 rounded-xl overflow-hidden&quot;&amp;gt;
          &amp;lt;Image
            src={post.coverImage.asset.url}
            alt={post.title}
            className=&quot;rounded-xl&quot;
            priority
            width={1200}
            height={630}
          /&amp;gt;
        &amp;lt;/div&amp;gt;
      )}

      {Array.isArray(post.body) &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;prose prose-lg max-w-none&quot;&amp;gt;
          &amp;lt;PortableText value={post.body} /&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/article&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A couple of things worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;generateStaticParams&lt;/code&gt; runs at build time and tells Next.js exactly which &lt;code&gt;/blog/[slug]&lt;/code&gt; pages to statically generate, rather than rendering every post on demand&lt;/li&gt;
&lt;li&gt;&lt;code&gt;params&lt;/code&gt; is passed as a &lt;code&gt;Promise&lt;/code&gt; — this matches Next.js 16's async params convention, which is why &lt;code&gt;postBySlugQuery&lt;/code&gt; accepts it directly inside &lt;code&gt;sanityFetch&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;notFound()&lt;/code&gt; triggers your custom &lt;code&gt;not-found.tsx&lt;/code&gt; page if no post matches the slug, instead of crashing with an unhandled error&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting &lt;code&gt;Array.isArray(post.body)&lt;/code&gt; before rendering &lt;code&gt;PortableText&lt;/code&gt;. If &lt;code&gt;body&lt;/code&gt; is ever &lt;code&gt;undefined&lt;/code&gt; (e.g. an editor saved a post without adding body content), &lt;code&gt;PortableText&lt;/code&gt; will throw instead of failing gracefully.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;why-groq-instead-of-a-rest-or-graphql-api-&quot;&gt;Why GROQ instead of a REST or GraphQL API?&lt;/h4&gt;
&lt;p&gt;GROQ is built specifically for querying deeply nested, structured content in a single request — including resolving references like images — without the boilerplate of a separate GraphQL schema or multiple REST calls.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-to-deploy-sanity-studio-separately-&quot;&gt;Do I need to deploy Sanity Studio separately?&lt;/h4&gt;
&lt;p&gt;No. Since Studio is embedded inside your Next.js app at &lt;code&gt;/studio&lt;/code&gt;, it deploys automatically whenever your Next.js app deploys — no separate hosting or build step required.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-render-images-from-sanity-with-next-js-image-optimization-&quot;&gt;How do I render images from Sanity with Next.js Image optimization?&lt;/h4&gt;
&lt;p&gt;Use &lt;code&gt;@sanity/image-url&lt;/code&gt; to build the image URL, then pass it to Next.js &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt;. Add &lt;code&gt;cdn.sanity.io&lt;/code&gt; to the &lt;code&gt;images.remotePatterns&lt;/code&gt; array in &lt;code&gt;next.config.ts&lt;/code&gt; so Next.js is allowed to optimize it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
images: {
  remotePatterns: [{ protocol: &quot;https&quot;, hostname: &quot;cdn.sanity.io&quot; }],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;is-sanitylive-safe-to-use-in-production-&quot;&gt;Is SanityLive safe to use in production?&lt;/h4&gt;
&lt;p&gt;Yes. It's designed for production use and is the recommended way to get real-time content updates without manually managing webhooks or polling.&lt;/p&gt;
&lt;h4 id=&quot;is-the-sanity-studio-accessible-to-the-public-&quot;&gt;Is the Sanity Studio accessible to the public?&lt;/h4&gt;
&lt;p&gt;By default, yes — anyone who visits &lt;code&gt;/studio&lt;/code&gt; can see it (though they can't edit without a Sanity account with the right permissions). To fully restrict it, wrap the studio route in a middleware auth check or only run it in dev using an environment variable guard.&lt;/p&gt;
&lt;h4 id=&quot;how-is-this-different-from-my-current-mdx-based-blog-&quot;&gt;How is this different from my current MDX-based blog?&lt;/h4&gt;
&lt;p&gt;With MDX, content lives as files in your repo — every edit means a commit and redeploy. With Sanity, content lives in the cloud and can be edited through a UI, published instantly, and even handed off to non-technical writers without giving them repo access.&lt;/p&gt;
&lt;h4 id=&quot;can-i-add-more-content-types-later-like-authors-or-categories-&quot;&gt;Can I add more content types later, like authors or categories?&lt;/h4&gt;
&lt;p&gt;Yes. Create a new schema file the same way you created &lt;code&gt;post.ts&lt;/code&gt;, then register it in the &lt;code&gt;types&lt;/code&gt; array inside &lt;code&gt;schemaTypes/index.ts&lt;/code&gt;. Studio will pick it up immediately.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;You now have a fully working Sanity CMS integration inside a Next.js 16 application — complete with an embedded Studio, a typed content model, GROQ-powered queries, and live content updates. From here, you can expand your schema with authors, categories, or custom block content, add Visual Editing for inline previews, or wire up on-demand revalidation through Sanity's webhooks.&lt;/p&gt;
&lt;p&gt;This setup scales well beyond a simple blog — the same pattern works for landing pages, product catalogs, or any structured content your app needs to manage outside the codebase.&lt;/p&gt;
&lt;h3 id=&quot;useful-resources&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.sanity.io/learn/course/content-driven-web-application-foundations/create-a-new-sanity-project&quot;&gt;Sanity: Create a New Sanity Project&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.sanity.io/learn/course/content-driven-web-application-foundations/the-next-sanity-toolkit&quot;&gt;Sanity: The next-sanity Toolkit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.sanity.io/docs/groq&quot;&gt;Sanity GROQ Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.portabletext.org/&quot;&gt;Portable Text Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;p&gt;If you'd like to keep building on this foundation, check out these related guides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/Sanity-Integration-in-Next.js-16&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23495/how-to-integrate-sanity-cms-with-next-js-16-app-router</guid>
<pubDate>Sun, 26 Jul 2026 11:37:52 +0000</pubDate>
</item>
</channel>
</rss>