Building a Blazing-Fast Blog with Headless WordPress and Next.js
Combining WordPress's powerful content management capabilities with Next.js's modern React framework creates an unbeatable combination for building performant, scalable blogs. This comprehensive tutorial walks you through building a complete blog from scratch, implementing best practices for performance, SEO, and user experience.
By the end of this guide, you'll have a production-ready blog that loads instantly, ranks well in search engines, and provides an exceptional editing experience for content creators.
Table of Contents
- Project Setup and Structure
- WordPress Configuration
- Next.js App Creation
- Data Fetching with SWR/React Query
- Static Generation Setup
- Dynamic Routing
- Deployment Guide
- Performance Optimization
Project Setup and Structure
Project Architecture Overview
Our headless blog architecture separates concerns for maximum flexibility:
headless-blog/
├── wordpress/ # WordPress backend (subdomain or separate hosting)
│ ├── wp-content/
│ │ ├── plugins/ # WPGraphQL, ACF, etc.
│ │ └── themes/ # Minimal theme for admin
│ └── wp-config.php
│
└── nextjs-frontend/ # Next.js application
├── src/
│ ├── app/ # App router files
│ ├── components/
│ ├── lib/ # Utilities and API clients
│ ├── hooks/ # Custom React hooks
│ └── types/ # TypeScript definitions
├── public/
└── package.json
Prerequisites and Initial Setup
# Required tools
node --version # 18.17 or higher
npm --version # 9.0 or higher
# Create Next.js project with TypeScript and Tailwind
npx create-next-app@latest nextjs-blog --typescript --tailwind --app --src-dir
# Navigate to project
cd nextjs-blog
# Install additional dependencies
npm install @apollo/client graphql
npm install swr graphql-request
npm install reading-time gray-matter
npm install next-seo
npm install --save-dev @types/node
Environment Configuration
Create environment files for different stages:
# .env.local (development)
WORDPRESS_API_URL=http://localhost:8080
WORDPRESS_GRAPHQL_URL=http://localhost:8080/graphql
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# .env.production
WORDPRESS_API_URL=https://api.yourblog.com
WORDPRESS_GRAPHQL_URL=https://api.yourblog.com/graphql
NEXT_PUBLIC_SITE_URL=https://yourblog.com
# .env.example (commit this)
WORDPRESS_API_URL=
WORDPRESS_GRAPHQL_URL=
NEXT_PUBLIC_SITE_URL=
TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/types/*": ["./src/types/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
WordPress Configuration
Essential Plugins Installation
# Using WP-CLI
wp plugin install --activate \
wp-graphql \
wp-graphql-acf \
wp-gatsby \
wordpress-seo \
custom-post-type-ui \
advanced-custom-fields
WPGraphQL Configuration
// functions.php - Extend GraphQL schema
add_action('graphql_register_types', function() {
// Add custom fields to Post type
register_graphql_field('Post', 'readingTime', [
'type' => 'String',
'description' => 'Estimated reading time',
'resolve' => function($post) {
$content = get_post_field('post_content', $post->ID);
$word_count = str_word_count(strip_tags($content));
$reading_time = ceil($word_count / 200); // 200 words per minute
return $reading_time . ' min read';
}
]);
// Add view count
register_graphql_field('Post', 'viewCount', [
'type' => 'Int',
'description' => 'Number of views',
'resolve' => function($post) {
return (int) get_post_meta($post->ID, 'view_count', true) ?: 0;
}
]);
// Add related posts
register_graphql_field('Post', 'relatedPosts', [
'type' => ['list_of' => 'Post'],
'description' => 'Related posts based on categories',
'args' => [
'limit' => [
'type' => 'Int',
'defaultValue' => 3
]
],
'resolve' => function($post, $args) {
$categories = wp_get_post_categories($post->ID);
$related = get_posts([
'category__in' => $categories,
'numberposts' => $args['limit'],
'post__not_in' => [$post->ID],
'orderby' => 'rand'
]);
return $related;
}
]);
});
// Enable preview support
add_action('init', function() {
add_filter('preview_post_link', function($link, $post) {
$frontend_url = 'https://yourblog.com';
return add_query_arg([
'secret' => 'your-preview-secret',
'id' => $post->ID,
'slug' => $post->post_name,
'status' => $post->post_status
], $frontend_url . '/api/preview');
}, 10, 2);
});
// CORS configuration for development
add_action('init', function() {
header('Access-Control-Allow-Origin: http://localhost:3000');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit(0);
}
});
Custom Post Types and Taxonomies
// Create 'Tutorial' post type for technical content
function create_tutorial_post_type() {
register_post_type('tutorial', [
'labels' => [
'name' => 'Tutorials',
'singular_name' => 'Tutorial',
'add_new' => 'Add New Tutorial',
'edit_item' => 'Edit Tutorial',
'view_item' => 'View Tutorial',
],
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'tutorial',
'graphql_plural_name' => 'tutorials',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'menu_icon' => 'dashicons-welcome-learn-more',
]);
// Tutorial difficulty taxonomy
register_taxonomy('difficulty', 'tutorial', [
'labels' => [
'name' => 'Difficulty Levels',
'singular_name' => 'Difficulty',
],
'hierarchical' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'difficulty',
'graphql_plural_name' => 'difficulties',
]);
}
add_action('init', 'create_tutorial_post_type');
Next.js App Creation
GraphQL Client Setup
// src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const httpLink = createHttpLink({
uri: process.env.WORDPRESS_GRAPHQL_URL,
});
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: process.env.WORDPRESS_AUTH_TOKEN
? `Bearer ${process.env.WORDPRESS_AUTH_TOKEN}`
: '',
},
};
});
export const apolloClient = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
},
});
// Alternative: Using graphql-request for simpler needs
import { GraphQLClient } from 'graphql-request';
export const graphqlClient = new GraphQLClient(process.env.WORDPRESS_GRAPHQL_URL!, {
headers: {
authorization: process.env.WORDPRESS_AUTH_TOKEN
? `Bearer ${process.env.WORDPRESS_AUTH_TOKEN}`
: '',
},
});
Type Definitions
// src/types/wordpress.ts
export interface Post {
id: string;
databaseId: number;
title: string;
slug: string;
content: string;
excerpt: string;
date: string;
modified: string;
readingTime: string;
viewCount: number;
featuredImage?: {
node: {
sourceUrl: string;
altText: string;
mediaDetails: {
width: number;
height: number;
};
};
};
author: {
node: {
name: string;
description: string;
avatar: {
url: string;
};
};
};
categories: {
nodes: Array<{
id: string;
name: string;
slug: string;
}>;
};
tags: {
nodes: Array<{
id: string;
name: string;
slug: string;
}>;
};
seo: {
title: string;
metaDesc: string;
canonical: string;
opengraphTitle: string;
opengraphDescription: string;
opengraphImage?: {
sourceUrl: string;
};
};
}
export interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string;
endCursor: string;
}
export interface PostEdge {
cursor: string;
node: Post;
}
export interface PostsResponse {
posts: {
edges: PostEdge[];
pageInfo: PageInfo;
};
}
GraphQL Queries
// src/lib/queries.ts
import { gql } from '@apollo/client';
export const POST_FIELDS = gql`
fragment PostFields on Post {
id
databaseId
title
slug
excerpt
date
modified
readingTime
viewCount
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
author {
node {
name
description
avatar {
url
}
}
}
categories {
nodes {
id
name
slug
}
}
}
`;
export const GET_ALL_POSTS = gql`
${POST_FIELDS}
query GetAllPosts($first: Int!, $after: String) {
posts(first: $first, after: $after, where: { status: PUBLISH, orderby: { field: DATE, order: DESC } }) {
edges {
cursor
node {
...PostFields
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
export const GET_POST_BY_SLUG = gql`
query GetPostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
id
databaseId
title
content
slug
date
modified
readingTime
viewCount
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
author {
node {
name
description
avatar {
url
}
}
}
categories {
nodes {
id
name
slug
}
}
tags {
nodes {
id
name
slug
}
}
seo {
title
metaDesc
canonical
opengraphTitle
opengraphDescription
opengraphImage {
sourceUrl
}
}
relatedPosts {
id
title
slug
featuredImage {
node {
sourceUrl
}
}
}
}
}
`;
export const GET_POSTS_BY_CATEGORY = gql`
${POST_FIELDS}
query GetPostsByCategory($slug: String!, $first: Int!, $after: String) {
posts(
first: $first
after: $after
where: {
status: PUBLISH,
categoryName: $slug,
orderby: { field: DATE, order: DESC }
}
) {
edges {
cursor
node {
...PostFields
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
Data Fetching with SWR/React Query
SWR Implementation
// src/hooks/usePosts.ts
import useSWR from 'swr';
import { graphqlClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/queries';
import { PostsResponse } from '@/types/wordpress';
const fetcher = (query: string, variables?: any) =>
graphqlClient.request(query, variables);
export function usePosts(pageSize = 10) {
const { data, error, isLoading, mutate } = useSWR<PostsResponse>(
['posts', pageSize],
() => fetcher(GET_ALL_POSTS, { first: pageSize }),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
return {
posts: data?.posts.edges.map(edge => edge.node) || [],
pageInfo: data?.posts.pageInfo,
isLoading,
isError: error,
mutate,
};
}
// Infinite scrolling hook
import useSWRInfinite from 'swr/infinite';
export function useInfinitePosts(pageSize = 10) {
const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
if (previousPageData && !previousPageData.posts.pageInfo.hasNextPage) {
return null;
}
if (pageIndex === 0) {
return [GET_ALL_POSTS, { first: pageSize }];
}
return [
GET_ALL_POSTS,
{
first: pageSize,
after: previousPageData?.posts.pageInfo.endCursor,
},
];
};
const { data, error, size, setSize, isValidating } = useSWRInfinite<PostsResponse>(
getKey,
([query, variables]) => fetcher(query, variables),
{
revalidateFirstPage: false,
}
);
const posts = data?.flatMap(page => page.posts.edges.map(edge => edge.node)) || [];
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const isEmpty = data?.[0]?.posts.edges.length === 0;
const isReachingEnd =
isEmpty || (data && !data[data.length - 1]?.posts.pageInfo.hasNextPage);
return {
posts,
error,
isLoadingMore,
size,
setSize,
isReachingEnd,
isValidating,
};
}
React Query Alternative
// src/hooks/usePostsQuery.ts
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { apolloClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS, GET_POST_BY_SLUG } from '@/lib/queries';
export function usePostsQuery(pageSize = 10) {
return useQuery({
queryKey: ['posts', pageSize],
queryFn: async () => {
const { data } = await apolloClient.query({
query: GET_ALL_POSTS,
variables: { first: pageSize },
});
return data.posts;
},
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
});
}
export function useInfinitePostsQuery(pageSize = 10) {
return useInfiniteQuery({
queryKey: ['posts', 'infinite', pageSize],
queryFn: async ({ pageParam = null }) => {
const { data } = await apolloClient.query({
query: GET_ALL_POSTS,
variables: {
first: pageSize,
after: pageParam,
},
});
return data.posts;
},
getNextPageParam: (lastPage) =>
lastPage.pageInfo.hasNextPage ? lastPage.pageInfo.endCursor : undefined,
staleTime: 5 * 60 * 1000,
});
}
export function usePostQuery(slug: string) {
return useQuery({
queryKey: ['post', slug],
queryFn: async () => {
const { data } = await apolloClient.query({
query: GET_POST_BY_SLUG,
variables: { slug },
});
return data.post;
},
enabled: !!slug,
staleTime: 5 * 60 * 1000,
});
}
Static Generation Setup
Homepage with ISR
// src/app/page.tsx
import { Metadata } from 'next';
import { apolloClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/queries';
import { PostGrid } from '@/components/PostGrid';
import { Hero } from '@/components/Hero';
import { Newsletter } from '@/components/Newsletter';
export const metadata: Metadata = {
title: 'My Tech Blog - Latest Articles on Web Development',
description: 'Discover the latest tutorials, tips, and insights on web development, JavaScript, React, and more.',
openGraph: {
title: 'My Tech Blog',
description: 'Latest articles on web development',
type: 'website',
},
};
async function getFeaturedPosts() {
const { data } = await apolloClient.query({
query: GET_ALL_POSTS,
variables: { first: 6 },
});
return data.posts.edges.map((edge: any) => edge.node);
}
export const revalidate = 3600; // Revalidate every hour
export default async function HomePage() {
const posts = await getFeaturedPosts();
return (
<>
<Hero />
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 sm:text-4xl">
Latest Articles
</h2>
<p className="mt-3 text-xl text-gray-600">
Thoughts, tutorials, and insights on modern web development
</p>
</div>
<PostGrid posts={posts} />
<div className="mt-12 text-center">
<a
href="/blog"
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
View All Posts
<svg className="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</a>
</div>
</section>
<Newsletter />
</>
);
}
Blog Archive Page
// src/app/blog/page.tsx
import { Metadata } from 'next';
import { apolloClient } from '@/lib/apollo-client';
import { GET_ALL_POSTS } from '@/lib/queries';
import { PostList } from '@/components/PostList';
import { Pagination } from '@/components/Pagination';
export const metadata: Metadata = {
title: 'Blog - All Articles',
description: 'Browse all articles on web development, JavaScript, React, and more.',
};
interface BlogPageProps {
searchParams: {
page?: string;
};
}
const POSTS_PER_PAGE = 12;
async function getPosts(page: number) {
const { data } = await apolloClient.query({
query: GET_ALL_POSTS,
variables: {
first: POSTS_PER_PAGE,
after: page > 1 ? btoa(`arrayconnection:${(page - 1) * POSTS_PER_PAGE - 1}`) : null,
},
});
return {
posts: data.posts.edges.map((edge: any) => edge.node),
pageInfo: data.posts.pageInfo,
};
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const currentPage = Number(searchParams.page) || 1;
const { posts, pageInfo } = await getPosts(currentPage);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 sm:text-5xl">
All Articles
</h1>
<p className="mt-3 text-xl text-gray-600">
Explore our complete collection of tutorials and insights
</p>
</div>
<PostList posts={posts} />
{(pageInfo.hasNextPage || currentPage > 1) && (
<Pagination
currentPage={currentPage}
hasNextPage={pageInfo.hasNextPage}
basePath="/blog"
/>
)}
</div>
);
}
Dynamic Routing
Individual Post Pages
// src/app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { apolloClient } from '@/lib/apollo-client';
import { GET_POST_BY_SLUG, GET_ALL_POSTS } from '@/lib/queries';
import { PostContent } from '@/components/PostContent';
import { PostSidebar } from '@/components/PostSidebar';
import { RelatedPosts } from '@/components/RelatedPosts';
import { Comments } from '@/components/Comments';
interface PostPageProps {
params: {
slug: string;
};
}
// Generate static params for all posts
export async function generateStaticParams() {
const { data } = await apolloClient.query({
query: GET_ALL_POSTS,
variables: { first: 100 }, // Adjust based on your needs
});
return data.posts.edges.map((edge: any) => ({
slug: edge.node.slug,
}));
}
// Generate metadata for SEO
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { data } = await apolloClient.query({
query: GET_POST_BY_SLUG,
variables: { slug: params.slug },
});
const post = data?.post;
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt,
openGraph: {
title: post.seo?.opengraphTitle || post.title,
description: post.seo?.opengraphDescription || post.excerpt,
type: 'article',
publishedTime: post.date,
modifiedTime: post.modified,
authors: [post.author.node.name],
images: post.seo?.opengraphImage?.sourceUrl
? [{ url: post.seo.opengraphImage.sourceUrl }]
: post.featuredImage
? [{ url: post.featuredImage.node.sourceUrl }]
: [],
},
twitter: {
card: 'summary_large_image',
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt,
},
};
}
export const revalidate = 3600; // Revalidate every hour
export default async function PostPage({ params }: PostPageProps) {
const { data } = await apolloClient.query({
query: GET_POST_BY_SLUG,
variables: { slug: params.slug },
});
const post = data?.post;
if (!post) {
notFound();
}
// Track view count
await fetch(`${process.env.WORDPRESS_API_URL}/wp-json/custom/v1/track-view`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ postId: post.databaseId }),
});
return (
<>
<article className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="lg:grid lg:grid-cols-3 lg:gap-8">
<div className="lg:col-span-2">
<PostContent post={post} />
<RelatedPosts posts={post.relatedPosts} />
<Comments postId={post.databaseId} />
</div>
<aside className="mt-8 lg:mt-0">
<PostSidebar post={post} />
</aside>
</div>
</article>
</>
);
}
Category Archive Pages
// src/app/category/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { apolloClient } from '@/lib/apollo-client';
import { GET_POSTS_BY_CATEGORY, GET_CATEGORY } from '@/lib/queries';
import { PostGrid } from '@/components/PostGrid';
interface CategoryPageProps {
params: {
slug: string;
};
}
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { data } = await apolloClient.query({
query: GET_CATEGORY,
variables: { slug: params.slug },
});
const category = data?.category;
if (!category) {
return {
title: 'Category Not Found',
};
}
return {
title: `${category.name} - Articles and Tutorials`,
description: category.description || `Browse all ${category.name} articles`,
};
}
export default async function CategoryPage({ params }: CategoryPageProps) {
const [categoryData, postsData] = await Promise.all([
apolloClient.query({
query: GET_CATEGORY,
variables: { slug: params.slug },
}),
apolloClient.query({
query: GET_POSTS_BY_CATEGORY,
variables: { slug: params.slug, first: 20 },
}),
]);
const category = categoryData.data?.category;
const posts = postsData.data?.posts.edges.map((edge: any) => edge.node) || [];
if (!category) {
notFound();
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900">
{category.name}
</h1>
{category.description && (
<p className="mt-3 text-xl text-gray-600">
{category.description}
</p>
)}
</div>
<PostGrid posts={posts} />
</div>
);
}
Deployment Guide
Vercel Deployment
// vercel.json
{
"functions": {
"app/api/preview.ts": {
"maxDuration": 10
},
"app/api/revalidate.ts": {
"maxDuration": 10
}
},
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
},
{
"source": "/api/:path*",
"headers": [
{
"key": "Cache-Control",
"value": "no-store, max-age=0"
}
]
}
],
"rewrites": [
{
"source": "/sitemap.xml",
"destination": "/api/sitemap"
},
{
"source": "/feed.xml",
"destination": "/api/feed"
}
]
}
Environment Variables on Vercel
# Set via Vercel CLI
vercel env add WORDPRESS_API_URL
vercel env add WORDPRESS_GRAPHQL_URL
vercel env add NEXT_PUBLIC_SITE_URL
vercel env add REVALIDATE_SECRET
# Or via Vercel Dashboard
# Project Settings > Environment Variables
Preview Mode Implementation
// src/app/api/preview/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const id = searchParams.get('id');
const slug = searchParams.get('slug');
const postType = searchParams.get('post_type') || 'post';
// Validate secret
if (secret !== process.env.PREVIEW_SECRET) {
return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
}
// Validate required params
if (!id || !slug) {
return NextResponse.json({ message: 'Missing parameters' }, { status: 400 });
}
// Enable Draft Mode
draftMode().enable();
// Redirect to the post
const path = postType === 'page' ? `/${slug}` : `/blog/${slug}`;
redirect(path);
}
// src/app/api/exit-preview/route.ts
export async function GET() {
draftMode().disable();
redirect('/');
}
On-Demand Revalidation
// src/app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: NextRequest) {
const body = await request.json();
const { secret, path, tag } = body;
// Validate secret
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
try {
if (path) {
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
if (tag) {
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}
return NextResponse.json({ message: 'Missing path or tag' }, { status: 400 });
} catch (err) {
return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });
}
}
// WordPress webhook on post update
add_action('save_post', function($post_id, $post) {
if ($post->post_status !== 'publish') {
return;
}
$revalidate_url = 'https://yourblog.com/api/revalidate';
wp_remote_post($revalidate_url, [
'body' => json_encode([
'secret' => 'your-revalidate-secret',
'path' => '/blog/' . $post->post_name,
]),
'headers' => [
'Content-Type' => 'application/json',
],
]);
}, 10, 2);
Performance Optimization
Image Optimization
// src/components/OptimizedImage.tsx
import Image from 'next/image';
import { useState } from 'react';
interface OptimizedImageProps {
src: string;
alt: string;
width: number;
height: number;
priority?: boolean;
className?: string;
sizes?: string;
}
export function OptimizedImage({
src,
alt,
width,
height,
priority = false,
className = '',
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
}: OptimizedImageProps) {
const [isLoading, setLoading] = useState(true);
return (
<div className={`relative overflow-hidden ${className}`}>
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
sizes={sizes}
quality={85}
placeholder="blur"
blurDataURL={`/_next/image?url=${encodeURIComponent(src)}&w=16&q=1`}
className={`
duration-700 ease-in-out
${isLoading ? 'scale-110 blur-2xl grayscale' : 'scale-100 blur-0 grayscale-0'}
`}
onLoadingComplete={() => setLoading(false)}
/>
</div>
);
}
// Usage in PostCard
<OptimizedImage
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
Code Splitting and Lazy Loading
// src/components/Comments.tsx
import dynamic from 'next/dynamic';
import { useState } from 'react';
const CommentForm = dynamic(() => import('./CommentForm'), {
loading: () => <div className="animate-pulse h-32 bg-gray-200 rounded" />,
});
const CommentList = dynamic(() => import('./CommentList'), {
loading: () => <div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-20 bg-gray-200 rounded" />
))}
</div>,
});
export function Comments({ postId }: { postId: number }) {
const [showComments, setShowComments] = useState(false);
return (
<section className="mt-12">
<h2 className="text-2xl font-bold mb-6">Comments</h2>
{!showComments ? (
<button
onClick={() => setShowComments(true)}
className="w-full py-4 bg-gray-100 hover:bg-gray-200 rounded-lg"
>
Load Comments
</button>
) : (
<>
<CommentForm postId={postId} />
<CommentList postId={postId} />
</>
)}
</section>
);
}
SEO Component
// src/components/SEO.tsx
import { NextSeo, ArticleJsonLd, BreadcrumbJsonLd } from 'next-seo';
interface SEOProps {
post: Post;
url: string;
}
export function SEO({ post, url }: SEOProps) {
const publishedTime = new Date(post.date).toISOString();
const modifiedTime = new Date(post.modified).toISOString();
return (
<>
<NextSeo
title={post.seo?.title || post.title}
description={post.seo?.metaDesc || post.excerpt}
canonical={post.seo?.canonical || url}
openGraph={{
title: post.seo?.opengraphTitle || post.title,
description: post.seo?.opengraphDescription || post.excerpt,
url,
type: 'article',
article: {
publishedTime,
modifiedTime,
authors: [post.author.node.name],
tags: post.tags.nodes.map(tag => tag.name),
},
images: post.featuredImage
? [
{
url: post.featuredImage.node.sourceUrl,
width: post.featuredImage.node.mediaDetails.width,
height: post.featuredImage.node.mediaDetails.height,
alt: post.featuredImage.node.altText || post.title,
},
]
: [],
}}
/>
<ArticleJsonLd
type="BlogPosting"
url={url}
title={post.title}
images={post.featuredImage ? [post.featuredImage.node.sourceUrl] : []}
datePublished={publishedTime}
dateModified={modifiedTime}
authorName={post.author.node.name}
description={post.excerpt}
/>
<BreadcrumbJsonLd
itemListElements={[
{
position: 1,
name: 'Home',
item: 'https://yourblog.com',
},
{
position: 2,
name: 'Blog',
item: 'https://yourblog.com/blog',
},
{
position: 3,
name: post.title,
item: url,
},
]}
/>
</>
);
}
Performance Monitoring
// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
// Custom performance monitoring
// src/hooks/usePerformance.ts
import { useEffect } from 'react';
export function usePerformance() {
useEffect(() => {
// Core Web Vitals
if ('web-vitals' in window) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(console.log);
getFID(console.log);
getFCP(console.log);
getLCP(console.log);
getTTFB(console.log);
});
}
// Navigation timing
window.addEventListener('load', () => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const metrics = {
dns: navigation.domainLookupEnd - navigation.domainLookupStart,
tcp: navigation.connectEnd - navigation.connectStart,
ttfb: navigation.responseStart - navigation.requestStart,
download: navigation.responseEnd - navigation.responseStart,
domComplete: navigation.domComplete - navigation.fetchStart,
};
console.log('Performance metrics:', metrics);
// Send to analytics
if (window.gtag) {
window.gtag('event', 'page_timing', {
event_category: 'Performance',
event_label: 'Page Load',
value: Math.round(metrics.domComplete),
});
}
});
}, []);
}
Production Checklist
Pre-Launch Checklist
## Technical
- [ ] All environment variables set in production
- [ ] HTTPS enabled on WordPress backend
- [ ] CORS properly configured
- [ ] API rate limiting implemented
- [ ] Error tracking (Sentry) configured
- [ ] Analytics installed
- [ ] Performance monitoring active
## SEO
- [ ] Sitemap generated and submitted
- [ ] Robots.txt configured
- [ ] Meta tags on all pages
- [ ] Structured data implemented
- [ ] Open Graph images optimized
- [ ] Canonical URLs set
## Performance
- [ ] Images optimized and lazy loaded
- [ ] Code splitting implemented
- [ ] Static generation for all possible pages
- [ ] CDN configured
- [ ] Caching headers set
- [ ] Bundle size optimized
## Content
- [ ] 404 page designed
- [ ] Error boundary implemented
- [ ] Loading states for all components
- [ ] Accessibility tested
- [ ] Mobile responsiveness verified
- [ ] Cross-browser testing complete
Conclusion
Building a headless WordPress blog with Next.js combines the best of both worlds: WordPress's powerful content management with Next.js's modern development experience and blazing-fast performance. The architecture we've built provides:
- Lightning-fast page loads through static generation and ISR
- Excellent SEO with proper meta tags and structured data
- Great developer experience with TypeScript and modern tooling
- Scalability to handle traffic spikes
- Flexibility to add features and integrations
Key takeaways: - Use static generation wherever possible for optimal performance - Implement proper caching strategies at all levels - Optimize images and implement lazy loading - Monitor performance metrics continuously - Keep the WordPress backend secure and optimized
This foundation can be extended with features like: - User authentication and personalization - Advanced search with Algolia - Newsletter integration - E-commerce capabilities - Multi-language support
The headless approach gives you the freedom to create exactly the experience you want while maintaining the familiar WordPress editing experience your content team loves.
Ready to explore more headless WordPress possibilities? Check out our comprehensive Headless WordPress Guide for advanced patterns and integrations.