Building a Modern Portfolio with Headless WordPress and Next.js
Process Dec 19, 2025 7 min read

Building a Modern Portfolio with Headless WordPress and Next.js

How I built a performant, SEO-friendly portfolio using WordPress as a headless CMS, Next.js for the frontend, and deployed it across Cloudways and Vercel.

Lushano Perera
Lushano Perera
Author

The Architecture Decision

When I set out to rebuild my portfolio, I had clear goals: I wanted the content management flexibility of WordPress, the performance benefits of static generation, and a modern developer experience. The answer was a headless architecture:

Why Headless WordPress?

WordPress powers over 40% of the web for good reason: its content management is unmatched. But traditional WordPress comes with performance baggage. By going headless:

  • WordPress handles what it does best: content editing, media management, custom fields via ACF
  • Next.js handles what it does best: rendering, performance optimization, static generation
  • Best of both worlds: familiar CMS + modern frontend

The WordPress Backend

Hosting on Cloudways

I chose Cloudways for the WordPress backend. Their managed cloud hosting provides:

  • One-click WordPress installation with optimized stack (PHP 8.2, Redis, Varnish)
  • Automatic backups and easy staging environments
  • SSH access for advanced deployments
  • Free SSL via Let’s Encrypt

The server runs on DigitalOcean under the hood, giving excellent performance for API responses (typically <100ms for REST endpoints).

Custom Plugin Architecture

Rather than relying on the theme for functionality, I built a dedicated plugin (lushanoperera-core) that encapsulates all backend logic:

<?php
/**
* Plugin Name: Lushano Perera Core
* Description: Core functionality for headless WordPress
*/

declare(strict_types=1);

namespace LushanoPerera\Core;

// Initialize components
function init(): void {
CPT::init(); // Custom Post Types
ACF::init(); // Advanced Custom Fields
REST_API::init(); // REST API enhancements
Image_Sizes::init(); // Custom image sizes
Revalidation::init(); // Next.js cache revalidation
}
add_action('plugins_loaded', __NAMESPACE__ . '\\init');

This approach keeps the codebase clean and makes deployments simpler—I can use a minimal theme (Twenty Twenty-Five) since all rendering happens on the frontend.

Portfolio Custom Post Type

The portfolio is powered by a custom post type with ACF fields:

<?php
register_post_type('portfolio', [
'labels' => [
'name' => 'Portfolio',
'singular_name' => 'Project',
],
'public' => true,
'show_in_rest' => true, // Essential for headless!
'rest_base' => 'portfolio',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'rewrite' => ['slug' => 'works'],
'has_archive' => 'archive',
]);

ACF fields store project metadata:

FieldTypePurpose
project_yearTextProject year
project_clientTextClient name
project_roleTextMy role on the project
project_technologiesCheckboxTech stack (React, Next.js, etc.)
project_galleryGalleryCase study images
project_featuredTrue/FalseShow on homepage

The key is setting show_in_rest: true for both the field group and individual fields to expose them via the REST API.


The Next.js Frontend

Project Structure

The frontend uses Next.js 15 with the App Router:

frontend/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout with fonts
│ │ ├── page.tsx # Homepage
│ │ ├── works/
│ │ │ ├── page.tsx # Portfolio grid
│ │ │ └── [slug]/page.tsx # Project detail
│ │ └── journal/
│ │ ├── page.tsx # Blog archive
│ │ └── [slug]/page.tsx # Article detail
│ ├── components/ # React components
│ ├── lib/
│ │ └── wordpress.ts # API client
│ └── types/
│ └── index.ts # TypeScript definitions

WordPress API Client

The API client fetches data from WordPress and transforms it into typed objects:

// lib/wordpress.ts
const API_URL = process.env.WORDPRESS_API_URL || "https://api.lushanoperera.com/wp-json";

export interface Project {
id: string;
slug: string;
title: string;
year: string;
client?: string;
tags?: string[];
image: string;
content?: string;
featured?: boolean;
}

// Fetch portfolio items with Next.js cache tags
export async function getPortfolio(limit = 10): Promise<Project[]> {
const res = await fetch(
`${API_URL}/wp/v2/portfolio?_embed&per_page=${limit}&orderby=date&order=desc`,
{ next: { tags: ["portfolio"] } } // Cache tag for revalidation
);

if (!res.ok) throw new Error("Failed to fetch portfolio");

const posts = await res.json();
return posts.map(transformPortfolio);
}

// Transform WordPress response to clean interface
function transformPortfolio(post: WPPortfolio): Project {
const featuredMedia = post._embedded?.["wp:featuredmedia"]?.[0];

return {
id: post.id.toString(),
slug: post.slug,
title: stripHtml(post.title.rendered),
year: post.acf?.project_year || new Date(post.date).getFullYear().toString(),
client: post.acf?.project_client || "",
tags: post.acf?.project_technologies || [],
image: featuredMedia?.source_url || "",
content: post.content.rendered,
featured: post.acf?.project_featured || false,
};
}

The _embed parameter is crucial—it includes featured images and taxonomies in a single request, avoiding N+1 queries.

Server Components and Data Fetching

Next.js App Router makes data fetching elegant with Server Components:

