web dev

Sanity NextJS Integration

By aregbesola
Sanity NextJS Integration

Integrating Sanity CMS with My Next.js Portfolio (And How You Can Too)

[@portabletext/react] Unknown block type "image", specify a component for it in the `components.types` prop

When I set out to rebuild my portfolio, I knew I didn't want to hardcode every project, blog post, and bio update directly into my components. Every tiny change — a new project, an updated headline, a fixed typo — would mean digging into code, redeploying, and hoping nothing broke. I wanted a setup where I could update content from a clean dashboard without touching a single line of code.

That's what led me to Sanity, a headless CMS that pairs beautifully with Next.js. In this article, I'll walk through exactly how I integrated Sanity into my Next.js portfolio, the decisions I made along the way, and how you can set up the same thing for your own site.

Why Sanity?

Before diving into the "how," here's the "why." A few things sold me on Sanity over alternatives like Contentful or Strapi:

  • Free, generous tier — perfect for a personal portfolio.
  • Sanity Studio — a fully customizable, React-based content editor that I could embed directly in my project or host separately.
  • GROQ — Sanity's query language, which is more flexible and intuitive than REST-based alternatives once you get the hang of it.
  • Real-time collaboration and versioning out of the box.
  • Structured content — I define schemas (project, post, skill, etc.) as code, so my content model lives in version control alongside my site.

Project Overview

My portfolio needed to display:

A list of projects (title, description, tech stack, links, cover image)

A blog section (title, slug, body content, publish date)

Basic site settings (name, bio, social links)

Sanity was a natural fit because each of these maps cleanly to a schema.

Step 1: Setting Up the Sanity Project

I started by installing the Sanity CLI and initializing a new project.

npm install -g sanity@latest
sanity init

The CLI walks you through:

  • Logging in / creating a Sanity account
  • Creating a new project (or selecting an existing one)
  • Choosing a dataset name (I used production)
  • Selecting a starter template — I went with the clean/empty template so I could define my own schemas from scratch

This creates a studio folder (I named mine sanity-studio) with the Sanity Studio config, ready to run locally.

cd sanity-studio
npm run dev

This spins up the Studio locally at http://localhost:3333.

Step 2: Defining My Schemas

Inside sanity-studio/schemaTypes, I created a schema file per content type.

project.ts

import { defineField, defineType } from 'sanity'

export default defineType({
name: 'project',
title: 'Project',
type: 'document',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string' }),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
}),
defineField({ name: 'description', title: 'Description', type: 'text' }),
defineField({
name: 'techStack',
title: 'Tech Stack',
type: 'array',
of: [{ type: 'string' }],
}),
defineField({ name: 'coverImage', title: 'Cover Image', type: 'image', options: { hotspot: true } }),
defineField({ name: 'liveUrl', title: 'Live URL', type: 'url' }),
defineField({ name: 'githubUrl', title: 'GitHub URL', type: 'url' }),
defineField({ name: 'publishedAt', title: 'Published At', type: 'datetime' }),
],
})

post.ts followed a similar structure, with a body field of type array using blockContent (Sanity's portable text format) so I could write rich blog content directly in the Studio.

I then registered these in schemaTypes/index.ts:

import project from './project'
import post from './post'
import siteSettings from './siteSettings'

export const schemaTypes = [project, post, siteSettings]

This step is the heart of the integration — your schema is your content model, and it lives right there in code, versioned alongside the rest of your project.

Step 3: Connecting Sanity to Next.js

With the Studio running and content types defined, it was time to pull that data into my Next.js app (App Router, in my case).

First, install the client libraries in the Next.js project (not the studio):

npm install @sanity/client @sanity/image-url next-sanity

Then I created a small client config:

sanity/lib/client.ts

import { createClient } from 'next-sanity'

export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: '2024-01-01',
useCdn: true, // faster, cached reads for published content
})

And an image URL builder, since Sanity stores images as references rather than raw URLs:

sanity/lib/image.ts

import imageUrlBuilder from '@sanity/image-url'
import { client } from './client'

const builder = imageUrlBuilder(client)

export function urlFor(source: any) {
return builder.image(source)
}

Environment variables went into .env.local:

NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production

You can find your project ID either in the Sanity dashboard or in sanity.config.ts inside your studio folder.

Step 4: Querying Content with GROQ

This is where Sanity's query language, GROQ, comes in. It reads almost like plain English once you get used to it.

To fetch all projects, sorted by most recent:

// sanity/lib/queries.ts
export const projectsQuery = `*[_type == "project"] | order(publishedAt desc) {
_id,
title,
slug,
description,
techStack,
coverImage,
liveUrl,
githubUrl
}`

