Sanity + Next.js: Full-Stack Experience Without Backend

Sanity + Next.js: Full-Stack Experience Without Backend

Let's say you have a website where you want to manage your content dynamically. But you don't want to set up a separate server for this. Because that means a new project, new architecture, separate database, and if you don't already have one, a CDN service. On top of that, you need to set up, configure, and keep synchronized separate databases for each environment (local, test, production).

In addition to all this, you also need to build systems like revalidation and cache management from scratch. The process grows so much in terms of cost, time, and maintenance that sometimes it's even easier to add content directly into the code as static content. So what is Sanity trying to solve at this point?

What is Sanity?

Sanity is a cloud-based headless CMS platform that provides an interface where you can manage your content, a powerful API to access this content, and an infrastructure that serves this data. It has a modern and clean interface.

The most important difference is that you can work without setting up your own backend. Sanity offers data storage, fast media distribution via CDN, querying, and content editing interface under a single service.

Key Features

  • Headless architecture: Next.js and content management work completely separately; data exchange only happens through the API provided by Sanity.
  • CDN-supported media management: Your images and files are automatically distributed through Sanity's global CDN network. (Let's not forget that it also automatically converts images to WebP in the background while doing this)
  • React-based customizable interface: You can adapt its modern and clean interface to your own needs by adding React components or modifying existing views.
  • Flexible data querying with GROQ: You can write custom queries for your content and fetch only the data you need.
  • Webhook support: It can automatically detect changes in content (create, update, delete) and trigger the systems you specify, easily managing cache revalidation or external services.

Setup

Before we start, everything I explain here is already implemented in the GitHub repository. You can directly clone the project and follow along from there.

There are two ways to integrate Sanity Studio into your project:

  • Managing Studio and Next.js application in separate folders (this method is explained in Sanity documentation).
  • Embedding Studio directly into the Next.js project and running it as a route.

I prefer the second method. Having everything together with a single repo and single deploy makes management much easier.

Creating a Sanity Account

  1. Go to Sanity.io.
  2. Click the "Get started" button.
  3. You can register with GitHub, Google, or email.
  4. On first login, there are step-by-step questions to create a customized project for you, it will ask for a project name and dataset name (default could be production).

Next.js Project and Sanity Package Installation

I prepared this guide with Next.js v15.4.6 App Router. In different versions, you might get errors during installation due to version incompatibilities of some dependencies. Before starting the installation, I recommend checking the compatibility of packages like sanity, next-sanity, @sanity/vision, @sanity/image-url with the Next.js version you're using.

# Create new Next.js project (or enter existing project)
npx create-next-app@latest my-app --typescript
cd my-app

# Install dependencies
npm i sanity next-sanity @sanity/vision @sanity/image-url
npm i -D @sanity/types

Create a sanity.config.ts in the root directory.

I'm customizing the admin panel using structureTool instead of the default desk structure. If you want, you can remove the structureTool definition in plugins and use the default desk structure.

'use client';
import { defineConfig } from 'sanity';
import { visionTool } from '@sanity/vision';
import { structureTool } from 'sanity/structure';
import { defineType, defineField } from 'sanity';

const post = defineType({
  name: 'post',
  type: 'document',
  title: 'Post',
  fields: [
    defineField({ name: 'title', type: 'string', title: 'Title' }),
    defineField({
      name: 'slug',
      type: 'slug',
      title: 'Slug',
      options: { source: 'title' },
    }),
    defineField({ name: 'coverImage', type: 'image', title: 'Cover Image' }),
  ],
});

export default defineConfig({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!, // The ID of the project you created in the Sanity.io panel.
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!, // Will be 'production' if you didn't change it.
  title: 'My Sanity Studio',
  schema: { types: [post] },
  basePath: '/studio', // Should be the same as the specified route name.

  // Customize desk structure
  plugins: [
    structureTool({
      structure: (S) =>
        S.list()
          .title('Content')
          .items([
            S.listItem()
              .title('Posts')
              .child(
                S.documentTypeList('post')
                  .title('Posts')
                  .defaultOrdering([{ field: '_createdAt', direction: 'desc' }])
              ),
          ]),
    }),
    visionTool(), // Allows us to write and test GROQ queries live.
  ],
});

