Skip to content
Tutorial

How to Build a Small Portfolio with Next.js, Sanity CMS, and Vercel

A practical guide to building a small portfolio with Next.js, Sanity CMS, dynamic project pages, SEO metadata, and Vercel deployment.

Next.js

1. Introduction

A personal portfolio is a good project for learning a modern Headless CMS workflow. It is small enough to build in a weekend, but it still teaches useful production patterns: editable content, dynamic routes, SEO metadata, and deployment.

In my own portfolio redesign, I moved from a traditional content setup to a Headless CMS architecture. Sanity CMS manages the content, Next.js renders the frontend, and Vercel handles deployment. This tutorial uses the same idea, but keeps the example intentionally small so you can build and understand every part.

By the end, you will have a simple Next.js portfolio with:

  • A /projects listing page
  • Dynamic project detail pages at /projects/[slug]
  • Editable project content in Sanity CMS
  • GROQ queries for fetching content
  • SEO metadata generated from CMS fields
  • A Vercel deployment workflow

This is not a full portfolio clone. It is a practical foundation you can extend.

2. What We Are Building

We will build a small portfolio site with one content type: project.

Each project will include:

  • Title
  • Slug
  • Description
  • Tags
  • Body content
  • SEO title and description

The architecture looks like this:

text
Sanity Studio
  -> editors create and publish project content

Sanity Content Lake
  -> stores structured content

Next.js App Router
  -> fetches content with GROQ
  -> renders listing and dynamic project pages
  -> generates SEO metadata

Vercel
  -> deploys the frontend

This pattern is useful if you are coming from WordPress or PHP because it separates the editing experience from the public website. Sanity becomes the content backend, while Next.js controls the frontend experience.

3. Project Requirements

Before starting, you should have:

  • Node.js installed
  • npm, pnpm, yarn, or bun
  • A Sanity account
  • A Vercel account
  • Basic familiarity with React components
  • Basic command line comfort

This tutorial uses npm in the examples. If you prefer another package manager, use the equivalent commands.

4. Setting Up a Next.js Project

Create a new Next.js App Router project:

sh
npx create-next-app@latest nextjs-sanity-portfolio

