Modern websites need more than just content management – they also require fast performance, SEO optimization, scalability, and a flexible developer experience. A headless CMS architecture solves this by separating the content management system from the frontend application.

In this guide, you’ll build a modern headless WordPress setup using:

  • WordPress as the backend CMS
  • Next.js as the frontend framework
  • WPGraphQL for GraphQL support
  • TypeScript for type safety
  • Tailwind CSS for styling
  • GraphQL Codegen for automatic TypeScript generation

By the end, you’ll have a scalable, SEO-friendly blog architecture powered by WordPress and rendered through Next.js App Router.

Architecture Overview


Production Implementation Example

To demonstrate how this architecture performs in a real production environment, we implemented this setup on www.regur.net using a Headless WordPress and Next.js architecture.

The implementation separates content management responsibilities from frontend rendering, allowing WordPress to function exclusively as the content layer while Next.js handles presentation, routing, and rendering.

By adopting a headless approach, the website benefits from:

  • improved frontend performance
  • better SEO optimization
  • scalable architecture
  • cleaner development workflows
  • faster page rendering

The implementation also uses modern Next.js features such as dynamic routing, metadata generation, image optimization, and Incremental Static Regeneration (ISR) to keep content fast and automatically updated without requiring full website rebuilds.

Tech Stack Overview

Technology Purpose
WordPress Headless CMS
Next.js Frontend framework
TypeScript Static typing
WPGraphQL GraphQL API for WordPress
GraphQL Codegen Auto-generate TypeScript types
Tailwind CSS Styling
ESLint Code quality

This setup provides:

  • Better SEO
  • Faster performance
  • Type-safe development
  • Flexible frontend architecture
  • Scalable content management

1. Install Required WordPress Plugins

Before creating the frontend, install the required GraphQL plugins in WordPress.

Required Plugins

  • WPGraphQL
  • WPGraphQL Rank Math Addon (optional but recommended for SEO metadata)

Once activated, your GraphQL endpoint becomes:

http://localhost/wordpress/graphql

You can verify it by opening the URL in your browser.

2. Create the Next.js Project

Create a new Next.js application with TypeScript support.

npx create-next-app@latest my-headless-site --typescript
cd my-headless-site

This automatically sets up:

  • App Router
  • TypeScript
  • ESLint
  • Modern Next.js configuration

3. Install Dependencies

Install GraphQL and GraphQL Codegen packages.

npm install graphql graphql-request graphql-tag

npm install -D \
@graphql-codegen/cli \
@graphql-codegen/client-preset \
@graphql-codegen/schema-ast

4. Configure Environment Variables

Create a .env.local file:

NEXT_PUBLIC_SITE_URL=http://localhost:3000

NEXT_PUBLIC_WORDPRESS_URL=http://localhost/wordpress

NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL=http://localhost/wordpress/graphql

NEXT_PUBLIC_SITE_PROTOCOL=http

NEXT_PUBLIC_WORDPRESS_REMOTE_IMAGE_HOSTNAME=localhost

These variables help configure:

  • GraphQL endpoint
  • Image optimization
  • Environment-specific URLs

5. Configure GraphQL Codegen

Create a codegen.ts file in the project root.

// codegen.ts

import type { CodegenConfig } from "@graphql-codegen/cli";
import { loadEnvConfig } from "@next/env";

const projectDir = process.cwd();

loadEnvConfig(projectDir);

const config: CodegenConfig = {
overwrite: true,

schema: {
[process.env.NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL as string]: {
headers: {
"User-Agent": "Codegen",
},
},
},

documents: ["src/**/*.{ts,tsx}"],

generates: {
"src/gql/": {
preset: "client",
},

"src/gql/schema.gql": {
plugins: ["schema-ast"],
},
},
};

export default config;

This automatically generates:

  • GraphQL types
  • Typed queries
  • Schema definitions

whenever you re-run the Codegen process or integrate it into your build pipeline.

6. Add Codegen Scripts

Update package.json scripts:

{
"scripts": {
"dev": "graphql-codegen --config codegen.ts && next dev",
"build": "graphql-codegen --config codegen.ts && next build",
"start": "next start",
"lint": "next lint",
"codegen": "graphql-codegen --config codegen.ts"
}
}

This ensures GraphQL types are generated before development and production builds.

7. Configure Next.js Image Optimization

This allows Next.js to optimize images served from WordPress.

Update next.config.js:

/** @type {import('next').NextConfig} */