Create a sanity.client.ts in the lib folder.

Here, we're creating a client for our GROQ queries to communicate with Sanity. If we don't specify the apiVersion value, the latest version will be used. However, this increases the risk of existing queries breaking unexpectedly in future updates. Therefore, defining the API version with a fixed date and testing new versions first before upgrading would be a healthier approach.

// lib/sanity.client.ts
import { createClient } from 'next-sanity';

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!;
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!;
const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2024-01-01';

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: false, // CDN off for tag-based revalidation
  perspective: 'published',
});

Create a sanity.image.ts in the lib folder.

Sanity serves media files through its own global CDN. To be able to perform optimizations like resizing, cropping, format changing (e.g., automatic WebP) on images, we use the @sanity/image-url package.

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

const builder = imageUrlBuilder(client);

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

/*
 * Example usage:
 * <img src={urlFor(post.coverImage).width(800).auto("format").url()} alt={post.title} />
 */

💡 Note: If you're going to use Next.js's next/image component, you need to allow Sanity's CDN domain in the next.config.ts file:

//next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
        port: '',
        pathname: '/**',
      },
    ],
  },
};

export default nextConfig;

Creating Studio Route

We're embedding Sanity Studio into our Next.js project and running it on the /studio route. For this, we create the app/studio/[[...tool]]/page.tsx file:

// app/studio/[[...tool]]/page.tsx
import { NextStudio } from "next-sanity/studio";
import config from "../../../../sanity.config";

export { metadata, viewport } from "next-sanity/studio"; // Adds necessary <head> information for Studio.

export default function StudioPage() {
  return <NextStudio config={config} />;
}

First Login to Studio

Start the project. Go to http://localhost:3001/studio (according to your own port) from the browser. On first launch, Studio will ask you to add the origin you're on http://localhost:3001 to the CORS allowlist. Then log in with your Sanity account. After login, your customized Studio (with "Posts" in the left menu) will open. Let's create some test data here.

Sanity Studio Interface

Fetching Data with Next.js

Creating the posts.ts service

The service below fetches posts with a GROQ query and returns them tagged with Next.js's tag-based cache mechanism. This way, when content changes in Sanity, we can smartly refresh pages by revalidating only this tag via webhook.

// lib/services/posts.ts
import { groq } from 'next-sanity';
import { client } from '../sanity.client';

export type Post = {
  _id: string;
  title: string;
  slug: string;
  coverImage?: any;
};

export const postsQuery = groq`*[_type == "post"] | order(_createdAt desc){
  _id,
  title,
  "slug": slug.current,
  coverImage
}`;

export async function getPosts(): Promise<Post[]> {
  return client.fetch(
    postsQuery,
    {},
    {
      next: { tags: ['posts'] },
    }
  );
}

Let's edit the page.tsx page

The page below fetches data through GROQ with getPosts() inside a server component and gets cached with the "posts" tag thanks to tag-based cache.

import { getPosts, type Post } from "@/lib/services/posts";
import { urlFor } from "@/lib/sanity.image";
import Image from "next/image";

