A developer portfolio powered by a headless Content Management System (CMS) gives you full control over your content and presentation. This guide walks you through building one with Next.js 16 and Strapi 5, from content modeling to deployment.
In brief:
- Set up a Strapi 5 backend with Collection Types for projects and blog posts, plus Single Types for your about page and global site settings.
- Build a Next.js 16 frontend using the App Router, Server Components, and a reusable data-fetching utility that connects to Strapi's REST API.
- Render Strapi's Rich Text Blocks content in React using the official
@strapi/blocks-react-rendererpackage with custom Tailwind CSS styling. - Deploy the frontend to Vercel and the backend to Strapi Cloud, connected by tag-based revalidation webhooks that keep content fresh.
Prerequisites: You'll need Node.js 20.9 or later (Next.js 16 dropped support for Node.js 18), TypeScript 5.1 or later, basic familiarity with React and Tailwind CSS, and a GitHub account for deployment. No prior Strapi experience is required, though the Strapi 5 beginner's guide is a useful warm-up if you've never touched the Admin Panel.
Why a Headless CMS for Your Portfolio
Most developers hit the same wall with their portfolio: they either hardcode everything and dread updating it, or reach for a monolithic CMS that dictates the frontend. A headless CMS like Strapi solves both problems. You manage content through an Admin Panel and consume it via API, which means your Next.js frontend stays entirely yours to design and optimize.
This architecture separates concerns cleanly. Strapi handles content storage, media management, and API delivery. Next.js handles rendering, routing, and performance. The two communicate over REST, and you get the best of both worlds: a content editing experience that doesn't require code changes, and a frontend that ships with server-side rendering, static generation, and the rest of the App Router toolkit.
Next.js 16 changes a few of the assumptions this stack used to rely on. Turbopack is now the default bundler, params and searchParams are always promises, and caching is explicit rather than implicit. If you're migrating an existing portfolio, the roundup of Next.js 16 features covers what moved and why. This guide is written against the new defaults, so you won't hit those breaking changes on your first build.
By the end, you'll have a working portfolio with dynamic project showcases, a blog, an about page, and a contact form, all powered by content you manage through Strapi's Admin Panel.
Set Up the Strapi Backend
Start by creating a root directory for the entire project, then initialize Strapi inside it:
mkdir portfolio-project
cd portfolio-project
npx create-strapi@latest backendDuring setup, select SQLite as your database (ideal for development), enable TypeScript, skip the example data, and let the installer handle dependencies. Once complete, start the development server:
cd backend
npm run developHead to http://localhost:1337/admin to create your admin account. This Admin Panel is where all your portfolio content lives.
Now set up the Next.js frontend in a sibling directory:
cd ../
npx create-next-app@latest frontend --typescriptChoose Yes for TypeScript, ESLint, Tailwind CSS, the src/ directory, and the App Router. Start the dev server to confirm everything works:
cd frontend
npm run devYour Next.js app should be running at http://localhost:3000. One thing worth noticing: your package.json scripts no longer need a --turbopack flag. Turbopack is the default for both next dev and next build in Next.js 16, which is where the faster refresh times come from.
Create a .env.local file in the frontend directory for your Strapi connection. Separating environment variables by scope keeps your API token out of the client bundle:
- Server-side only (no
NEXT_PUBLIC_prefix): sensitive API tokens and secrets that authenticate your server to Strapi. - Client-side accessible (
NEXT_PUBLIC_prefix): public endpoints your frontend can safely expose, such as the media URL.
Anything without the prefix stays on the server, so keep tokens out of the public group:
# frontend/.env.local
STRAPI_API_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_here
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337You'll generate the API token after building your content types.
Model Your Content in Strapi
Content modeling is where the real architectural decisions happen. A well-structured backend makes frontend development significantly easier, so invest time here before writing any React code. Getting this wrong means restructuring your API calls later, which is never fun.
Project and Blog Post Collection Types
In the Strapi Admin Panel, navigate to Content-Type Builder and create a new Collection Type called Project. Add the following fields:
| Field Name | Type | Notes |
|---|---|---|
title | Text (Short) | Required |
slug | UID | Attached to title |
description | Text (Long) | Required |
content | Rich Text (Blocks) | Detailed project write-up |
coverImage | Media (Single) | Project thumbnail |
technologies | Text (Short) | Comma-separated tech stack |
liveUrl | Text (Short) | Link to live project |
githubUrl | Text (Short) | Link to source code |
isFeatured | Boolean | Controls homepage display |
order | Number (Integer) | Display ordering |
publishedDate | Date | Completion date |
Create a second Collection Type called BlogPost with these fields:
| Field Name | Type | Notes |
|---|---|---|
title | Text (Short) | Required |
slug | UID | Attached to title |
excerpt | Text (Long) | Post summary for listings |
content | Rich Text (Blocks) | Full post body |
coverImage | Media (Single) | Post thumbnail |
category | Enumeration | For example: Tutorial, Case Study |
publishedDate | Date | Publication date |
Single Types for About and Global Settings
Single Types handle one-off pages. Create an About Single Type with these fields:
| Field Name | Type | Notes |
|---|---|---|
heading | Text (Short) | Page title |
bio | Rich Text (Blocks) | Extended biography |
profilePhoto | Media (Single) | Profile image |
skills | JSON | Array of skill name strings |
resumeFile | Media (Single) | Downloadable CV or resume |
Then create a Global Single Type to store site-wide configuration. Single Types work well for unique, non-repeating content like site settings. Include these fields:
| Field Name | Type | Notes |
|---|---|---|
siteName | Text (Short) | Site title used in metadata |
siteDescription | Text (Long) | Default meta description |
favicon | Media (Single) | Browser tab icon |
socialLinks | JSON | Object of platform URLs |
footerText | Text (Short) | Copyright or footer message |
This centralizes site-wide configuration in one location, so you can update your entire portfolio without touching code.
Contact Message Collection Type
Finally, create a ContactMessage Collection Type to store form submissions. Add fields for name (Text, Short), email (Email), subject (Text, Short), and message (Text, Long). Unlike your other content types, this one receives data from the frontend rather than serving it, so you won't enable public find permissions for it. Only your server-side API token will write to it.
API Permissions and Token Generation
Create an API token by going to Settings → API Tokens → Create new API token. Name it "Portfolio Frontend," set the type to Read-only, and set the duration to Unlimited. Copy this token into your .env.local file. If you plan to write to Strapi later (the contact form does), you'll want a Custom token scoped to create on contact-message instead. Strapi's guide to authenticating REST requests walks through the permission model in more depth.
Next, go to Settings → Users and Permissions Plugin → Roles → Public and enable find and findOne for your public content types. This allows your Next.js frontend to query the REST API.
Before moving to the frontend, populate sample content: at least three projects (one marked as featured), two blog posts, and complete About and Global data. Upload images for all media fields through the Media Library.
Build the Frontend
API Utility Layer
Create a reusable data-fetching module at src/lib/strapi.ts. This centralizes your Strapi communication and keeps API logic out of your components. Note the tags array: Next.js 16 caches nothing implicitly, so tagging each request is what makes on-demand revalidation possible later.
// frontend/src/lib/strapi.ts
const STRAPI_URL = process.env.STRAPI_API_URL;
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
export async function fetchFromStrapi<T>(
endpoint: string,
params?: string,
tag?: string
): Promise<T> {
const url = `${STRAPI_URL}/api/${endpoint}${params ? `?${params}` : ""}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
"Content-Type": "application/json",
},
next: { revalidate: 60, tags: [tag ?? endpoint] },
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
}
return response.json();
}One critical detail: Strapi 5 uses a flattened response structure. Fields sit directly on the data object instead of being nested under attributes, which is the main thing that trips up code copied from Strapi 4 tutorials. Define your TypeScript interfaces accordingly:
// frontend/src/types/strapi.ts
import type { BlocksContent } from "@strapi/blocks-react-renderer";
export interface StrapiResponse<T> {
data: T;
meta?: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface Project {
id: number;
documentId: string;
title: string;
slug: string;
description: string;
content: BlocksContent;
coverImage?: StrapiMedia | null;
technologies: string;
liveUrl?: string;
githubUrl?: string;
isFeatured: boolean;
order: number;
publishedDate: string;
}
export interface StrapiMedia {
url: string;
alternativeText?: string;
width: number;
height: number;
formats?: Record<string, { url: string; width: number; height: number }>;
}Now add specific fetching functions. Notice the explicit populate parameters: avoid populate=* in production, since it pulls every relation and significantly impacts response times. Strapi's breakdown of populate and filtering is worth a read if the query syntax feels opaque.
// frontend/src/lib/strapi.ts
import type { StrapiResponse, Project, BlogPost, About, GlobalSettings } from "@/types/strapi";
export async function getProjects() {
return fetchFromStrapi<StrapiResponse<Project[]>>(
"projects",
"populate[coverImage][fields][0]=url&populate[coverImage][fields][1]=alternativeText&populate[coverImage][fields][2]=width&populate[coverImage][fields][3]=height&sort=order:asc",
"projects"
);
}
export async function getFeaturedProjects() {
return fetchFromStrapi<StrapiResponse<Project[]>>(
"projects",
"filters[isFeatured][$eq]=true&populate[coverImage]=*&sort=order:asc",
"projects"
);
}
export async function getProjectBySlug(slug: string) {
const response = await fetchFromStrapi<StrapiResponse<Project[]>>(
"projects",
`filters[slug][$eq]=${slug}&populate=*`,
"projects"
);
return response.data[0] || null;
}
export async function getBlogPosts() {
return fetchFromStrapi<StrapiResponse<BlogPost[]>>(
"blog-posts",
"populate[coverImage][fields][0]=url&populate[coverImage][fields][1]=alternativeText&sort=publishedDate:desc",
"blog-posts"
);
}
export async function getBlogPostBySlug(slug: string) {
const response = await fetchFromStrapi<StrapiResponse<BlogPost[]>>(
"blog-posts",
`filters[slug][$eq]=${slug}&populate=*`,
"blog-posts"
);
return response.data[0] || null;
}
export async function getAbout() {
return fetchFromStrapi<StrapiResponse<About>>("about", "populate=*", "about");
}
export async function getGlobalSettings() {
return fetchFromStrapi<StrapiResponse<GlobalSettings>>("global", "populate=*", "global");
}For complex queries with multiple filters and nested population, the qs library keeps things readable:
// frontend/src/lib/queries.ts
import qs from "qs";
const query = qs.stringify({
filters: { isFeatured: { $eq: true } },
sort: ["order:asc"],
populate: {
coverImage: { fields: ["url", "alternativeText", "width", "height"] },
},
}, { encodeValuesOnly: true });
export default query;Layout and Navigation
Configure next.config.ts to allow images from your Strapi instance. Two Next.js 16 details matter here: images.domains is deprecated in favor of remotePatterns, and local IP optimization is now blocked by default for security, so you need an explicit opt-in while developing against localhost.
// frontend/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
],
// Next.js 16 blocks optimization of local IPs by default.
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
},
};
export default nextConfig;Swap the pattern for your production Strapi hostname before you deploy. Strapi's guide to Next.js image optimization covers the sizing and format trade-offs in detail.
Create shared layout components. The root src/app/layout.tsx fetches global settings for site metadata and wraps everything with Header and Footer:
// frontend/src/app/layout.tsx
import { getGlobalSettings } from "@/lib/strapi";
import { Inter } from "next/font/google";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export async function generateMetadata() {
const { data: global } = await getGlobalSettings();
return {
title: { default: global.siteName, template: `%s | ${global.siteName}` },
description: global.siteDescription,
};
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const { data: global } = await getGlobalSettings();
return (
<html lang="en" className={inter.className}>
<body className="min-h-screen flex flex-col">
<Header siteName={global.siteName} />
<main className="flex-grow">{children}</main>
<Footer
footerText={global.footerText}
socialLinks={global.socialLinks}
/>
</body>
</html>
);
}getGlobalSettings runs twice here, once in generateMetadata and once in the layout, but the revalidate option on the underlying fetch means the second call hits the cache instead of Strapi.
Using next/font is also deliberate. It self-hosts font files, eliminating external network requests and preventing layout shift through display: 'swap', both of which directly affect Lighthouse scores.
Now build the Header and Footer components that the layout imports. The Header provides responsive navigation across the site:
// frontend/src/components/Header.tsx
import Link from "next/link";
interface HeaderProps {
siteName: string;
}
export default function Header({ siteName }: HeaderProps) {
return (
<header className="border-b">
<nav className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="text-xl font-bold hover:text-blue-600 transition">
{siteName}
</Link>
<ul className="flex items-center gap-6 text-sm font-medium">
<li>
<Link href="/projects" className="hover:text-blue-600 transition">
Projects
</Link>
</li>
<li>
<Link href="/blog" className="hover:text-blue-600 transition">
Blog
</Link>
</li>
<li>
<Link href="/about" className="hover:text-blue-600 transition">
About
</Link>
</li>
<li>
<Link
href="/contact"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
Contact
</Link>
</li>
</ul>
</nav>
</header>
);
}The Footer displays the configurable footer text and renders social links pulled from the Global Single Type:
// frontend/src/components/Footer.tsx
interface FooterProps {
footerText: string;
socialLinks: Record<string, string>;
}
export default function Footer({ footerText, socialLinks }: FooterProps) {
return (
<footer className="border-t py-8 mt-16">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-gray-500">{footerText}</p>
<div className="flex gap-4">
{Object.entries(socialLinks || {}).map(([platform, url]) => (
<a
key={platform}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-500 hover:text-blue-600 transition capitalize"
>
{platform}
</a>
))}
</div>
</div>
</footer>
);
}Both components are Server Components by default, with no "use client" directive needed, so they add zero client-side JavaScript to your bundle.
Homepage
The homepage brings together featured projects, recent blog posts, and a hero section. Since this is a Server Component, all data fetching happens on the server with no client-side JavaScript overhead:
// frontend/src/app/page.tsx
import { getFeaturedProjects, getBlogPosts, getAbout } from "@/lib/strapi";
import Image from "next/image";
import Link from "next/link";
export default async function HomePage() {
const [projectsRes, postsRes, aboutRes] = await Promise.all([
getFeaturedProjects(),
getBlogPosts(),
getAbout(),
]);
const projects = projectsRes.data;
const recentPosts = postsRes.data.slice(0, 3);
const about = aboutRes.data;
return (
<>
{/* Hero Section */}
<section className="py-20 px-6 text-center">
<h1 className="text-5xl md:text-6xl font-bold mb-4">
{about.heading}
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8">
Full-Stack Developer
</p>
<Link
href="/projects"
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition"
>
View My Work
</Link>
</section>
{/* Skills Overview */}
<section className="py-12 px-6 max-w-6xl mx-auto">
<h2 className="text-2xl font-bold mb-6 text-center">Tech Stack</h2>
<div className="flex flex-wrap justify-center gap-3">
{(about.skills as string[]).map((skill) => (
<span
key={skill}
className="bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium"
>
{skill}
</span>
))}
</div>
</section>
{/* Featured Projects */}
<section className="py-16 px-6 max-w-6xl mx-auto">
<h2 className="text-3xl font-bold mb-8">Featured Projects</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<Link
key={project.documentId}
href={`/projects/${project.slug}`}
className="group rounded-lg overflow-hidden border hover:shadow-lg transition"
>
{project.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${project.coverImage.url}`}
alt={project.coverImage.alternativeText || project.title}
width={600}
height={400}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="text-xl font-semibold group-hover:text-blue-600 transition">
{project.title}
</h3>
<p className="text-gray-600 mt-2 line-clamp-2">
{project.description}
</p>
</div>
</Link>
))}
</div>
</section>
{/* Recent Blog Posts */}
<section className="py-16 px-6 max-w-6xl mx-auto bg-gray-50">
<h2 className="text-3xl font-bold mb-8">Latest Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{recentPosts.map((post) => (
<Link
key={post.documentId}
href={`/blog/${post.slug}`}
className="bg-white rounded-lg p-6 hover:shadow-md transition"
>
<span className="text-sm text-blue-600 font-medium">
{post.category}
</span>
<h3 className="text-lg font-semibold mt-2">{post.title}</h3>
<p className="text-gray-600 mt-2 text-sm">{post.excerpt}</p>
</Link>
))}
</div>
</section>
</>
);
}Notice Promise.all for parallel data fetching. Three sequential API calls would triple the page load time, so fire them all at once.
Projects Listing and Detail Pages
The projects listing displays all projects in a responsive grid, stacking on mobile and expanding to two columns on larger screens:
// frontend/src/app/projects/page.tsx
import { getProjects } from "@/lib/strapi";
import Image from "next/image";
import Link from "next/link";
export const metadata = {
title: "Projects",
description: "A showcase of my development work and side projects.",
};
export default async function ProjectsPage() {
const { data: projects } = await getProjects();
return (
<section className="py-16 px-6 max-w-6xl mx-auto">
<h1 className="text-4xl font-bold mb-10">All Projects</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{projects.map((project, index) => (
<Link
key={project.documentId}
href={`/projects/${project.slug}`}
className="rounded-lg overflow-hidden border hover:shadow-lg transition"
>
{project.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${project.coverImage.url}`}
alt={project.coverImage.alternativeText || project.title}
width={800}
height={500}
priority={index < 2}
className="w-full h-56 object-cover"
/>
)}
<div className="p-6">
<h2 className="text-2xl font-semibold">{project.title}</h2>
<p className="text-gray-600 mt-2">{project.description}</p>
<div className="flex flex-wrap gap-2 mt-4">
{project.technologies.split(",").map((tech) => (
<span
key={tech.trim()}
className="bg-gray-100 text-gray-700 px-3 py-1 rounded-full text-sm"
>
{tech.trim()}
</span>
))}
</div>
</div>
</Link>
))}
</div>
</section>
);
}The priority prop on the first two images tells Next.js to preload them, which helps your Largest Contentful Paint score.
For the individual project page, you need the Rich Text renderer. Install Strapi's official package:
npm install @strapi/blocks-react-rendererThis package covers all block types (paragraphs, headings, lists, code, images) and lets you override each with custom components. For a deeper look at the editor itself, see the walkthrough on Strapi's Rich Text editor. Create a reusable RichTextRenderer component:
// frontend/src/components/RichTextRenderer.tsx
import { BlocksRenderer, type BlocksContent } from "@strapi/blocks-react-renderer";
interface RichTextRendererProps {
content: BlocksContent;
className?: string;
}
export default function RichTextRenderer({ content, className }: RichTextRendererProps) {
return (
<div className={`prose prose-lg max-w-none ${className || ""}`}>
<BlocksRenderer
content={content}
blocks={{
paragraph: ({ children }) => (
<p className="my-4 text-gray-800 leading-relaxed">{children}</p>
),
heading: ({ children, level }) => {
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
const sizes: Record<number, string> = {
1: "text-4xl",
2: "text-3xl",
3: "text-2xl",
4: "text-xl",
5: "text-lg",
6: "text-base",
};
return (
<Tag className={`${sizes[level]} font-bold mt-8 mb-4`}>
{children}
</Tag>
);
},
code: ({ children }) => (
<pre className="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto my-6">
<code>{children}</code>
</pre>
),
list: ({ children, format }) => {
const List = format === "ordered" ? "ol" : "ul";
return <List className="list-disc ml-6 my-4">{children}</List>;
},
quote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic my-4">
{children}
</blockquote>
),
link: ({ children, url }) => (
<a href={url} className="text-blue-600 hover:underline">
{children}
</a>
),
image: ({ image }) => (
<img
src={image.url}
alt={image.alternativeText || ""}
width={image.width}
height={image.height}
className="rounded-lg shadow-md my-6"
/>
),
}}
modifiers={{
bold: ({ children }) => <strong className="font-bold">{children}</strong>,
italic: ({ children }) => <em className="italic">{children}</em>,
underline: ({ children }) => <u className="underline">{children}</u>,
strikethrough: ({ children }) => <s className="line-through">{children}</s>,
code: ({ children }) => (
<code className="bg-gray-200 px-1 rounded">{children}</code>
),
}}
/>
</div>
);
}Now build the project detail page that uses this renderer. This is where the Next.js 16 async params change shows up: both generateMetadata and the page component receive params as a promise, and synchronous access no longer works.
// frontend/src/app/projects/[slug]/page.tsx
import { getProjectBySlug, getProjects } from "@/lib/strapi";
import RichTextRenderer from "@/components/RichTextRenderer";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const project = await getProjectBySlug(slug);
if (!project) return { title: "Project Not Found" };
return {
title: project.title,
description: project.description,
openGraph: {
title: project.title,
description: project.description,
images: project.coverImage
? [`${process.env.NEXT_PUBLIC_STRAPI_URL}${project.coverImage.url}`]
: [],
},
};
}
export async function generateStaticParams() {
const { data: projects } = await getProjects();
return projects.map((project) => ({ slug: project.slug }));
}
export default async function ProjectPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const project = await getProjectBySlug(slug);
if (!project) notFound();
return (
<article className="py-16 px-6 max-w-4xl mx-auto">
<Link
href="/projects"
className="text-blue-600 hover:underline mb-6 inline-block"
>
Back to Projects
</Link>
<h1 className="text-4xl font-bold mb-4">{project.title}</h1>
{project.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${project.coverImage.url}`}
alt={project.coverImage.alternativeText || project.title}
width={1200}
height={600}
priority
className="w-full rounded-lg mb-8"
/>
)}
<div className="flex gap-4 mb-8">
{project.liveUrl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Live Demo
</a>
)}
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="border border-gray-300 px-4 py-2 rounded hover:bg-gray-50"
>
Source Code
</a>
)}
</div>
<RichTextRenderer content={project.content} />
<div className="flex flex-wrap gap-2 mt-8">
{project.technologies.split(",").map((tech) => (
<span
key={tech.trim()}
className="bg-gray-100 text-gray-700 px-3 py-1 rounded-full text-sm"
>
{tech.trim()}
</span>
))}
</div>
</article>
);
}generateStaticParams pre-renders each project page at build time, so visitors get static HTML instead of waiting for server rendering. generateMetadata sets per-page SEO fields, including Open Graph images for social sharing. Both matter for a portfolio that needs to look good in search results and link previews; if you want to go further, Strapi's Next.js SEO guide covers structured data and sitemaps.
Blog Listing and Post Pages
The blog listing page displays posts sorted by date in a clean vertical layout:
// frontend/src/app/blog/page.tsx
import { getBlogPosts } from "@/lib/strapi";
import Image from "next/image";
import Link from "next/link";
export const metadata = {
title: "Blog",
description: "Thoughts on web development, design, and technology.",
};
export default async function BlogPage() {
const { data: posts } = await getBlogPosts();
return (
<section className="py-16 px-6 max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-10">Blog</h1>
<div className="space-y-8">
{posts.map((post) => (
<Link
key={post.documentId}
href={`/blog/${post.slug}`}
className="flex flex-col md:flex-row gap-6 group"
>
{post.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${post.coverImage.url}`}
alt={post.coverImage.alternativeText || post.title}
width={300}
height={200}
className="rounded-lg object-cover w-full md:w-48 h-36"
/>
)}
<div>
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition">
{post.title}
</h2>
<p className="text-gray-500 text-sm mt-1">
{new Date(post.publishedDate).toLocaleDateString()}
</p>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
</div>
</Link>
))}
</div>
</section>
);
}The blog detail page follows the same structure as the project detail page, adapted for long-form reading:
// frontend/src/app/blog/[slug]/page.tsx
import { getBlogPostBySlug, getBlogPosts } from "@/lib/strapi";
import RichTextRenderer from "@/components/RichTextRenderer";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getBlogPostBySlug(slug);
if (!post) return { title: "Post Not Found" };
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: post.coverImage
? [`${process.env.NEXT_PUBLIC_STRAPI_URL}${post.coverImage.url}`]
: [],
},
};
}
export async function generateStaticParams() {
const { data: posts } = await getBlogPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getBlogPostBySlug(slug);
if (!post) notFound();
return (
<article className="py-16 px-6 max-w-3xl mx-auto">
<Link href="/blog" className="text-blue-600 hover:underline mb-6 inline-block">
Back to Blog
</Link>
<span className="block text-sm text-blue-600 font-medium mb-2">{post.category}</span>
<h1 className="text-4xl font-bold mb-2">{post.title}</h1>
<p className="text-gray-500 mb-8">
{new Date(post.publishedDate).toLocaleDateString()}
</p>
{post.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${post.coverImage.url}`}
alt={post.coverImage.alternativeText || post.title}
width={1200}
height={600}
priority
className="w-full rounded-lg mb-8"
/>
)}
<RichTextRenderer content={post.content} />
</article>
);
}About Page
The about page fetches from a Strapi Single Type to display dynamic content including skills:
// frontend/src/app/about/page.tsx
import { getAbout } from "@/lib/strapi";
import RichTextRenderer from "@/components/RichTextRenderer";
import Image from "next/image";
export const metadata = {
title: "About",
description: "Learn more about my background, skills, and experience.",
};
export default async function AboutPage() {
const { data: about } = await getAbout();
return (
<section className="py-16 px-6 max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-8">{about.heading}</h1>
<div className="flex flex-col md:flex-row gap-10">
{about.profilePhoto && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${about.profilePhoto.url}`}
alt={about.profilePhoto.alternativeText || "Profile photo"}
width={300}
height={300}
className="rounded-lg object-cover"
priority
/>
)}
<RichTextRenderer content={about.bio} />
</div>
<div className="mt-12">
<h2 className="text-2xl font-bold mb-4">Skills</h2>
<div className="flex flex-wrap gap-3">
{(about.skills as string[]).map((skill) => (
<span
key={skill}
className="bg-blue-100 text-blue-800 px-4 py-2 rounded-full font-medium"
>
{skill}
</span>
))}
</div>
</div>
{about.resumeFile && (
<a
href={`${process.env.NEXT_PUBLIC_STRAPI_URL}${about.resumeFile.url}`}
download
className="inline-block mt-8 bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition font-medium"
>
Download Resume
</a>
)}
</section>
);
}Contact Page with Server Actions
The contact form is the one place you need client-side interactivity. You already created the ContactMessage Collection Type, so the remaining work is a Server Action that validates input with Zod before writing to Strapi. Validating on both sides is worth the duplication: Strapi's own API data validation catches anything that bypasses your frontend.
// frontend/src/app/actions/contact.ts
"use server";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
subject: z.string().min(2, "Subject must be at least 2 characters"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export type ContactFormState = {
errors?: Record<string, string[]>;
message?: string;
success?: boolean;
};
export async function submitContactForm(
prevState: ContactFormState | null,
formData: FormData
): Promise<ContactFormState> {
const raw = Object.fromEntries(formData.entries());
const validated = contactSchema.safeParse(raw);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Please correct the errors below.",
};
}
try {
const response = await fetch(
`${process.env.STRAPI_API_URL}/api/contact-messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
body: JSON.stringify({ data: validated.data }),
}
);
if (!response.ok) throw new Error("Submission failed");
return {
message: "Thanks for reaching out. I'll get back to you soon.",
success: true,
};
} catch {
return {
message: "Something went wrong. Please try again later.",
success: false,
};
}
}The client component uses useActionState to manage form state. Server Actions work without JavaScript enabled, but useActionState is a client-side hook and needs a "use client" boundary to handle validation feedback and state updates:
// frontend/src/components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { submitContactForm, type ContactFormState } from "@/app/actions/contact";
export default function ContactForm() {
const [state, formAction] = useActionState(submitContactForm, null);
if (state?.success) {
return (
<p className="text-green-600 text-lg font-medium">{state.message}</p>
);
}
return (
<form action={formAction} className="space-y-6 max-w-lg">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full border rounded-lg px-4 py-2"
aria-describedby="name-error"
/>
{state?.errors?.name && (
<p id="name-error" className="text-red-600 text-sm mt-1">
{state.errors.name[0]}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded-lg px-4 py-2"
aria-describedby="email-error"
/>
{state?.errors?.email && (
<p id="email-error" className="text-red-600 text-sm mt-1">
{state.errors.email[0]}
</p>
)}
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium mb-1">
Subject
</label>
<input
id="subject"
name="subject"
type="text"
required
className="w-full border rounded-lg px-4 py-2"
aria-describedby="subject-error"
/>
{state?.errors?.subject && (
<p id="subject-error" className="text-red-600 text-sm mt-1">
{state.errors.subject[0]}
</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
rows={5}
required
className="w-full border rounded-lg px-4 py-2"
aria-describedby="message-error"
/>
{state?.errors?.message && (
<p id="message-error" className="text-red-600 text-sm mt-1">
{state.errors.message[0]}
</p>
)}
</div>
{state?.message && !state.success && (
<p className="text-red-600">{state.message}</p>
)}
<button
type="submit"
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition"
>
Get in Touch
</button>
</form>
);
}Every input has a proper <label>, aria-describedby for error messages, and a required attribute. These semantic attributes let screen readers associate labels with inputs and surface error messages correctly, and they enable browser-level validation as a fallback.
The contact page itself is a thin wrapper that imports the form component:
// frontend/src/app/contact/page.tsx
import ContactForm from "@/components/ContactForm";
export const metadata = {
title: "Contact",
description: "Get in touch to discuss projects, opportunities, or collaboration.",
};
export default function ContactPage() {
return (
<section className="py-16 px-6 max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-4">Get in Touch</h1>
<p className="text-gray-600 mb-8">
Have a project in mind or want to discuss an opportunity? Fill out the form below and I'll get back to you within 48 hours.
</p>
<ContactForm />
</section>
);
}Styling and Responsive Design
The code examples throughout this guide follow a consistent styling approach worth calling out. The color palette centers on blue-600 for primary actions and gray tones for text and borders; extend these in your Tailwind theme with custom brand colors if you want a more distinctive look. Typography scales from the Inter font loaded via next/font, with heading sizes ranging from text-base up to text-6xl depending on context.
Every layout uses a mobile-first responsive strategy. Grids start as single-column (grid-cols-1) and expand at the md: breakpoint (768 pixels) to two or three columns. The about page stacks the photo above the bio on mobile (flex-col) and places them side by side on tablet and up (md:flex-row). Navigation, cards, and content sections all follow the same pattern.
Interaction feedback comes from Tailwind's transition utility paired with hover states: hover:shadow-lg on cards, group-hover:text-blue-600 on linked titles, and hover:bg-blue-700 on buttons. These small touches make the interface feel responsive without any custom CSS or animation libraries.
Deploy to Production
Deploy Strapi
Strapi Cloud is the most direct option. Connect your GitHub repository, select a deployment branch, configure environment variables, and Strapi Cloud provides managed PostgreSQL, an integrated CDN for media assets, and automated SSL certificates.
Alternatively, Railway gives you more infrastructure control. It automatically provisions a PostgreSQL database and gives you a public URL for your Strapi instance. If neither fits your constraints, the comparison of Strapi deployment options covers the trade-offs across self-hosted and managed setups.
Either way, configure CORS on your deployed Strapi instance by updating config/middlewares.js to accept requests from your Vercel domain:
// backend/config/middlewares.js
module.exports = [
{
name: "strapi::cors",
config: {
enabled: true,
origin: [
"http://localhost:3000",
"https://your-nextjs-domain.vercel.app",
],
headers: ["Content-Type", "Authorization", "Origin", "Accept"],
credentials: true,
},
},
];Deploy Next.js to Vercel
Push your frontend to GitHub and connect the repository to Vercel. Add your environment variables (STRAPI_API_URL, STRAPI_API_TOKEN, NEXT_PUBLIC_STRAPI_URL) pointing to your deployed Strapi instance, and remember to update remotePatterns in next.config.ts to your production Strapi hostname.
For content updates without full rebuilds, set up a revalidation route. This is the second place Next.js 16 differs from earlier versions: revalidateTag now takes a cacheLife profile as its second argument, and the single-argument form is deprecated.
// frontend/src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
const MODEL_TO_TAG: Record<string, string> = {
project: "projects",
"blog-post": "blog-posts",
about: "about",
global: "global",
};
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-webhook-secret");
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const tag = MODEL_TO_TAG[body.model];
if (!tag) {
return Response.json({ revalidated: false, reason: "Unknown model" });
}
revalidateTag(tag, "max");
return Response.json({ revalidated: true, tag });
}The tag names match the ones you passed to fetchFromStrapi earlier, which is what connects a publish event in Strapi to a specific cache entry in Next.js. Configure the webhook in Strapi's Admin Panel under Settings → Webhooks → Create new webhook, pointing to https://your-domain.com/api/revalidate with a matching secret header and the publish and update events selected. If you'd rather let pages refresh on a timer than wire up webhooks, the walkthrough of incremental static regeneration covers when that trade-off makes sense.
Troubleshooting Common Issues
If images don't load, verify that your remotePatterns entries match your Strapi URL exactly, including protocol and port. In local development, also confirm dangerouslyAllowLocalIP is set, since Next.js 16 blocks local IP optimization by default.
If API calls return 403 errors, check that the Public role permissions in Strapi include find and findOne for your content types. The 403 error is the one that trips up most people — it's almost always a permissions issue rather than a token problem.
If Rich Text content renders as raw JSON, confirm you installed @strapi/blocks-react-renderer and that the content passes through BlocksRenderer. And if pages feel slower than expected, the rundown of common performance mistakes in Strapi and Next.js apps is a good place to start.
Frequently Asked Questions
Do I need Strapi Cloud, or can I self-host?
Both work. Strapi is open source, so you can self-host on any Node.js environment with a SQL database. Strapi Cloud handles the database, CDN, and SSL for you, which is usually the faster path for a portfolio you don't want to maintain.
Which database should I use in production?
PostgreSQL is the common choice, and both Strapi Cloud and Railway provision it automatically. SQLite is fine for local development but not for production, since most hosting platforms use ephemeral filesystems.
Why is my content not updating after I publish?
Check three things: the webhook fired in Strapi's Admin Panel logs, the secret header matches, and the model name in your MODEL_TO_TAG map matches the one Strapi sends. A mismatched tag name is the most common cause.
Can I preview draft content before publishing?
Yes. Strapi 5 ships a Preview feature that works with Next.js Draft Mode, so you can view unpublished entries on your live frontend without exposing them to visitors.
What to Build Next
You have a solid foundation. Here are practical next steps to consider:
- Dark mode toggle using Tailwind's
dark:variant and a client-side theme switcher. - Draft previews using Next.js Draft Mode with Strapi's Preview feature for unpublished content.
- Search functionality across projects and blog posts using client-side filtering or Strapi's filtering API.
- RSS feed for your blog using a
route.tshandler that generates XML from your blog posts. - Analytics integration with Vercel Speed Insights to monitor real-user Core Web Vitals in production.
If you'd rather start from something already wired up, the Strapi Launchpad demo is a production-grade Next.js and Strapi 5 project you can clone and adapt, and Strapi Cloud will host the backend in a few minutes. Push your code to GitHub, deploy it, and start shipping content.






