This guide provides step-by-step instructions for implementing WordPress as a headless CMS in a Next.js application using Faust.js and GraphQL. This approach allows you to use WordPress for content management while leveraging Next.js for the frontend.
- A WordPress site with admin access
- Node.js and npm/yarn installed
- Basic knowledge of React and Next.js
- Basic understanding of GraphQL
-
Install Required WordPress Plugins:
- WPGraphQL: Exposes your WordPress data as a GraphQL API
- FaustWP: Connects your WordPress site to your headless frontend
# These can be installed via the WordPress admin dashboard or using WP-CLI wp plugin install wp-graphql --activate wp plugin install faustwp --activate
-
Configure FaustWP Plugin:
- Go to WordPress Admin → Settings → Headless
- Set your frontend URL (your Next.js app URL)
- Generate a secret key for preview functionality
- Save settings
-
Initialize a New Next.js Project:
npx create-next-app my-headless-wp cd my-headless-wp
-
Install Required Dependencies:
npm install @apollo/client graphql @faustwp/core @faustwp/cli
-
Create a
.env.local
File:# WordPress NEXT_PUBLIC_WORDPRESS_API_URL=https://your-wordpress-site.com/graphql NEXT_PUBLIC_WORDPRESS_IMAGE_DOMAIN=your-wordpress-site.com # WordPress Preview (optional) WORDPRESS_AUTH_REFRESH_TOKEN= WORDPRESS_PREVIEW_SECRET=your-secret-key
-
Create a WordPress Client File (
lib/wordpress-client.ts
):import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; const cache = new InMemoryCache(); const link = createHttpLink({ uri: process.env.NEXT_PUBLIC_WORDPRESS_API_URL, credentials: 'same-origin' }); export const client = new ApolloClient({ link, cache, defaultOptions: { query: { fetchPolicy: 'no-cache', }, }, }); export default client;
-
Create a WordPress Utility File (
lib/wordpress.ts
):import { ApolloClient, InMemoryCache, createHttpLink, FetchPolicy, gql } from '@apollo/client'; import type { WPPost, WPPage } from '@/types/wordpress'; const defaultOptions = { watchQuery: { fetchPolicy: 'no-cache' as const, errorPolicy: 'all' as const, }, query: { fetchPolicy: 'no-cache' as FetchPolicy, errorPolicy: 'all' as const, }, }; const cache = new InMemoryCache({ typePolicies: { Post: { keyFields: ["id", "slug"], }, Page: { keyFields: ["id", "slug"], }, }, }); const link = createHttpLink({ uri: process.env.NEXT_PUBLIC_WORDPRESS_API_URL, }); export const client = new ApolloClient({ link, cache, defaultOptions, }); // Define your GraphQL queries export const POSTS_QUERY = gql` query Posts($first: Int!) { posts(first: $first, where: { orderby: { field: DATE, order: DESC } }) { nodes { id title excerpt slug date featuredImage { node { sourceUrl altText } } author { node { name avatar { url } } } categories { nodes { id name slug } } } } } `; export const POST_QUERY = gql` query Post($uri: ID!) { post(id: $uri, idType: URI) { id title content slug date featuredImage { node { sourceUrl altText } } author { node { name avatar { url } } } categories { nodes { id name slug } } } } `; // Add more queries as needed
-
Create WordPress Types (
types/wordpress.ts
):export interface WPImage { sourceUrl: string; altText?: string; } export interface WPAuthor { node: { name: string; avatar?: { url: string; }; }; } export interface WPCategory { node: { name: string; uri: string; }; } export interface WPPost { id: string; title: string; content: string; slug: string; uri?: string; date: string; excerpt: string; categories?: { nodes: WPCategory[]; }; featuredImage?: { node: WPImage; }; author?: WPAuthor; } export interface WPPage { id: string; title: string; content: string; slug: string; uri?: string; template?: { templateName: string; }; featuredImage?: { node: WPImage; }; } export interface WPPaginationVars { first: number; after?: string; } export interface WPPostsResponse { posts: { nodes: WPPost[]; pageInfo: { hasNextPage: boolean; endCursor: string; }; }; }
-
Create a Blog Index Page (
app/blog/page.tsx
):import client from '@/lib/wordpress-client'; import type { WPPost } from '@/types/wordpress'; import { POSTS_QUERY } from '@/lib/wordpress'; import Link from 'next/link'; export default async function BlogPage() { const { data } = await client.query({ query: POSTS_QUERY, variables: { first: 10, }, }); const posts = data?.posts?.nodes || []; return ( <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8">Blog Posts</h1> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {posts.map((post: WPPost) => ( <article key={post.uri || post.slug} className="border rounded-lg p-4"> {post.featuredImage?.node && ( <img src={post.featuredImage.node.sourceUrl} alt={post.featuredImage.node.altText || post.title} className="w-full h-48 object-cover rounded-md mb-4" /> )} <h2 className="text-xl font-semibold mb-2">{post.title}</h2> <div className="text-gray-600" dangerouslySetInnerHTML={{ __html: post.excerpt }} /> <Link href={`/blog/${post.slug}`} className="text-blue-600 hover:underline mt-4 inline-block"> Read more </Link> </article> ))} </div> </div> ); }
-
Create a Blog Post Page (
app/blog/[slug]/page.tsx
):import { cookies } from 'next/headers'; import { POST_QUERY, isPreviewMode, getClient } from '@/lib/wordpress'; import type { WPPost } from '@/types/wordpress'; export default async function PostPage({ params }: { params: { slug: string } }) { const { data } = await client.query({ query: POST_QUERY, variables: { uri: params.slug, }, }); if (!data?.post) return <div>Post not found</div>; const post = data.post as WPPost; const categories = post.categories?.nodes || []; return ( <article className="container mx-auto px-4 py-8 max-w-4xl"> <h1 className="text-4xl font-bold mb-4">{post.title}</h1> {post.featuredImage?.node && ( <img src={post.featuredImage.node.sourceUrl} alt={post.featuredImage.node.altText || post.title} className="w-full h-64 object-cover rounded-lg mb-6" /> )} <div className="flex items-center text-gray-600 mb-6"> <span>By {post.author?.node?.name}</span> <span className="mx-2">•</span> <span>{new Date(post.date).toLocaleDateString()}</span> {categories.length > 0 && ( <> <span className="mx-2">•</span> <span> {categories.map((cat) => cat.name).join(', ')} </span> </> )} </div> <div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); }
-
Create a Preview Client (
lib/preview-client.ts
):import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; import { setContext } from '@apollo/client/link/context'; const cache = new InMemoryCache(); /** * Create an Apollo Client instance configured for WordPress preview mode * This client includes authentication headers for preview requests */ export function createPreviewClient(token?: string) { // Create the standard HTTP link const httpLink = createHttpLink({ uri: process.env.NEXT_PUBLIC_WORDPRESS_API_URL, credentials: 'same-origin' }); // Add auth headers if token is provided const authLink = setContext((_, { headers }) => { if (!token) { return { headers }; } // Return the headers to the context so httpLink can read them return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '', } }; }); return new ApolloClient({ link: authLink.concat(httpLink), cache, defaultOptions: { query: { fetchPolicy: 'no-cache', }, }, }); }
-
Create a Preview API Route (
app/api/preview/route.ts
):import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const secret = searchParams.get('secret'); const slug = searchParams.get('slug'); const postType = searchParams.get('postType') || 'post'; // Check the secret and next parameters if (secret !== process.env.WORDPRESS_PREVIEW_SECRET || !slug) { return NextResponse.json( { message: 'Invalid token or missing slug' }, { status: 401 } ); } // Enable Preview Mode by setting the cookies const response = NextResponse.redirect( new URL(`/${postType}/${slug}`, request.url) ); response.cookies.set('__preview_mode', 'true', { path: '/', httpOnly: true, sameSite: 'strict' }); return response; }
-
Add Preview Mode Detection (add to
lib/wordpress.ts
):/** * Check if the current request is in preview mode */ export function isPreviewMode(cookies: any): boolean { return cookies.get('__preview_mode')?.value === 'true'; } /** * Get the appropriate client based on preview mode */ export function getClient(preview: boolean = false, token?: string) { if (preview && token) { // Use dynamic import to avoid loading on the server unnecessarily const { createPreviewClient } = require('./preview-client'); return createPreviewClient(token); } return client; }
-
Create a Faust Config File (
faust.config.js
):import { config } from '@faustwp/core'; export default config({ wpUrl: process.env.NEXT_PUBLIC_WORDPRESS_URL || 'https://your-wordpress-site.com', apiClientSecret: process.env.WORDPRESS_PREVIEW_SECRET, });
-
Create a Faust API Route (
app/api/faust/[[...route]].js
):import "../../../faust.config"; export { apiRouter as default } from "@faustwp/core";
-
Create a WordPress Node Catch-All Route (
app/[...wordpressNode].js
):import { getWordPressProps, WordPressTemplate } from "@faustwp/core"; export default function Page(props) { return <WordPressTemplate {...props} />; } export function getStaticProps(ctx) { return getWordPressProps({ ctx }); } export async function getStaticPaths() { return { paths: [], fallback: "blocking", }; }
-
Create a Template Mapping File (
lib/template-mapping.ts
):/** * Template mapping configuration * Maps WordPress template names to Next.js page components */ export interface TemplateMapping { [key: string]: string; } /** * Maps WordPress template names to Next.js routes * Default template is used when no specific template is found */ export const templateMapping: TemplateMapping = { 'default': '/page/[slug]', 'template-full-width': '/page/full-width/[slug]', 'template-contact': '/contact', 'template-about': '/about', 'template-blog': '/blog' }; /** * Get the appropriate Next.js route based on WordPress template */ export function getTemplateRoute(templateName: string | undefined, slug: string): string { if (!templateName) { return `/page/${slug}`; } const route = templateMapping[templateName] || templateMapping['default']; return route.replace('[slug]', slug); }
-
Update Next.js Config (
next.config.mjs
):/** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: process.env.NEXT_PUBLIC_WORDPRESS_IMAGE_DOMAIN || "your-wordpress-site.com", pathname: '/wp-content/uploads/**', } ], }, }; export default nextConfig;
-
Create a WordPress Single Post Template (
app/wp-templates/single.js
):import { gql } from "@apollo/client"; export default function SingleTemplate(props) { const { title, content } = props.data.post; return ( <> <h1>{title}</h1> <div dangerouslySetInnerHTML={{ __html: content }} /> </> ); } SingleTemplate.query = gql` query GetPost($uri: ID!) { post(id: $uri, idType: URI) { title content } } `; SingleTemplate.variables = (seedQuery, ctx) => { return { uri: seedQuery?.uri, }; };
-
Register Templates (
app/wp-templates/index.js
):import SingleTemplate from "./single"; const templates = { single: SingleTemplate, }; export default templates;
You've now successfully set up a headless WordPress integration with Next.js! This approach gives you the best of both worlds: WordPress's powerful content management capabilities and Next.js's performance and developer experience.
With this setup, you can:
- Use WordPress as a headless CMS
- Fetch data from WordPress using GraphQL
- Create dynamic routes based on WordPress content
- Preview content before publishing
- Use WordPress templates with Next.js components
Remember to customize the queries and components based on your specific needs. The WordPress GraphQL schema provides many more fields and connections that you can use to build rich, dynamic websites.