export default async function Home() {
  const posts: Post[] = await getPosts();

  return (
    <div className="font-sans min-h-screen p-8">
      <main className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold mb-8 text-center">Blog Posts</h1>
        {posts.length === 0 ? (
          <p className="text-gray-600 text-center">No posts found.</p>
        ) : (
          <div className="grid gap-6">
            {posts.map((post) => (
              <article key={post._id} className="max-w-sm mx-auto">
                {post.coverImage && (
                  <div>
                    <Image
                      src={urlFor(post.coverImage).url()}
                      alt={post.title}
                      width={300}
                      height={200}
                      className="w-full h-auto rounded-lg"
                    />
                    <div className="mt-3">
                      <h2 className="text-lg font-semibold mb-1">
                        {post.title}
                      </h2>
                      <p className="text-sm">Slug: {post.slug}</p>
                    </div>
                  </div>
                )}
              </article>
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

After placing the code snippet, run your project in production mode with npm run build and npm run start. Note: Next.js tag cache doesn't work in development mode (npm run dev).

Open the page in your browser and make sure the posts coming from getPosts() are listed. Go to /studio, make a small change to any post and Publish it.

Refresh the page, you'll see that the content hasn't changed. In the Network panel, you can see this in the Response Headers section of the Document request:

cache-control: s-maxage=31536000, stale-while-revalidate

The s-maxage=31536000 here means 31,536,000 seconds (approximately 1 year). So the document will be cached for a long time in the CDN/proxy layer. So how will we keep the content up to date in this case?

Setting Up Sanity Webhook

In this section, we'll set up the webhook flow in three steps. First, let's expose the application to the internet with ngrok so we can test locally.

npm i -g ngrok
# or: brew install ngrok (macOS)
ngrok config add-authtoken <your-ngrok-authtoken>
ngrok http 3001 # Pay attention that it's the port your project is running on.

Ngrok Interface

The HTTPS address provided by ngrok is the base host you'll use in the webhook. Let's define a custom webhook in the Sanity.io panel that will be triggered on post create/update/delete events. Let's create a new webhook from the API > Webhooks page.

Sanity Web Hook Create

In the Trigger on section, let's enable Create, Update, and Delete options. In the URL field, let's write the /api/revalidate route we'll add in the next step, together with the ngrok address we created in the previous step.

Example: https://<ngrok-subdomain>.ngrok-free.app/api/revalidate?secret=YOUR_SECRET_KEY

Let's add a simple endpoint on the Next.js side that validates the secret key and refreshes the relevant tags with revalidateTag. Let's create the /api/revalidate/route.ts file.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const secret = process.env.SANITY_WEBHOOK_SECRET; //"YOUR_SECRET_KEY"

    // The parsed token needs to be the same as the secret.
    const token = new URL(request.url).searchParams.get('secret');

    if (!secret || secret !== token) {
      return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
    }

    const body = await request.json();
    const { _type } = body;

    let tag: string | null = null;

    switch (_type) {
      case 'post':
        tag = 'posts';
        break;
      default:
        return NextResponse.json({ message: `Unknown document type: ${_type}` }, { status: 400 });
    }

    revalidateTag(tag);
    console.log(`✅ Revalidated cache for: ${tag}`);

    return NextResponse.json({
      revalidated: true,
      tag,
      now: Date.now(),
    });
  } catch (error) {
    console.error('❌ Revalidation error:', error);
    return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });
  }
}

If you've made all the changes, restart your project in production mode:

npm run build && npm run start

Then update the relevant post through Studio and Publish it. You should then see output similar to this in your Next.js application terminal:

✅ Revalidated cache for: posts
POST /api/revalidate?secret=YOUR_SECRET_KEY 200 in 167ms

Revalidation is done! Now go back to the browser, refresh the page, and verify that the updated values are reflected.

Note: If you don't see the logs, check the secret matching, that the webhook URL (with ngrok address) is correct, and that the tag name (posts) is the same as what you use in your service.

Recommendations

  • For small and medium-scale projects (blogs, documentation, marketing sites, MVPs), the Sanity + Next.js combination is ideal: lightweight setup, low maintenance cost, high performance.
  • Sanity's free plan is more than sufficient for most scenarios. It easily meets needs like content modeling, media via CDN, GROQ, webhooks.
  • As the project grows (traffic/increasing content volume, more complex workflows, SLA expectations), transition to paid plans can be considered.
  • For large teams/enterprise processes, the free plan may be limited in terms of team authorization, concurrent work, and governance; in this case, paid plans or different solutions should be considered.

Conclusion

In summary, we set up a "full-stack" flow with Sanity + Next.js without setting up a separate backend: we embedded Studio into the project with the /studio route, served images through Sanity CDN with automatic format/optimization (mostly WebP). Then we used Next.js tag-based cache with the getPosts() service and set up the Sanity webhooks → Next.js revalidateTag line to instantly reflect changes despite long cache lifetime in prod mode.

This architecture allows the content team to progress independently through Studio, while the developer keeps performance (cache/CDN) and maintenance costs under control. With single repo/single deploy, manageability increases while setup and operational load is minimized.

If you've read this far, thank you, you're awesome.