And in a Server Component:

// app/projects/page.tsx
import { client } from '@/sanity/lib/client'
import { projectsQuery } from '@/sanity/lib/queries'
import { urlFor } from '@/sanity/lib/image'

export default async function ProjectsPage() {
const projects = await client.fetch(projectsQuery)

return (
<section className="grid gap-8 md:grid-cols-2">
{projects.map((project: any) => (
<div key={project._id} className="rounded-lg border p-4">
<img
src={urlFor(project.coverImage).width(600).height(340).url()}
alt={project.title}
className="rounded-md"
/>
<h2 className="mt-4 text-xl font-semibold">{project.title}</h2>
<p className="text-gray-600">{project.description}</p>
<div className="mt-2 flex flex-wrap gap-2">
{project.techStack.map((tech: string) => (
<span key={tech} className="rounded-full bg-gray-100 px-3 py-1 text-sm">
{tech}
</span>
))}
</div>
</div>
))}
</section>
)
}

Because this is a Server Component, the data fetch happens at request/build time on the server — no client-side loading spinners needed for something as static as a portfolio's project list.

Step 5: Rendering Blog Posts (Portable Text)

Sanity stores rich text as Portable Text, a JSON structure rather than raw HTML. To render it in React, I used @portabletext/react:

npm install @portabletext/react

// app/blog/[slug]/page.tsx
import { PortableText } from '@portabletext/react'
import { client } from '@/sanity/lib/client'

const postQuery = `*[_type == "post" && slug.current == $slug][0]{
title,
body,
publishedAt
}`

export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await client.fetch(postQuery, { slug: params.slug })

return (
<article className="prose mx-auto py-10">
<h1>{post.title}</h1>
<PortableText value={post.body} />
</article>
)
}

Wrapping it in Tailwind's prose class gave me clean, readable typography without writing custom styles for headings, links, and lists.

Step 6: Enabling Preview and Draft Content (Optional but Worth It)

By default, useCdn: true only serves published content. Since I wanted to preview drafts before publishing, I set up a separate preview client with useCdn: false and a read token, gated behind a /api/draft route that Sanity's Studio's "Preview" button could call. This isn't essential for a simple portfolio, but it's a nice touch if you plan to write blog posts and want to see them styled before hitting publish.

Step 7: Deploying

Two things needed deploying:

The Next.js site — I deployed to Vercel as usual. The only extra step was adding NEXT_PUBLIC_SANITY_PROJECT_ID and NEXT_PUBLIC_SANITY_DATASET to Vercel's environment variables.

The Sanity Studio — Sanity offers free hosting for the Studio itself:

cd sanity-studio
sanity deploy

This gives you a URL like your-project.sanity.studio, where you (and anyone else with an editor role) can log in and manage content from anywhere, on any device — no need to run anything locally.

What I'd Do Differently

A few lessons learned, in case they save you time:

  • Define your schemas thoughtfully upfront. Migrating existing content when you change a field type later is more annoying than getting the shape right the first time.
  • Use useCdn: true for production reads. It's noticeably faster and Sanity's CDN handles caching well; only disable it where you specifically need fresh/draft data.
  • Keep the Studio and the Next.js app as separate deployments. Embedding the Studio inside the Next.js app is possible, but keeping them separate made my Next.js build faster and my mental model cleaner.
  • Use TypeScript types generated from your schema (via sanity typegen or manually written types) — it catches a lot of small bugs when your query shape drifts from your schema.

How You Can Do It Too — Quick Checklist

If you want to replicate this setup on your own portfolio:

npm install -g sanity@latest && sanity init to scaffold a Studio.

Define schemas for your content types (projects, posts, settings, etc.).

Install next-sanity, @sanity/client, and @sanity/image-url in your Next.js app.

Set up a Sanity client using your project ID and dataset from .env.local.

Write GROQ queries and fetch them in Server Components.

Use @portabletext/react if you have rich text fields.

Deploy your Next.js app (Vercel) and your Studio (sanity deploy).

That's genuinely most of it. The steepest part of the learning curve is getting comfortable with GROQ and Portable Text — everything else follows familiar Next.js patterns you likely already know.

Final Thoughts

What I like most about this setup is the separation of concerns: my portfolio's design lives in my Next.js codebase, while its content lives in Sanity, editable from a clean dashboard whether I'm on my laptop or my phone. Adding a new project to my portfolio now takes about thirty seconds — no code, no redeploy, no fuss.

If you've been putting off making your portfolio easier to update, this is a genuinely low-friction way to do it, and the free tier is more than enough for a personal site.