When prompted, choose:

  • TypeScript: Yes
  • App Router: Yes
  • Tailwind CSS: Yes
  • src/ directory: Your preference
  • Import alias: @/* is fine

This tutorial uses Tailwind CSS for basic styling so the finished example feels like a real portfolio instead of unstyled HTML. The Tailwind classes are not required for the architecture. You can replace them with CSS Modules, plain CSS, Sass, styled components, or your own design system.

Move into the project:

sh
cd nextjs-sanity-portfolio

Start the development server:

sh
npm run dev

Open http://localhost:3000. You now have the frontend shell for your Next.js portfolio.

For this guide, the important folder is app/:

text
app/
  page.tsx
  layout.tsx

Later we will add:

text
app/
  projects/
    page.tsx
    [slug]/
      page.tsx

That [slug] folder is how Next.js dynamic routes work in the App Router.

5. Setting Up Sanity CMS

Now create a Sanity Studio. You can keep it inside the same repository in a separate folder:

sh
npm create sanity@latest studio

When prompted:

  • Log in or create a Sanity account
  • Create a new project
  • Choose the default dataset name, usually production
  • Choose TypeScript
  • Choose a clean project if available

Your folder structure will look like this:

text
nextjs-sanity-portfolio/
  app/
  package.json
  studio/
    sanity.config.ts
    schemaTypes/
    package.json

This setup keeps the frontend and CMS separate. That is a useful habit. The frontend has its own dependencies, and the Sanity Studio has its own dependencies.

Start Sanity Studio:

sh
cd studio
npm run dev

Sanity Studio usually runs at http://localhost:3333.

6. Creating a Simple Sanity Schema

Sanity schemas define the shape of your content. In this example, we need a project document.

Create studio/schemaTypes/projectType.ts:

projectType.tstypescript
import { defineArrayMember, defineField, defineType } from "sanity";

export const projectType = defineType({
  name: "project",
  title: "Project",
  type: "document",
  fields: [
    defineField({
      name: "title",
      title: "Title",
      type: "string",
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: "slug",
      title: "Slug",
      type: "slug",
      options: {
        source: "title",
        maxLength: 96,
      },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: "description",
      title: "Description",
      type: "text",
      rows: 3,
      validation: (Rule) => Rule.required().max(180),
    }),
    defineField({
      name: "tags",
      title: "Tags",
      type: "array",
      of: [defineArrayMember({ type: "string" })],
      options: {
        layout: "tags",
      },
    }),
    defineField({
      name: "body",
      title: "Body",
      type: "array",
      of: [
        defineArrayMember({
          type: "block",
        }),
      ],
    }),
    defineField({
      name: "seo",
      title: "SEO",
      type: "object",
      fields: [
        defineField({
          name: "metaTitle",
          title: "Meta Title",
          type: "string",
        }),
        defineField({
          name: "metaDescription",
          title: "Meta Description",
          type: "text",
          rows: 3,
          validation: (Rule) => Rule.max(160),
        }),
      ],
    }),
  ],
  preview: {
    select: {
      title: "title",
      subtitle: "description",
    },
  },
});

Then register the schema in studio/schemaTypes/index.ts:

index.tstypescript
import { projectType } from "./projectType";

export const schemaTypes = [projectType];

Check that studio/sanity.config.ts uses those schema types:

sanity.config.tstypescript
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { schemaTypes } from "./schemaTypes";

export default defineConfig({
  name: "default",
  title: "Portfolio CMS",
  projectId: "your-project-id",
  dataset: "production",
  plugins: [structureTool()],
  schema: {
    types: schemaTypes,
  },
});

eplace your-project-id with the project ID Sanity created for you.

Now open Sanity Studio, create a few project documents, generate slugs, and publish them.

7. Connecting Sanity to Next.js

Go back to the root of your Next.js app:

sh
cd ..

Install the Sanity packages needed by the frontend:

sh
npm install next-sanity @portabletext/react

Create .env.local in the Next.js project root:

text
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production

For public portfolio content, you usually do not need a token. If your dataset is private or you need draft previews, you will need a read token later. Keep tokens server-side and never expose them with a NEXT_PUBLIC_ prefix.

Create lib/sanity/client.ts:

client.tstypescript
import { createClient } from "next-sanity";

export const sanityClient = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: "2026-06-09",
  useCdn: process.env.NODE_ENV === "production",
});

The apiVersion should be a fixed date string. Do not use the current date dynamically in production code. A fixed API version makes your project more predictable.

You may also need to add CORS origins in Sanity:

  • http://localhost:3000 for local development
  • Your Vercel production URL after deployment

You can manage this in the Sanity project dashboard.


8. Fetching Projects with GROQ

GROQ is Sanity's query language. It lets you ask for exactly the fields your frontend needs.

Create lib/sanity/queries.ts:

queries.tstypescript
import { groq } from "next-sanity";
import { sanityClient } from "./client";

export type Project = {
  _id: string;
  title: string;
  slug: string;
  description: string;
  tags?: string[];
  body?: unknown[];
  seo?: {
    metaTitle?: string;
    metaDescription?: string;
  };
};

export async function getProjects(): Promise<Project[]> {
  return sanityClient.fetch(
    groq`*[_type == "project"] | order(title asc) {
      _id,
      title,
      "slug": slug.current,
      description,
      tags
    }`
  );
}

export async function getProjectBySlug(slug: string): Promise<Project | null> {
  return sanityClient.fetch(
    groq`*[_type == "project" && slug.current == $slug][0] {
      _id,
      title,
      "slug": slug.current,
      description,
      tags,
      body,
      seo
    }`,
    { slug }
  );
}

This line is important:

text
"slug": slug.current

Sanity stores slugs as objects. The query maps slug.current to a plain slug string so the React code is easier to work with.

9. Creating a Projects Listing Page

Create app/projects/page.tsx:

page.tsxtsx
import Link from "next/link";
import { getProjects } from "@/lib/sanity/queries";

export const metadata = {
  title: "Projects | My Portfolio",
  description: "Selected projects from my portfolio.",
};

export default async function ProjectsPage() {
  const projects = await getProjects();

  return (
    <main className="mx-auto max-w-5xl px-6 py-16">
      <section className="mb-12">
        <p className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-500">
          Portfolio
        </p>
        <h1 className="text-4xl font-bold tracking-tight text-slate-950">
          Projects
        </h1>
        <p className="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
          A selection of things I have designed, built, or shipped.
        </p>
      </section>

      <section>
        {projects.length === 0 ? (
          <p className="rounded-lg border border-dashed border-slate-300 p-6 text-slate-600">
            No projects have been published yet.
          </p>
        ) : (
          <ul className="grid gap-6 sm:grid-cols-2">
            {projects.map((project) => (
              <li
                key={project._id}
                className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"
              >
                <h2 className="text-xl font-semibold text-slate-950">
                  <Link href={`/projects/${project.slug}`} className="hover:text-blue-600">
                    {project.title}
                  </Link>
                </h2>
                <p className="mt-3 leading-7 text-slate-600">{project.description}</p>

                {project.tags && project.tags.length > 0 && (
                  <ul className="mt-5 flex flex-wrap gap-2">
                    {project.tags.map((tag) => (
                      <li
                        key={tag}
                        className="rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700"
                      >
                        {tag}
                      </li>
                    ))}
                  </ul>
                )}
              </li>
            ))}
          </ul>
        )}
      </section>
    </main>
  );
}

This is still a small UI, but it gives the listing page a useful starting point: a readable container, a responsive grid, simple cards, and tag pills. Treat these Tailwind classes as a baseline. A real portfolio should eventually reflect your own visual style.

At this point, visiting /projects should show the project documents you published in Sanity.

10. Creating Dynamic Project Pages

Now create a dynamic route for each project.

Create app/projects/[slug]/page.tsx:

page.tsxtsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PortableText } from "@portabletext/react";
import { getProjectBySlug, getProjects } from "@/lib/sanity/queries";

type ProjectPageProps = {
  params: Promise<{
    slug: string;
  }>;
};

export async function generateStaticParams() {
  const projects = await getProjects();

  return projects.map((project) => ({
    slug: project.slug,
  }));
}

export async function generateMetadata({
  params,
}: ProjectPageProps): Promise<Metadata> {
  const { slug } = await params;
  const project = await getProjectBySlug(slug);

  if (!project) {
    return {
      title: "Project Not Found",
    };
  }

  return {
    title: project.seo?.metaTitle ?? `${project.title} | My Portfolio`,
    description: project.seo?.metaDescription ?? project.description,
    openGraph: {
      title: project.seo?.metaTitle ?? project.title,
      description: project.seo?.metaDescription ?? project.description,
      type: "article",
      url: `/projects/${project.slug}`,
    },
  };
}

export default async function ProjectPage({ params }: ProjectPageProps) {
  const { slug } = await params;
  const project = await getProjectBySlug(slug);

  if (!project) {
    notFound();
  }

  return (
    <main className="mx-auto max-w-3xl px-6 py-16">
      <article>
        <header className="mb-10 border-b border-slate-200 pb-8">
          <p className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-500">
            Project
          </p>
          <h1 className="text-4xl font-bold tracking-tight text-slate-950">
            {project.title}
          </h1>
          <p className="mt-5 text-lg leading-8 text-slate-600">{project.description}</p>

          {project.tags && project.tags.length > 0 && (
            <ul className="mt-6 flex flex-wrap gap-2">
              {project.tags.map((tag) => (
                <li
                  key={tag}
                  className="rounded-full bg-slate-100 px-3 py-1 text-sm text-slate-700"
                >
                  {tag}
                </li>
              ))}
            </ul>
          )}
        </header>

        {project.body && (
          <div className="space-y-6 text-base leading-8 text-slate-700 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:text-slate-950 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-slate-950 [&_a]:text-blue-600 [&_a]:underline">
            <PortableText value={project.body} />
          </div>
        )}
      </article>
    </main>
  );
}

There are three important pieces here.

First, generateStaticParams() tells Next.js which project pages can be generated from Sanity content.

Second, getProjectBySlug(slug) fetches one project based on the current URL.

Third, generateMetadata() creates SEO metadata from the same CMS document.

This is the core pattern behind many Headless CMS sites: fetch content by slug, render the page, and generate metadata from the content.

11. Adding SEO Metadata

SEO is easier to maintain when content editors can control page-level fields without touching code.

In the schema, we added:

json
seo: {
  metaTitle: string;
  metaDescription: string;
}

In the route, we used those fields:

typescript
return {
  title: project.seo?.metaTitle ?? `${project.title} | My Portfolio`,
  description: project.seo?.metaDescription ?? project.description,
};

The fallback matters. Editors should not have to fill every SEO field for every project. A good default keeps the site usable:

  • Use the custom meta title if it exists
  • Otherwise use the project title
  • Use the custom meta description if it exists
  • Otherwise use the project description

For a real portfolio, you can improve this further by adding:

  • Open Graph images
  • Canonical URLs
  • Sitemap generation
  • Structured data
  • Better social sharing previews

Keep the first version simple. Add complexity only when you need it.

12. Deploying to Vercel

Before deploying, commit your project to GitHub, GitLab, or Bitbucket.

Then deploy the Next.js frontend:

  1. Go to Vercel.
  2. Create a new project.
  3. Import your Git repository.
  4. Select the Next.js app root.
  5. Add the environment variables
  6. Deploy the project.
.envtext
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production

After deployment, copy your Vercel production URL and add it as an allowed CORS origin in Sanity.

You have two common options for the Sanity Studio:

  • Deploy Sanity Studio separately, either with Sanity's hosting or as its own Vercel project.
  • Keep Sanity Studio local if you are the only editor and do not need a hosted editing interface.

For a serious portfolio, I prefer deploying the Studio separately. It keeps the public frontend and the editing interface independent, which makes deployments and access control clearer.

13. Common Improvements

This tutorial builds the smallest useful version. Once it works, you can improve it in several directions.

Add blog posts

Create a post schema with fields like:

  • Title
  • Slug
  • Excerpt
  • Published date
  • Tags
  • Body
  • SEO

Then repeat the same routing pattern:

text
app/
  blog/
    page.tsx
    [slug]/
      page.tsx

The project pages and blog posts can share the same Headless CMS architecture.

Add images

Projects usually need screenshots. Add an image field to the Sanity schema:

typescript
defineField({
  name: "coverImage",
  title: "Cover Image",
  type: "image",
  options: {
    hotspot: true,
  },
  fields: [
    defineField({
      name: "alt",
      title: "Alt Text",
      type: "string",
    }),
  ],
});

Then query the image URL:

json
"coverImage": {
  "url": coverImage.asset->url,
  "alt": coverImage.alt
}

For production image handling, use Sanity's image builder and Next.js image optimization rather than manually hardcoding image dimensions everywhere.

Add preview mode

This guide only fetches published content. Preview mode lets you see draft Sanity content inside Next.js before publishing.

That is useful, but it adds more moving parts:

  • A Sanity read token
  • Draft mode routes in Next.js
  • Preview configuration in Sanity
  • More careful caching rules

Add preview mode after the basic published-content workflow is stable.

Improve caching

For small portfolio sites, static generation is often enough. If you update content frequently, consider:

  • Incremental Static Regeneration
  • Sanity webhooks
  • Tag-based revalidation
  • Separate caching rules for development and production

The tradeoff is freshness versus complexity. Start simple, then optimize when the site needs it.

Add stronger TypeScript types

The example uses a small hand-written Project type. That is fine for a tutorial.

For larger projects, consider generated types or a stricter content typing workflow so your frontend and schema stay in sync.

14. Final Thoughts

A Next.js portfolio with Sanity CMS is a practical way to learn Headless CMS architecture. You get an editable backend, a modern React frontend, dynamic routes, and a deployment flow that works well on Vercel.

The key idea is simple:

  1. Model your content in Sanity.
  2. Query that content with GROQ.
  3. Render it with Next.js App Router pages.
  4. Generate metadata from the same content.
  5. Deploy the frontend to Vercel.

From there, you can add blog posts, images, preview mode, webhooks, analytics, and a more polished design. But the foundation stays the same.

If you are coming from WordPress or a traditional PHP CMS, this workflow may feel different at first. Instead of themes controlling everything, your CMS stores structured content and your frontend decides how to display it. That separation is what makes a Headless CMS approach flexible.

For a personal site, that flexibility is especially useful. You can redesign the frontend later without throwing away your content.

References