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.
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
/projectslisting 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:
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 frontendThis 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:
npx create-next-app@latest nextjs-sanity-portfolioWhen 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:
cd nextjs-sanity-portfolioStart the development server:
npm run devOpen http://localhost:3000. You now have the frontend shell for your Next.js portfolio.
For this guide, the important folder is app/:
app/
page.tsx
layout.tsxLater we will add:
app/
projects/
page.tsx
[slug]/
page.tsxThat [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:
npm create sanity@latest studioWhen 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:
nextjs-sanity-portfolio/
app/
package.json
studio/
sanity.config.ts
schemaTypes/
package.jsonThis 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:
cd studio
npm run devSanity 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:
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:
import { projectType } from "./projectType";
export const schemaTypes = [projectType];Check that studio/sanity.config.ts uses those schema types:
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:
cd ..Install the Sanity packages needed by the frontend:
npm install next-sanity @portabletext/reactCreate .env.local in the Next.js project root:
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=productionFor 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:
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:3000for 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:
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:
"slug": slug.currentSanity 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:
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:
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:
seo: {
metaTitle: string;
metaDescription: string;
}In the route, we used those fields:
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:
- Go to Vercel.
- Create a new project.
- Import your Git repository.
- Select the Next.js app root.
- Add the environment variables
- Deploy the project.
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=productionAfter 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:
app/
blog/
page.tsx
[slug]/
page.tsxThe 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:
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:
"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:
- Model your content in Sanity.
- Query that content with GROQ.
- Render it with Next.js App Router pages.
- Generate metadata from the same content.
- 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
- Next.js
create-next-appCLI documentation: https://nextjs.org/docs/app/api-reference/cli/create-next-app - Sanity getting started documentation: https://www.sanity.io/docs/getting-started
- Vercel Next.js deployment documentation: https://vercel.com/docs/frameworks/full-stack/nextjs