const nextConfig = {
trailingSlash: true,

images: {
remotePatterns: [
{
protocol: process.env.NEXT_PUBLIC_SITE_PROTOCOL,
hostname:
process.env.NEXT_PUBLIC_WORDPRESS_REMOTE_IMAGE_HOSTNAME,
},
],
},

eslint: {
dirs: ["app", "components", "lib", "queries", "utils"],
},
};

module.exports = nextConfig;

Note: Using environment variables for the protocol and hostname makes the configuration flexible and easier to manage across different environments. For example, local development may use http with a local hostname, while staging or production requires https with a different domain, without needing to modify the source code.

8. Recommended Project Structure

A clean folder structure improves maintainability.

src/
├── app/
├── components/
├── gql/
├── lib/
├── queries/
├── styles/
└── utils/

9. Create a GraphQL Fetch Utility

Create a reusable fetch helper.

// src/lib/fetchGraphQL.ts

export async function fetchGraphQL<T = unknown>(
query: string,
variables?: Record<string, unknown>,
headers?: Record<string, string>
): Promise<T> {
const response = await fetch(
process.env.NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL as string,
{
method: "POST",

headers: {
"Content-Type": "application/json",
...headers,
},

body: JSON.stringify({
query,
variables,
}),

next: {
tags: ["wordpress"],
revalidate: 60,
},
}
);

if (!response.ok) {
throw new Error(response.statusText);
}

const data = await response.json();

if (data.errors) {
throw new Error(
"GraphQL Errors: " + JSON.stringify(data.errors)
);
}

return data.data;
}

10. Understanding ISR (Incremental Static Regeneration)

One of the biggest benefits of Next.js is ISR.

next: {
revalidate: 60
}

The revalidate option enables Next.js Data Cache revalidation, allowing cached content to refresh automatically at defined intervals without requiring a full rebuild.

This tells Next.js to:

  • cache the rendered result and revalidate it at a defined interval
  • regenerating content in the background when stale
  • update content automatically every 60 seconds

Benefits:

  • Performance: faster rendering and caching via ISR
  • SEO: improved indexing and metadata control
  • Scalability: decoupled architecture supports growth
  • Developer Experience: type-safe and modular workflow

This is especially useful for blogs and content-heavy websites.

11. Create the Posts Query

Create src/queries/posts.ts.

import { gql } from "graphql-tag";

export const GeneralPostsQuery = gql`
query GeneralPostsQuery {
posts(
first: 100
where: {
orderby: {
field: DATE
order: DESC
}
}
) {
nodes {
id
title
slug
date
excerpt

featuredImage {
node {
sourceUrl
}
}
}
}
}
`;

12. Create the Single Post Query

import { gql } from "graphql-tag";

export const DetailsPostQuery = gql`
query DetailsPostQuery($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
content
date
seo {
title
description
openGraph {
image {
secureUrl
}
}
}
}
}
`;

13. Create Post Types

export type Post = {
id: string;
title: string;
slug: string;
date: string;
excerpt: string;

featuredImage?: {
node: {
sourceUrl: string;
};
};
};

export type PostsProps = {
posts: {
nodes: Post[];
};
};

14. Fetch and Display Posts

import Link from "next/link";
import Image from "next/image";

import { print } from "graphql";

import { fetchGraphQL } from "@/lib/fetchGraphQL";

import {
GeneralPostsQuery,
} from "@/queries/posts";

const { posts } =
await fetchGraphQL<PostsProps>(
print(GeneralPostsQuery)
);

return (
<>
{posts?.nodes?.map((post) => (
<div key={post.id}>
<Link href={`/blog/${post.slug}`}>
<Image
src={
post.featuredImage?.node.sourceUrl || ""
}
alt={post.title || "Blog post image" }
width={357}
height={223}
/>
</Link>

<Link
href={`/blog/${post.slug}`}
className="font-medium text-black hover:underline text-base"
>
{post.title}
</Link>
</div>
))}
</>
);

15. Create Dynamic Blog Pages

Create:

app/blog/[slug]/page.tsx

16. Generate Static Pages

Use generateStaticParams() to pre-render blog pages at build time.

export async function generateStaticParams() {
const { posts } =
await fetchGraphQL<PostsProps>(
print(GeneralPostsQuery)
);

return posts.nodes.map((post) => ({
slug: post.slug,
}));
}

This allows Next.js to statically generate known blog routes for better performance and SEO.

17. Generate Dynamic SEO Metadata

import type { Metadata } from "next";

type Props = {
params: {
slug: string;
};
};

