Having a personal portfolio website is essential these days for showcasing your skills and building a professional online presence. But building one that’s both visually stunning and highly functional can be challenging. That’s where Strapi and Next.js come in. Together, they offer a powerful, flexible solution for creating a portfolio site that not only highlights your work but also provides a seamless, fast, and customizable user experience.
With Strapi as your headless CMS, you gain complete control over your content, making it easy to manage and update your projects, blog posts, and other portfolio items. On the front-end, Next.js brings speed, SEO benefits, and dynamic capabilities, ensuring your site performs well and looks great on any device.
In this guide, we’ll walk you through how to build a portfolio site with Strapi and Next.js that’s not just another template, but a unique digital showcase that reflects your personal brand and creativity. Let’s dive in!
In brief:
Your portfolio backend starts with Strapi as your content hub. Let's create a robust backend system with step-by-step instructions and code examples.
First, let's create a new Strapi project:
1npx create-strapi@latest portfolio-backend --quickstart
The --quickstart
flag sets up SQLite as your database for rapid development. Once installation completes, Strapi automatically launches and prompts you to create an admin user.
From the Strapi admin panel, we'll create custom content types for your portfolio projects:
1Field: title (Text - Short text)
2Field: description (Text - Long text)
3Field: slug (UID - linked to title)
4Field: content (Rich text)
5Field: featured_image (Media - Single media)
6Field: technologies (Text - Short text) with "Multiple" enabled
7Field: project_url (Text - Short text)
8Field: github_url (Text - Short text)
9Field: published_date (Date - Date)
10Field: featured (Boolean)
Click "Save" to create your Project content type. Similarly, create a "Blog Post" content type:
1Field: title (Text - Short text)
2Field: slug (UID - linked to title)
3Field: content (Rich text)
4Field: excerpt (Text - Long text)
5Field: cover_image (Media - Single media)
6Field: tags (Text - Short text) with "Multiple" enabled
7Field: published_date (Date - Date)
Secure your API by setting proper permissions:
For programmatic access, let's create an API token:
In Strapi admin panel: Settings → API Tokens → Create new API token. Name: "Portfolio Frontend". Token type: "Read-only". Token duration: "Unlimited". Save and copy the generated token for use in your Next.js application.
Create a few portfolio projects and blog posts using the Strapi admin interface. Here's an example project structure as JSON that you could import:
1{
2 "title": "E-commerce Platform",
3 "description": "A full-stack e-commerce solution with payment processing",
4 "slug": "e-commerce-platform",
5 "content": "# E-commerce Platform\n\nThis project features user authentication, product management, cart functionality, and Stripe payment integration.",
6 "technologies": ["React", "Node.js", "MongoDB", "Stripe"],
7 "project_url": "https://myecommerce.example.com",
8 "github_url": "https://github.com/yourusername/ecommerce",
9 "published_date": "2023-01-15",
10 "featured": true
11}
Create a new file at ./src/api/project/content-types/project/schema.json
and add a lifecycle hook. In the ./src/api/project/controllers/project.js
file, use the following code to optimize Strapi responses:
1const { createCoreController } = require('@strapi/strapi').factories;
2
3module.exports = createCoreController('api::project.project', ({ strapi }) => ({
4 async find(ctx) {
5 const { data, meta } = await super.find(ctx);
6 const optimizedData = data.map(item => ({
7 ...item,
8 id: item.id,
9 featured_image: item.featured_image?.data
10 ? {
11 url: item.featured_image.data.url,
12 alt: item.title,
13 }
14 : null,
15 }));
16 const sanitizedResults = await this.sanitizeOutput(optimizedData, ctx);
17 return this.transformResponse(sanitizedResults, { pagination: meta.pagination });
18 },
19}));
This customization simplifies consuming the API in your Next.js frontend by flattening the response structure.
Now that your backend is ready, let's build a powerful Next.js frontend to showcase your portfolio with code examples at each step.
Create a new Next.js application with TypeScript support:
1npx create-next-app@latest portfolio-frontend --typescript
2cd portfolio-frontend
Install essential dependencies:
1npm install axios swr sass
Create a .env.local
file in your project root:
1NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
2STRAPI_API_TOKEN=your-api-token-from-strapi
Next, create a configuration file for API connections:
1// lib/api.ts
2export const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
3export const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
4
5export const fetchAPI = async (path: string) => {
6 const requestUrl = `${STRAPI_URL}/api${path}`;
7 const response = await fetch(requestUrl, {
8 headers: {
9 'Content-Type': 'application/json',
10 Authorization: `Bearer ${STRAPI_API_TOKEN}`
11 }
12 });
13
14 const data = await response.json();
15 return data;
16};
Define TypeScript interfaces for your content:
1// types/project.ts
2export interface Project {
3 id: number;
4 title: string;
5 description: string;
6 slug: string;
7 content: string;
8 featured_image: {
9 url: string;
10 alt: string;
11 };
12 technologies: string[];
13 project_url: string;
14 github_url: string;
15 published_date: string;
16 featured: boolean;
17}
18
19// types/blog-post.ts
20export interface BlogPost {
21 id: number;
22 title: string;
23 slug: string;
24 content: string;
25 excerpt: string;
26 cover_image: {
27 url: string;
28 alt: string;
29 };
30 tags: string[];
31 published_date: string;
32}
Build functions to fetch data from your Strapi backend:
1// lib/projects.ts
2import { fetchAPI } from './api';
3import { Project } from '../types/project';
4
5export async function getAllProjects(): Promise<Project[]> {
6 const data = await fetchAPI('/projects?populate=featured_image');
7 return data.data;
8}
9
10export async function getFeaturedProjects(): Promise<Project[]> {
11 const data = await fetchAPI('/projects?filters[featured]=true&populate=featured_image');
12 return data.data;
13}
14
15export async function getProjectBySlug(slug: string): Promise<Project> {
16 const data = await fetchAPI(`/projects?filters[slug][$eq]=${slug}&populate=featured_image`);
17 return data.data[0];
18}
Let's create a ProjectCard component:
1// components/ProjectCard.tsx
2import Image from 'next/image';
3import Link from 'next/link';
4import { Project } from '../types/project';
5import styles from '../styles/ProjectCard.module.scss';
6
7interface ProjectCardProps {
8 project: Project;
9}
10
11export default function ProjectCard({ project }: ProjectCardProps) {
12 return (
13 <div className={styles.card}>
14 <div className={styles.imageContainer}>
15 {project.featured_image && (
16 <Image
17 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${project.featured_image.url}`}
18 alt={project.featured_image.alt || project.title}
19 layout="fill"
20 objectFit="cover"
21 />
22 )}
23 </div>
24 <div className={styles.content}>
25 <h3>{project.title}</h3>
26 <p>{project.description}</p>
27 <div className={styles.technologies}>
28 {project.technologies.map(tech => (
29 <span key={tech} className={styles.tech}>{tech}</span>
30 ))}
31 </div>
32 <div className={styles.links}>
33 <Link href={`/projects/${project.slug}`}>
34 <a className={styles.detailsLink}>View Details</a>
35 </Link>
36 {project.github_url && (
37 <a href={project.github_url} target="_blank" rel="noopener noreferrer" className={styles.externalLink}>
38 GitHub
39 </a>
40 )}
41 {project.project_url && (
42 <a href={project.project_url} target="_blank" rel="noopener noreferrer" className={styles.externalLink}>
43 Live Demo
44 </a>
45 )}
46 </div>
47 </div>
48 </div>
49 );
50}
Create a dynamic page for individual projects:
1// pages/projects/[slug].tsx
2import { GetStaticProps, GetStaticPaths } from 'next';
3import Image from 'next/image';
4import Head from 'next/head';
5import { getAllProjects, getProjectBySlug } from '../../lib/projects';
6import { Project } from '../../types/project';
7import ReactMarkdown from 'react-markdown';
8import styles from '../../styles/ProjectDetail.module.scss';
9
10interface ProjectDetailProps {
11 project: Project;
12}
13
14export default function ProjectDetail({ project }: ProjectDetailProps) {
15 if (!project) return <div>Loading...</div>;
16
17 return (
18 <>
19 <Head>
20 <title>{project.title} | My Portfolio</title>
21 <meta name="description" content={project.description} />
22 </Head>
23
24 <article className={styles.projectDetail}>
25 <header>
26 <h1>{project.title}</h1>
27 {project.featured_image && (
28 <div className={styles.featuredImage}>
29 <Image
30 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${project.featured_image.url}`}
31 alt={project.title}
32 width={1200}
33 height={630}
34 layout="responsive"
35 />
36 </div>
37 )}
38 </header>
39
40 <div className={styles.metadata}>
41 <div className={styles.technologies}>
42 {project.technologies.map(tech => (
43 <span key={tech} className={styles.tech}>{tech}</span>
44 ))}
45 </div>
46 <div className={styles.links}>
47 {project.github_url && (
48 <a href={project.github_url} target="_blank" rel="noopener noreferrer" className={styles.link}>
49 GitHub Repository
50 </a>
51 )}
52 {project.project_url && (
53 <a href={project.project_url} target="_blank" rel="noopener noreferrer" className={styles.link}>
54 Live Demo
55 </a>
56 )}
57 </div>
58 </div>
59
60 <div className={styles.content}>
61 <ReactMarkdown>{project.content}</ReactMarkdown>
62 </div>
63 </article>
64 </>
65 );
66}
67
68export const getStaticPaths: GetStaticPaths = async () => {
69 const projects = await getAllProjects();
70
71 return {
72 paths: projects.map(project => ({
73 params: { slug: project.slug }
74 })),
75 fallback: 'blocking'
76 };
77};
78
79export const getStaticProps: GetStaticProps = async ({ params }) => {
80 const slug = params?.slug as string;
81 const project = await getProjectBySlug(slug);
82
83 if (!project) {
84 return { notFound: true };
85 }
86
87 return {
88 props: { project },
89 revalidate: 60 // Revalidate page every 60 seconds for fresh content
90 };
91};
Build a page that displays all your projects:
1// pages/projects/index.tsx
2import { GetStaticProps } from 'next';
3import Head from 'next/head';
4import { getAllProjects } from '../../lib/projects';
5import { Project } from '../../types/project';
6import ProjectCard from '../../components/ProjectCard';
7import styles from '../../styles/Projects.module.scss';
8
9interface ProjectsPageProps {
10 projects: Project[];
11}
12
13export default function ProjectsPage({ projects }: ProjectsPageProps) {
14 return (
15 <>
16 <Head>
17 <title>My Projects | Portfolio</title>
18 <meta name="description" content="Explore my latest projects and work" />
19 </Head>
20
21 <section className={styles.projectsSection}>
22 <h1>My Projects</h1>
23 <p className={styles.intro}>
24 Browse through my recent work and personal projects. Each project includes details about the technologies used and links to [live demo](https://strapi.io/demo)s or repositories.
25 </p>
26
27 <div className={styles.projectsGrid}>
28 {projects.map(project => (
29 <ProjectCard key={project.id} project={project} />
30 ))}
31 </div>
32 </section>
33 </>
34 );
35}
36
37export const getStaticProps: GetStaticProps = async () => {
38 const projects = await getAllProjects();
39
40 return {
41 props: { projects },
42 revalidate: 60 // Revalidate page every 60 seconds
43 };
44};
Next.js's Incremental Static Regeneration (ISR) allows you to update static content after you've built your site. This is already implemented in the examples above with the revalidate
property in getStaticProps
.
For example, when you add a new project in Strapi, your Next.js site will automatically update the projects page after the revalidation period (60 seconds in our examples), without requiring a full rebuild.
By following these steps and implementing these code examples, you'll have a fully functional, high-performance portfolio site that showcases your work beautifully while maintaining excellent SEO and user experience.
You've built something better than just another developer site learning how to build a portfolio site with Strapi and Next.js. Your custom solution combines flexible content management with performance-focused rendering to create a platform that showcases your work professionally while staying completely under your control.
Your site now includes dynamic project showcases, blog management, global settings for consistent branding, and secure contact functionality. Unlike template-based options, you control every aspect of the data structure, API endpoints, and frontend presentation. The headless architecture you've implemented ensures your content can expand to multiple channels as your career grows.
Security comes built-in through role-based permissions and API tokens, while Next.js's static generation delivers fast load times and excellent SEO.
Want to take it further? Enable internationalization using i18n features for multiple languages, use preview mode to check draft content before publishing, or add the Discussion plugin to manage blog comments. Next.js 14's App Router and Server Actions enhance frontend performance. The App Router supports structured routing with shared layouts and nested routing, while Server Actions handle server-side tasks like data fetching and mutation, reducing client load and improving delivery speed.
our portfolio represents your technical skills—now you have the tools to maintain and expand it exactly as your career demands.
Ready to take your portfolio to the next level? Build with Strapi v5 for enhanced performance and flexibility, or use Strapi Cloud to manage everything effortlessly, ensuring your site grows with your career while staying secure and scalable.