Integrating Sanity.io with Next.js 15: A Step-by-Step Guide
Create a Powerful, Easy-to-Manage Blog with Sanity and Next.js

This guide walks you through setting up a blog using Sanity.io as a headless CMS and Next.js 14 for the front end. We’ll cover installation, schema creation, data fetching, and displaying your content beautifully with Tailwind CSS and Prism for code highlighting.
Outline
Here’s a quick overview of what we’ll be covering:
- Setting up Sanity: We’ll install the necessary packages and initialize a Sanity project, creating our content studio.
- Structuring our Project: We’ll define the file and folder organization for our Next.js application.
- Designing the Article Template: We create tamplate that will define blog display
- Styling with Portable Text: We’ll learn how to style Sanity’s rich text content using Portable Text and Tailwind CSS.
- Date Formatting: A helper function to display dates beautifully.
- TypeScript Types: Defining types for our Sanity data for type safety.
- Code Highlighting: We’ll add syntax highlighting for code snippets using prism-react-renderer.
- Fetching Data: We’ll write GROQ queries to fetch our blog posts from Sanity.
- Sanity Client Setup: Configuring the Sanity client for data retrieval.
- Building Blog Pages: Creating the main blog listing page and individual post pages.
- Image Configuration: Setting up Next.js to handle images from the Sanity CDN.
- Dynamic Sitemap: We’ll automate sitemap generation to include all published blog posts.
Let’s dive in and start building!
1. Installation and Sanity Studio Setup
First, install the necessary Sanity client package
1npm i next-sanitycontent_copydownload
Next, initialize a Sanity project. This command creates a new Sanity project or connects to an existing one. It prompts you for configuration options (project name, dataset, etc.). Accept the defaults for a quick start.
npx sanity@latest init
After the initialization completes (it can take a few minutes), navigate to your project's studio:
1npm run dev
Then open your browser and go to http://localhost:3000/studio. If the Sanity Studio loads correctly, your installation is successful! This is where you'll manage your content.
2. Project structure
1my-blog/2├── src/3│ ├── app/4│ │ ├── blog/5│ │ │ ├── [id]/6│ │ │ │ └── page.tsx # Dynamic blog post page7│ │ │ └── page.tsx # Blog listing page8│ ├── components/9│ │ ├── template/10│ │ │ └── article.tsx # Article template component11│ │ └── code-block.tsx # For syntax-highlighted code12│ ├── sanity/13│ │ ├── lib/14│ │ │ ├── client.ts # Sanity client setup15│ │ │ ├── image.ts # Image URL builder (not shown, but good practice)16│ │ │ ├── portabletext-components.tsx # Portable Text styling17│ │ │ └── queries.ts # GROQ queries for fetching data18│ │ ├── types/19│ │ │ └── index.ts # TypeScript types for Sanity data20│ ├── utils/21│ │ └── formatDate.ts # Date formatting helper
3. Creating the Article Template Component
Create a template component at @/components/template/article.tsx. This file isn't provided in full, as it's highly dependent on your design. However, the core idea is to receive a post prop (of type Post as defined later) and render its content. It'll use the PortableText component and the styling we define in the next step. Key placeholders you would include:
1// Example (simplified)2// components/template/article.tsx3import { PortableText } from '@portabletext/react';4import { portableTextComponents } from '@/sanity/lib/portabletext-components';5import { Post } from '@/sanity/types';6import formatDate from '@/utils/formatDate';7// ... other imports89interface ArticleProps {10 post: Post;11}1213export default function Article({ post }: ArticleProps) {14 return (15 <article>16 <h1>{post.title}</h1>17 {post.subtitle && <h2>{post.subtitle}</h2>}18 <time dateTime={post.publishedAt}>19 {formatDate(post.publishedAt)}20 </time>21 {/* ... other content */}22 <PortableText value={post.body} components={portableTextComponents} />23 </article>24 );25}
4. Styling Portable Text Content
This file (portabletext-components.tsx) controls how content from Sanity's Portable Text is rendered in your application. Instead of dealing with raw text and blocks, this setup ensures structured, well-styled, and responsive content using Tailwind CSS.
- Text Formatting — Defines styling for headings, paragraphs, blockquotes, and inline elements like bold, italics, and code.
- Lists — Supports both bullet and numbered lists with proper spacing.
- Images — Ensures images are displayed responsively using next/image.
- Code Blocks — Integrates Prism-based syntax highlighting for a clean developer-friendly experience.
- Links — Handles internal and external links, opening new tabs when necessary.
1// src/sanity/lib/portabletext-components.tsx2import { PortableTextComponents } from '@portabletext/react'3import Link from 'next/link'4import Image from 'next/image'5import { urlFor } from './image' // You'll need to create this (see Sanity docs)6import { CodeBlock } from '../../components/code-block' // From step 778export const portableTextComponents: PortableTextComponents = {9 block: {10 h1: ({ children }) => (11 <h1 className="text-4xl font-bold mt-12 mb-6">{children}</h1>12 ),13 h2: ({ children }) => (14 <h2 className="text-3xl font-bold mt-10 mb-5">{children}</h2>15 ),16 h3: ({ children }) => (17 <h3 className="text-2xl font-bold mt-8 mb-4">{children}</h3>18 ),19 h4: ({ children }) => (20 <h4 className="text-xl font-bold mt-6 mb-3">{children}</h4>21 ),22 normal: ({ children }) => (23 <p className="mb-6 leading-relaxed">{children}</p>24 ),25 blockquote: ({ children }) => (26 <blockquote className="border-l-4 border-blue-500 pl-4 italic my-6">27 {children}28 </blockquote>29 ),30 },31 marks: {32 strong: ({ children }) => <strong className="font-bold">{children}</strong>,33 em: ({ children }) => <em className="italic">{children}</em>,34 code: ({ children }) => (35 <code className="bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5 font-mono text-sm">36 {children}37 </code>38 ),39 link: ({ value, children }) => {40 const target = (value?.href || '').startsWith('http') ? '_blank' : undefined41 return (42 <Link43 href={value?.href || ''}44 target={target}45 rel={target === '_blank' ? 'noopener noreferrer' : undefined}46 className="text-blue-600 hover:underline"47 >48 {children}49 </Link>50 )51 },52 },53 list: {54 bullet: ({ children }) => (55 <ul className="list-disc pl-6 mb-6 space-y-2">{children}</ul>56 ),57 number: ({ children }) => (58 <ol className="list-decimal pl-6 mb-6 space-y-2">{children}</ol>59 ),60 },61 listItem: {62 bullet: ({ children }) => <li>{children}</li>,63 number: ({ children }) => <li>{children}</li>,64 },65 types: {66 image: ({ value }) => {67 if (!value?.asset) {68 return null69 }70 return (71 <div className="my-8 relative aspect-video">72 <Image73 src={urlFor(value)?.width(1920).quality(90).url() || ''}74 alt={value.alt || ''}75 fill76 className="object-cover rounded-lg"77 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"78 />79 </div>80 )81 },82 code: ({ value }) => (83 <CodeBlock84 code={value?.code || ''}85 language={value?.language || 'typescript'}86 filename={value?.filename}87 />88 ),89 hr: () => <hr className="my-8 border-gray-200 dark:border-gray-800" />,90 },91}
5. Formatting Dates
Create src/utils/formatDate.ts to display dates in a user-friendly format:+
1// src/utils/formatDate.ts2function getOrdinalSuffix(day: number): string {3 if (day >= 11 && day <= 13) {4 return "th";5 }6 switch (day % 10) {7 case 1:8 return "st";9 case 2:10 return "nd";11 case 3:12 return "rd";13 default:14 return "th";15 }16}1718function formatDate(dateString: string): string {19 const date = new Date(dateString);20 const day = date.getDate();21 const month = date.toLocaleString("en-US", { month: "long" });22 const year = date.getFullYear();23 const ordinalSuffix = getOrdinalSuffix(day);2425 return `${day}${ordinalSuffix} ${month} ${year}`;26}2728export default formatDate;
6. Defining TypeScript Types
Create src/sanity/types/index.ts to define TypeScript interfaces for your Sanity data. This improves type safety and autocompletion.
1// src/sanity/types/index.ts2import type { PortableTextBlock } from "@portabletext/types";34interface SanityImage {5 _type: "image";6 asset: {7 _ref: string;8 _type: "reference";9 };10 hotspot?: {11 x: number;12 y: number;13 height: number;14 width: number;15 };16 crop?: {17 top: number;18 bottom: number;19 left: number;20 right: number;21 };22}2324interface SanitySlug {25 _type: "slug";26 current: string;27}2829export interface Author {30 _type: "author";31 name: string;32 image?: SanityImage;33 bio?: PortableTextBlock[];34 slug?: SanitySlug;35 role?: string;36 social?: {37 twitter?: string;38 linkedin?: string;39 github?: string;40 };41}4243export interface Category {44 _type: "category";45 title: string;46 description?: string;47}4849export interface Post {50 _type: "post";51 title: string;52 subtitle?: string;53 slug: SanitySlug;54 author: Author;55 mainImage?: SanityImage;56 categories?: Category[];57 publishedAt: string;58 metaDescription?: string;59 body: PortableTextBlock[];60}
7. Code Block Highlighting
To display code snippets beautifully, we integrate prism-react-renderer, allowing syntax highlighting with a clean and readable UI.
Why This Matters:
- It makes technical content visually appealing.
- Supports filename display (useful for tutorials).
- Improves usability with line numbers and copy-to-clipboard functionality.
Install the necessary package for code highlighting:
1npm i @sanity/code-input prism-react-renderer2
Create src/sanity/components/code-block.tsx to integrate prism-react-renderer:
1// src/sanity/components/code-block.tsx2import { Highlight, themes } from 'prism-react-renderer'34interface CodeBlockProps {5 code: string6 language: string7 filename?: string8}910export function CodeBlock({ code, language, filename }: CodeBlockProps) {11 return (12 <div className="relative my-6 rounded-lg overflow-hidden">13 {filename && (14 <div className="absolute top-0 right-0 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 rounded-bl-lg">15 {filename}16 </div>17 )}18 <Highlight19 theme={themes.nightOwl}20 code={code}21 language={language}22 >23 {({ className, style, tokens, getLineProps, getTokenProps }) => (24 <pre25 className={`${className} p-4 overflow-x-auto`}26 style={{27 ...style,28 backgroundColor: 'rgb(1, 22, 39)',29 marginTop: 0,30 marginBottom: 0,31 }}32 >33 {tokens.map((line, i) => (34 <div key={i} {...getLineProps({ line })}>35 <span className="select-none mr-4 text-gray-500">{i + 1}</span>36 {line.map((token, key) => (37 <span key={key} {...getTokenProps({ token })} />38 ))}39 </div>40 ))}41 </pre>42 )}43 </Highlight>44 </div>45 )46}
8. Fetching Data with GROQ Queries
Create src/sanity/lib/queries.ts to define your GROQ queries. You'll need to adapt these queries to match your exact Sanity schema.
1// src/sanity/lib/queries.ts23export const POSTS_QUERY = `*[_type == "post"] {4 ...,5 author->,6 categories[]->7} | order(publishedAt desc)`;89export const POST_QUERY = `*[_type == "post" && slug.current == $slug][0] {10 ...,11 author->,12 categories[]->13}`;1415// Add more queries as needed, e.g., for authors, categories, etc.
9. Setting up the Sanity Client
This setup configures the Sanity client in src/sanity/lib/client.ts and introduces a sanityFetch helper function to streamline fetching data.
Breakdown of the sanityFetch Function:
- query: The GROQ query to fetch data from Sanity.
- params: Optional query parameters.
- tags: Used for tag-based revalidation (helpful for keeping the cache fresh in Next.js).
- useCdn: Set to true for faster responses using Sanity's CDN unless you need fresh content (e.g., for ISR).
- next.revalidate: Configures revalidation (e.g., every 60 seconds).
1// src/sanity/lib/client.ts2import { createClient } from "next-sanity";34import { apiVersion, dataset, projectId } from "../env"; // Ensure these are set in .env.local56export const client = createClient({7 projectId,8 dataset,9 apiVersion,10 useCdn: true, // Set to false if statically generating pages, using ISR or tag-based revalidation11});1213export async function sanityFetch<QueryResponse>({14 query,15 params = {},16 tags,17}: {18 query: string;19 params?: Record<string, unknown>;20 tags: string[];21}) {22 return client.fetch<QueryResponse>(query, params, {23 next: {24 revalidate: 60, // for simple, time-based revalidation25 tags, // for tag-based revalidation26 },27 });28}
10. Creating Blog Routes and Components
In this section, you’ll set up the main blog listing page and individual blog post pages in your Next.js app. This includes fetching blog data from Sanity, displaying posts with metadata, and ensuring each post has its own dynamic route.
Blog Listing Page (src/app/blog/page.tsx)
This page fetches all blog posts from Sanity, sorts them by date, and displays them in a grid format. Each post includes an image, title, author, date, categories, and a “Read More” button.
1// src/app/blog/page.tsx2import Image from 'next/image';3import Link from 'next/link';456import { urlFor } from '@/sanity/lib/image';7import formatDate from '@/utils/fomatDate';89import type { SanityDocument } from '@sanity/client';1011import { Button } from '@/components/ui/button';12import { sanityFetch } from '@/sanity/lib/client';13import { Post } from '@/sanity/types';14import { POSTS_QUERY } from '@/sanity/lib/queries';1516export default async function BlogPage() {17 const posts = await sanityFetch<SanityDocument<Post>[]>({18 query: POSTS_QUERY,19 tags: ['post']20 });2122 // Sort posts by date descending23 const sortedPosts = [...posts].sort((a, b) =>24 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()25 );2627 return (28 <div className="container mx-auto px-4 py-12 mt-12">29 <div className="flex flex-col lg:flex-row gap-8">30 <div className="lg:w-3/4">31 <h1 className="text-4xl font-bold mb-8">Blog Posts</h1>32 <div className="grid grid-cols-1 md:grid-cols-2 gap-8">33 {sortedPosts.map((post) => (34 <article key={post._id} className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-lg">35 {post.mainImage?.asset && (36 <div className="relative h-48 w-full">37 <Image38 src={urlFor(post.mainImage)?.width(800).quality(80).url() || ''}39 alt={post.title}40 fill41 className="object-cover"42 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"43 />44 </div>45 )}46 <div className="p-6">47 <h2 className="text-xl font-semibold mb-2">48 <Link href={`/blog/${post.slug.current}`} className="hover:text-blue-600">49 {post.title}50 </Link>51 </h2>52 {post.subtitle && (53 <p className="text-gray-600 dark:text-gray-300 mb-4 text-base leading-relaxed">54 {post.subtitle}55 </p>56 )}57 <div className="flex items-center text-sm text-gray-500 dark:text-gray-400">58 <div className="flex items-center">59 {post.author.image?.asset && (60 <div className="relative w-6 h-6 rounded-full overflow-hidden mr-2">61 <Image62 src={urlFor(post.author.image)?.width(100).height(100).url() || ''}63 alt={post.author.name}64 fill65 className="object-cover"66 />67 </div>68 )}69 <span>{post.author.name}</span>70 </div>71 <span className="mx-2">•</span>72 <time dateTime={post.publishedAt}>73 {formatDate(post.publishedAt)}74 </time>75 </div>76 {post.categories && post.categories.length > 0 && (77 <div className="mt-4 flex flex-wrap gap-2">78 {post.categories.map((category) => (79 <span80 key={`${post._id}-${category.title}`}81 className="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 rounded-full"82 >83 {category.title}84 </span>85 ))}86 </div>87 )}88 </div>89 <Button className='m-4'>90 <Link href={`/blog/${post.slug.current}`}>91 Lesen Sie mehr92 </Link>93 </Button>94 </article>95 ))}96 </div>97 </div>9899 </div>100 </div>101 );102}
Individual Blog Post Page (src/app/blog/[id]/page.tsx)
This page dynamically fetches and displays a single blog post based on its slug. It also generates metadata for SEO and handles cases where a post is not found.
1// src/app/blog/[id]/page.tsx2import { Metadata } from 'next';3import { sanityFetch } from '@/sanity/lib/client';45import type { SanityDocument } from '@sanity/client';67import Link from 'next/link';8import Article from '@/components/template/article';9import { Post } from '@/sanity/types';10import { POST_QUERY } from '@/sanity/lib/queries';1112type Params = Promise<{ id: string }>;1314export async function generateMetadata({15 params,16}: {17 params: Params;18}): Promise<Metadata> {19 const { id } = await params;2021 const post = await sanityFetch<SanityDocument<Post>>({22 query: POST_QUERY,23 params: { slug: id },24 tags: ['post'],25 });2627 if (!post) {28 return {29 title: 'Artikel nicht gefunden | Firmeneintrag Online',30 description: 'Der angeforderte Artikel konnte nicht gefunden werden.',31 };32 }3334 return {35 title: `${post.title} | Firmeneintrag Online`,36 description:37 post.metaDescription ||38 post.subtitle ||39 post.body?.[0]?.children?.[0]?.text?.slice(0, 155) + '...' ||40 post.title,41 };42}4344export default async function BlogPostPage({45 params,46}: {47 params: Params;48}) {49 const { id } = await params;5051 const post = await sanityFetch<SanityDocument<Post>>({52 query: POST_QUERY,53 params: { slug: id },54 tags: ['post'],55 });5657 if (!post) {58 return (59 <div className="container mt-12 mx-auto px-4 pb-12">60 <div className="max-w-3xl mx-auto">61 <h1 className="text-4xl font-bold mb-4">Artikel nicht gefunden</h1>62 <p className="text-muted-foreground mb-8">63 Der gesuchte Artikel existiert leider nicht.64 </p>65 <Link href="/blog" className="text-primary hover:underline">66 ← Zurück zum Blog67 </Link>68 </div>69 </div>70 );71 }7273 return <Article post={post} />;74}
11. Configuring Image Handling in next.config.js
To display images from your Sanity CMS in a Next.js application, you need to configure Next.js to allow loading images from the Sanity CDN. In this section, you’ll update your next.config.js file to specify which external image sources are permitted. This ensures that images hosted on Sanity (and other external sources like LinkedIn) can be optimized and served efficiently by Next.js.
1// next.config.js2/** @type {import('next').NextConfig} */3const nextConfig = {4 images: {5 remotePatterns: [6 {7 protocol: 'https',8 hostname: 'cdn.sanity.io',9 pathname: '/images/**',10 },11 {12 protocol: 'https',13 hostname: 'media.licdn.com',14 pathname: '/**',15 },16 ],17 },18}1920module.exports = nextConfig
12. Automatically Updating the Sitemap with Blog Posts
Keeping your sitemap up to date is essential for SEO, ensuring search engines can quickly index new content. In this section, you’ll learn how to automatically include your Sanity blog posts in your Next.js application’s sitemap. By dynamically fetching published posts and adding them to the sitemap, you ensure that search engines always have access to the latest updates. We’ll be modifying the sitemap.ts file to achieve this.
1// src/app/sitemap.ts2import { MetadataRoute } from "next";3import { sanityFetch } from "@/sanity/lib/client";4import { groq } from "next-sanity";5import type { Post } from "@/sanity/types";67// Query to fetch all published posts with their slugs and publish dates8const POSTS_QUERY = groq`9 *[_type == "post" && defined(slug.current) && publishedAt < now()] {10 "slug": slug.current,11 publishedAt12 }13`;14export default async function sitemap(): Promise<MetadataRoute.Sitemap> {15 const baseUrl = "https://egostitelj.si"; // Replace with your site's URL16 // Fetch all published posts17 let posts: Post[] = [];18 try {19 posts = await sanityFetch<Post[]>({20 query: POSTS_QUERY,21 tags: ["post"],22 });23 } catch (error) {24 console.error("Error fetching posts for sitemap:", error);25 // Handle the error appropriately. The sitemap *will* still be generated,26 // but without blog post entries. You might want to log to a monitoring service.27 }28 // Create sitemap entries for each blog post29 const postUrls = posts30 ? posts.map((post) => ({31 url: `${baseUrl}/blog/${post.slug}`,32 lastModified: new Date(post.publishedAt),33 changeFrequency: "monthly" as const,34 priority: 0.7,35 }))36 : [];37 return [38 {39 url: baseUrl,40 lastModified: new Date(),41 changeFrequency: "monthly",42 priority: 1,43 },44 {45 url: `${baseUrl}/blog`,46 lastModified: new Date(),47 changeFrequency: "weekly",48 priority: 0.8,49 },50 {51 url: `${baseUrl}/pogoji-uporabe`, // Example static page52 lastModified: new Date(),53 changeFrequency: "yearly",54 priority: 0.5,55 },56 {57 url: `${baseUrl}/zasebnost`, // Example static page58 lastModified: new Date(),59 changeFrequency: "yearly",60 priority: 0.5,61 },62 {63 url: `${baseUrl}/kontakt`, // Example static page64 lastModified: new Date(),65 changeFrequency: "monthly",66 priority: 0.7,67 },68 {69 url: `${baseUrl}/orodja/kalkulator-prihrankov-provizij`, // Example tool page70 lastModified: new Date(),71 changeFrequency: "monthly",72 priority: 0.8,73 },74 {75 url: `${baseUrl}/orodja/turisticna-taksa`, // Example tool page76 lastModified: new Date(),77 changeFrequency: "monthly",78 priority: 0.8,79 },80 // Add all blog post URLs81 ...postUrls,82 ];83}
Visiting sitemap.xml on http://localhost:3000/sitemap.xml will now display blog links
Conclusion
And there you have it! You’ve successfully built a complete blog using the powerful combination of Sanity.io and Next.js 15.
From setting up your content studio to automatically generating your sitemap, you now have a solid foundation for creating and managing a dynamic, content-rich website.
Thanks,
Matija