export async function generateMetadata({
params,
}: Props): Promise<Metadata> {
const { post } =
await fetchGraphQL<{ post: Post }>(
print(DetailsPostQuery),
{
slug: params.slug,
}
);

if (!post) {
return {
title: "Page Not Found",
description: "The requested post could not be found.",
};
}

return {
title:
post?.seo?.title ||
"Page Not Found",

description:
post?.seo?.description ||
"Post not found.",

openGraph: {
images: [
{
url:
post?.seo?.openGraph?.image
?.secureUrl || "",
},
],
},
};
}

This improves:

  • SEO
  • social sharing previews
  • search engine visibility

18. Render the Single Blog Post

import { notFound } from "next/navigation";

export default async function BlogPostPage({
params,
}: {
params: {
slug: string;
};
}) {
const { post } =
await fetchGraphQL<{ post: Post }>(
print(DetailsPostQuery),
{
slug: params.slug,
}
);

if (!post) {
return notFound();
}

return (
<main className="prose mx-auto">
<h1>{post.title}</h1>

<article
dangerouslySetInnerHTML={{
__html: post.content,
}}
/>
</main>
);
}

dangerouslySetInnerHTML should only be used with trusted or properly sanitized content, as it renders raw HTML directly into the page. In controlled editorial environments like WordPress, content is generally considered safe when it does not include untrusted user input, though additional sanitization is still recommended for production applications.

19. Add Global Metadata

For non-dynamic pages, define default metadata in app/layout.tsx.

import type { Metadata } from "next";
import type { ReactNode } from "react";

export const metadata: Metadata = {
title:
"My Blog - Powered by Headless WordPress",

description:
"A fast and SEO-friendly blog built using Next.js and WordPress.",

openGraph: {
images: [
"https://example.com/default-og-image.jpg",
],
},
};

export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

20. Production SEO Recommendations

For production-ready SEO optimization, consider implementing:

  • sitemap.xml generation
  • robots.txt configuration
  • canonical URLs
  • structured data (JSON-LD)
  • Open Graph metadata
  • Twitter card metadata

These improvements help search engines better understand and index your website while improving social sharing previews and technical SEO performance.

21. Add Loading Error States

Next.js App Router supports route-level loading and error handling.

loading.tsx

export default function Loading() {
return <p>Loading...</p>;
}

error.tsx

"use client";

export default function Error({
error,
}: {
error: Error;
}) {
return (
<div>
<h2>Something went wrong!</h2>

<p>{error.message}</p>
</div>
);
}

These improve:

  • user experience
  • error recovery
  • perceived performance

22. Tailwind CSS Setup

Install Tailwind CSS:

npm install -D tailwindcss postcss autoprefixer

Create configuration:

npx tailwindcss init -p

Update your global stylesheet:

@tailwind base;
@tailwind components;
@tailwind utilities;

You can now use Tailwind utility classes throughout the project.

23. Benefits of This Architecture

Why Use Headless WordPress?

Using WordPress as a headless CMS gives content editors a familiar dashboard while allowing developers to build modern frontend experiences independently.

Why Use GraphQL Instead of REST?

GraphQL allows clients to request only the data they need, reducing over-fetching and improving frontend flexibility.

Why Use Next.js?

Next.js provides:

  • static generation
  • ISR
  • SEO optimization
  • server rendering
  • App Router architecture
  • image optimization

making it ideal for modern CMS-driven websites.

24. Deployment Considerations

This architecture can be deployed across multiple modern hosting platforms, including:

  • Vercel
  • Netlify
  • AWS
  • Docker-based environments

When deploying to production, consider:

  • environment variable management
  • caching strategies
  • image optimization
  • build performance
  • CDN integration
  • Incremental Static Regeneration (ISR)
  • security headers

Next.js works especially well with Vercel because of its built-in support for server rendering, caching, and edge deployment features.

Conclusion

You now have a modern headless CMS architecture powered by:

  • WordPress
  • Next.js
  • WPGraphQL
  • TypeScript

This setup combines:

  • flexible content management
  • excellent SEO
  • fast frontend performance
  • type-safe development
  • scalable architecture

and is well-suited for:

  • blogs
  • marketing websites
  • content platforms
  • enterprise CMS projects
  • editorial websites

As your project grows, you can extend this architecture with:

  • authentication
  • preview mode
  • search
  • caching layers
  • edge rendering
  • deployment pipelines
  • AI-powered content features

making it a strong foundation for modern web applications.