← Back to Blog

Integrating Sanity.io with Next.js 15: A Step-by-Step Guide

Create a Powerful, Easy-to-Manage Blog with Sanity and Next.js

·Matija Žiberna·
CodingNextJs
Integrating Sanity.io with Next.js 15: A Step-by-Step Guide

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 page
7│ │ │ └── page.tsx # Blog listing page
8│ ├── components/
9│ │ ├── template/
10│ │ │ └── article.tsx # Article template component
11│ │ └── code-block.tsx # For syntax-highlighted code
12│ ├── sanity/
13│ │ ├── lib/
14│ │ │ ├── client.ts # Sanity client setup
15│ │ │ ├── image.ts # Image URL builder (not shown, but good practice)
16│ │ │ ├── portabletext-components.tsx # Portable Text styling
17│ │ │ └── queries.ts # GROQ queries for fetching data
18│ │ ├── types/
19│ │ │ └── index.ts # TypeScript types for Sanity data
20│ ├── 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.tsx
3import { PortableText } from '@portabletext/react';
4import { portableTextComponents } from '@/sanity/lib/portabletext-components';
5import { Post } from '@/sanity/types';
6import formatDate from '@/utils/formatDate';
7// ... other imports
8
9interface ArticleProps {
10 post: Post;
11}
12
13export 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.tsx
2import { 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 7
7
8export 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' : undefined
41 return (
42 <Link
43 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 null
69 }
70 return (
71 <div className="my-8 relative aspect-video">
72 <Image
73 src={urlFor(value)?.width(1920).quality(90).url() || ''}
74 alt={value.alt || ''}
75 fill
76 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 <CodeBlock
84 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.ts
2function 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}
17
18function 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);
24
25 return `${day}${ordinalSuffix} ${month} ${year}`;
26}
27
28export 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.ts
2import type { PortableTextBlock } from "@portabletext/types";
3
4interface 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}
23
24interface SanitySlug {
25 _type: "slug";
26 current: string;
27}
28
29export 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}
42
43export interface Category {
44 _type: "category";
45 title: string;
46 description?: string;
47}
48
49export 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-renderer
2

Create src/sanity/components/code-block.tsx to integrate prism-react-renderer:

1// src/sanity/components/code-block.tsx
2import { Highlight, themes } from 'prism-react-renderer'
3
4interface CodeBlockProps {
5 code: string
6 language: string
7 filename?: string
8}
9
10export 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 <Highlight
19 theme={themes.nightOwl}
20 code={code}
21 language={language}
22 >
23 {({ className, style, tokens, getLineProps, getTokenProps }) => (
24 <pre
25 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.ts
2
3export const POSTS_QUERY = `*[_type == "post"] {
4 ...,
5 author->,
6 categories[]->
7} | order(publishedAt desc)`;
8
9export const POST_QUERY = `*[_type == "post" && slug.current == $slug][0] {
10 ...,
11 author->,
12 categories[]->
13}`;
14
15// 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.ts
2import { createClient } from "next-sanity";
3
4import { apiVersion, dataset, projectId } from "../env"; // Ensure these are set in .env.local
5
6export const client = createClient({
7 projectId,
8 dataset,
9 apiVersion,
10 useCdn: true, // Set to false if statically generating pages, using ISR or tag-based revalidation
11});
12
13export 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 revalidation
25 tags, // for tag-based revalidation
26 },
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.tsx
2import Image from 'next/image';
3import Link from 'next/link';
4
5
6import { urlFor } from '@/sanity/lib/image';
7import formatDate from '@/utils/fomatDate';
8
9import type { SanityDocument } from '@sanity/client';
10
11import { Button } from '@/components/ui/button';
12import { sanityFetch } from '@/sanity/lib/client';
13import { Post } from '@/sanity/types';
14import { POSTS_QUERY } from '@/sanity/lib/queries';
15
16export default async function BlogPage() {
17 const posts = await sanityFetch<SanityDocument<Post>[]>({
18 query: POSTS_QUERY,
19 tags: ['post']
20 });
21
22 // Sort posts by date descending
23 const sortedPosts = [...posts].sort((a, b) =>
24 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
25 );
26
27 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 <Image
38 src={urlFor(post.mainImage)?.width(800).quality(80).url() || ''}
39 alt={post.title}
40 fill
41 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 <Image
62 src={urlFor(post.author.image)?.width(100).height(100).url() || ''}
63 alt={post.author.name}
64 fill
65 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 <span
80 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 mehr
92 </Link>
93 </Button>
94 </article>
95 ))}
96 </div>
97 </div>
98
99 </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.tsx
2import { Metadata } from 'next';
3import { sanityFetch } from '@/sanity/lib/client';
4
5import type { SanityDocument } from '@sanity/client';
6
7import Link from 'next/link';
8import Article from '@/components/template/article';
9import { Post } from '@/sanity/types';
10import { POST_QUERY } from '@/sanity/lib/queries';
11
12type Params = Promise<{ id: string }>;
13
14export async function generateMetadata({
15 params,
16}: {
17 params: Params;
18}): Promise<Metadata> {
19 const { id } = await params;
20
21 const post = await sanityFetch<SanityDocument<Post>>({
22 query: POST_QUERY,
23 params: { slug: id },
24 tags: ['post'],
25 });
26
27 if (!post) {
28 return {
29 title: 'Artikel nicht gefunden | Firmeneintrag Online',
30 description: 'Der angeforderte Artikel konnte nicht gefunden werden.',
31 };
32 }
33
34 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}
43
44export default async function BlogPostPage({
45 params,
46}: {
47 params: Params;
48}) {
49 const { id } = await params;
50
51 const post = await sanityFetch<SanityDocument<Post>>({
52 query: POST_QUERY,
53 params: { slug: id },
54 tags: ['post'],
55 });
56
57 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 Blog
67 </Link>
68 </div>
69 </div>
70 );
71 }
72
73 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.js
2/** @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}
19
20module.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.ts
2import { MetadataRoute } from "next";
3import { sanityFetch } from "@/sanity/lib/client";
4import { groq } from "next-sanity";
5import type { Post } from "@/sanity/types";
6
7// Query to fetch all published posts with their slugs and publish dates
8const POSTS_QUERY = groq`
9 *[_type == "post" && defined(slug.current) && publishedAt < now()] {
10 "slug": slug.current,
11 publishedAt
12 }
13`;
14export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
15 const baseUrl = "https://egostitelj.si"; // Replace with your site's URL
16 // Fetch all published posts
17 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 post
29 const postUrls = posts
30 ? 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 page
52 lastModified: new Date(),
53 changeFrequency: "yearly",
54 priority: 0.5,
55 },
56 {
57 url: `${baseUrl}/zasebnost`, // Example static page
58 lastModified: new Date(),
59 changeFrequency: "yearly",
60 priority: 0.5,
61 },
62 {
63 url: `${baseUrl}/kontakt`, // Example static page
64 lastModified: new Date(),
65 changeFrequency: "monthly",
66 priority: 0.7,
67 },
68 {
69 url: `${baseUrl}/orodja/kalkulator-prihrankov-provizij`, // Example tool page
70 lastModified: new Date(),
71 changeFrequency: "monthly",
72 priority: 0.8,
73 },
74 {
75 url: `${baseUrl}/orodja/turisticna-taksa`, // Example tool page
76 lastModified: new Date(),
77 changeFrequency: "monthly",
78 priority: 0.8,
79 },
80 // Add all blog post URLs
81 ...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

0
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.