// app/works/[slug]/page.tsx
import { getPortfolioBySlug } from "@/lib/wordpress";
import ProjectDetail from "@/components/ProjectDetail";

export default async function ProjectPage({ params }: { params: { slug: string } }) {
const project = await getPortfolioBySlug(params.slug);

if (!project) {
notFound();
}

return <ProjectDetail project={project} />;
}

// Generate static params for all projects
export async function generateStaticParams() {
const projects = await getPortfolio(100);
return projects.map((project) => ({
slug: project.slug,
}));
}

This generates static pages at build time while still fetching fresh data on revalidation.


On-Demand Revalidation

The magic sauce for keeping the static site in sync with WordPress changes is on-demand revalidation.

How It Works

  1. User updates content in WordPress
  2. WordPress hook triggers → sends POST to Next.js API
  3. Next.js invalidates relevant cache tags
  4. Next request regenerates the page with fresh data

WordPress Revalidation Class

<?php
// includes/class-revalidation.php

class Revalidation {
private const FRONTEND_URL = 'https://lushanoperera.com';
private const REVALIDATION_SECRET = 'your-secret-token';

public static function init(): void {
add_action('save_post', [__CLASS__, 'on_post_save'], 10, 3);
add_action('before_delete_post', [__CLASS__, 'on_post_delete'], 10, 2);
add_action('wp_trash_post', [__CLASS__, 'on_post_trash']);
}

public static function on_post_save(int $post_id, \WP_Post $post, bool $update): void {
// Skip autosaves and drafts
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if ('publish' !== $post->post_status) return;

self::revalidate_post($post);
}

private static function revalidate_post(\WP_Post $post): bool {
$payload = [
'secret' => self::REVALIDATION_SECRET,
'post_type' => $post->post_type,
'slug' => $post->post_name,
];

return self::send_revalidation_request($payload);
}

private static function send_revalidation_request(array $payload): bool {
$response = wp_remote_post(
self::FRONTEND_URL . '/api/revalidate',
[
'timeout' => 10,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode($payload),
]
);

return !is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200;
}
}

Next.js Revalidation Endpoint

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

const REVALIDATION_SECRET = process.env.REVALIDATION_SECRET;

export async function POST(request: NextRequest) {
const { secret, tag, slug, post_type } = await request.json();

// Validate secret
if (secret !== REVALIDATION_SECRET) {
return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
}

// Determine cache tags to invalidate
const tagsToRevalidate: string[] = [];

if (tag === "all") {
tagsToRevalidate.push("posts", "portfolio", "pages");
} else if (post_type) {
const typeTagMap: Record<string, string> = {
post: "posts",
portfolio: "portfolio",
page: "pages",
};
const cacheTag = typeTagMap[post_type];
if (cacheTag) tagsToRevalidate.push(cacheTag);
}

// Add slug-specific tag
if (slug && post_type) {
tagsToRevalidate.push(`${post_type}-${slug}`);
}

// Invalidate all tags
for (const t of tagsToRevalidate) {
revalidateTag(t, "max"); // Next.js 16 requires profile argument
}

return NextResponse.json({
revalidated: true,
tags: tagsToRevalidate,
timestamp: Date.now(),
});
}

Now when I update a portfolio project in WordPress, the change appears on the live site within seconds—without rebuilding the entire site.


Deployment Pipeline

Vercel for the Frontend

Deploying Next.js to Vercel is straightforward:

# Link to Vercel project
vercel link --project lushanoperera-com --yes

# Deploy to production
vercel --prod

vercel.json ensures proper framework detection:

{
"framework": "nextjs"
}

Vercel provides:

  • Automatic HTTPS and CDN distribution
  • Preview deployments for every PR
  • Analytics and monitoring built-in
  • Edge functions for API routes

WordPress Deployment to Cloudways

For the WordPress backend on Cloudways, I use rsync for theme and plugin deployments:

# Deploy theme
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.git' \
./wp-content/themes/lushanoperera/ \
user@server:/path/to/wp-content/themes/lushanoperera/

# Deploy custom plugin
rsync -avz --delete \
./wp-content/plugins/lushanoperera-core/ \
user@server:/path/to/wp-content/plugins/lushanoperera-core/

# Flush caches
ssh user@server "cd /path/to/wordpress && wp cache flush && wp rewrite flush"

Performance Results

The headless architecture delivers impressive performance:

MetricScore
Lighthouse Performance98
First Contentful Paint0.6s
Largest Contentful Paint1.2s
Time to Interactive0.9s
Core Web VitalsAll Green

Static generation means pages are served from CDN edge nodes worldwide, with WordPress only handling content management—not page rendering.


Key Takeaways

  1. Separate concerns: Let WordPress handle content, Next.js handle rendering
  2. Use cache tags: Granular revalidation is better than full site rebuilds
  3. Type everything: TypeScript interfaces catch API changes early
  4. Secure the API: Use secrets for revalidation, CORS for REST endpoints
  5. Keep it simple: A minimal WordPress theme works fine when it’s headless

If you’re building something similar and need reliable WordPress hosting, Cloudways has been rock solid for my use case.


Built with Next.js 15, WordPress 6.5+, and deployed across Vercel and Cloudways.

Written by Lushano Perera

Digital craftsman exploring the intersection of design, technology, and human experience.