How to handle WordPress previews in a decoupled Next.js frontend?

So, you’re building a sleek Next.js frontend and hooking it up to a robust WordPress backend. Fantastic! But then you hit a familiar snag: how do you get those “preview” buttons in WordPress to actually show you what your content will look like before it’s published on your Next.js site? Don’t worry, it’s a common challenge, but entirely solvable.

The short answer is: you need a mechanism that allows WordPress to communicate a draft state to your Next.js application, enabling Next.js to fetch and render that unpublished content. This usually involves generating a unique token or secret within WordPress, passing it to Next.js via a specific URL structure, and then Next.js using that token to request the draft data from WordPress’s API.

Let’s dive a bit deeper into the practicalities.

When we talk about a “decoupled” or “headless” WordPress setup, we mean that WordPress is primarily acting as a content management system (CMS), providing data through an API (like REST or GraphQL), and a separate application (our Next.js site) is responsible for rendering that data.

The standard WordPress “Preview” button, by default, tries to load the WordPress theme’s preview page. But in our decoupled world, there’s no WordPress theme rendering the frontend. Next.js is doing that. So, we need to bridge this gap.

The fundamental issue is that unpublished content isn’t typically available to the public. If it were, anyone could stumble upon your half-finished blog posts. So, the preview mechanism needs to be secure and temporary, providing a window into content that’s not yet live.

Why Standard Previews Don’t Work Natively

  • No WordPress Theme: Your Next.js site is your frontend. WordPress doesn’t know how to “preview” on that.
  • Public vs. Private: Unpublished posts are usually private. How do you tell Next.js to fetch something secret without making it entirely public?
  • API Exposure: WordPress’s standard REST API, for instance, might not expose draft content without specific authentication or parameters.

If you’re looking to enhance your understanding of managing WordPress previews in a decoupled Next.js frontend, you might find it beneficial to explore a related article that delves into the integration of headless CMS with modern JavaScript frameworks. This resource provides insights on optimizing content delivery and improving user experience. For more information, check out this article: here.

The Essential Ingredients for a Next.js Preview System

To make this work, you’ll generally need a few key components playing nicely together. Think of it like a little network of secure messengers.

WordPress: The Content Originator

On the WordPress side, you’ll need a way to:

  • Generate a Preview Token/Secret: This acts as a temporary key that grants access to the draft content. It should be unique, hard to guess, and ideally expire after a certain time or use.
  • Modify the Preview Link: When an editor clicks “Preview,” WordPress needs to generate a URL pointing to your Next.js site, not its own, and include the preview token and the ID of the content being previewed.
  • Expose Draft Content: Your WordPress API (whether REST, GraphQL, or a custom endpoint) needs to be configured to return draft content when presented with the correct preview token.

Next.js: The Content Presenter

On your Next.js application, you’ll need to:

  • Handle Incoming Preview Requests: A specific route in your Next.js app needs to be set up to catch the preview links generated by WordPress.
  • Securely Fetch Draft Data: When a preview request comes in, Next.js needs to extract the preview token and content ID, then use them to make a secure API call back to WordPress to get the draft version of the content.
  • Display the Draft: Once the draft data is fetched, Next.js renders it just like any other content, but perhaps with a visual indicator that it’s a preview.
  • Clear Preview State: Provide a way to exit the preview mode and return to the live content view.

Method 1: Using WordPress’s Built-in API and Next.js Draft Mode

This is often the most straightforward and recommended approach, leveraging Next.js’s native Draft Mode feature (formerly Preview Mode).

1. Setting Up WordPress for Previews

We need a way for WordPress to generate a special link and provide access to draft posts.

a. Customizing the Preview Button Link

WordPress’s default preview link points to /?p=[post_id]&preview=true. We need to override this.

You’ll typically do this with a filter in your WordPress functions.php file or a custom plugin. The goal is to generate a URL like https://yournextjsdomain.com/api/preview?secret=[YOUR_SECRET_KEY]&slug=[post_slug]&post_type=[post_type_slug].

“`php

function custom_preview_link($link, $post) {

if (is_preview() || !is_object($post) || !isset($post->post_type)) {

return $link;

}

// Replace with your actual Next.js application URL

$nextjs_base_url = ‘https://yournextjsdomain.com’;

$preview_api_route = ‘/api/preview’; // The Next.js API route we’ll create

// Get a secret key. This should be a strong, hard-to-guess string.

// Store it securely, perhaps in your wp-config.php or as an environment variable.

// Make sure this matches the secret key in your Next.js application.

$secret = get_option(‘NEXTJS_PREVIEW_SECRET’, ‘YOUR_SUPER_SECRET_KEY_HERE’);

// Build the query parameters for your Next.js route

$query_params = array(

‘secret’ => $secret,

‘slug’ => $post->post_name, // Or use a custom field for slug if needed

‘post_type’ => $post->post_type,

// You might want to pass the post ID too for more robust fetching

‘id’ => $post->ID,

);

// Build the full URL

$new_preview_link = add_query_arg($query_params, $nextjs_base_url . $preview_api_route);

return $new_preview_link;

}

add_filter(‘preview_post_link’, ‘custom_preview_link’, 10, 2);

// Optionally, you might need to adjust the “View Post” link in the admin bar too

function custom_wp_admin_bar_view_site_link($wp_admin_bar) {

global $post;

if (!is_admin() && is_singular() && is_object($post) && isset($post->post_status) && $post->post_status === ‘publish’) {

// This filter is primarily for published posts, need to adapt if viewing draft

// The above preview_post_link filter should handle the actual preview button

}

}

// add_action( ‘wp_admin_bar_menu’, ‘custom_wp_admin_bar_view_site_link’, 80 ); // Be careful with this, might override other things.

“`

Important: Don’t hardcode YOUR_SUPER_SECRET_KEY_HERE in production. Fetch it from an environment variable or wp-config.php.

b. Exposing Draft Content via API

The standard REST API wp/v2/posts endpoint doesn’t expose drafts unless you’re authenticated. You can handle this in a few ways:

  • OAuth or Application Passwords: If your Next.js app can securely send authentication headers to WordPress (e.g., using an application password created in WordPress settings), you can query drafts directly. This is more complex for previews as the browser client would need those credentials.
  • Custom REST Endpoint with Token Check: A safer approach is to create a custom REST API endpoint in WordPress that, only when presented with your shared secret, allows fetching of draft content for a specific post ID or slug.

“`php

// In functions.php or a custom plugin

add_action(‘rest_api_init’, function () {

register_rest_route(‘myplugin/v1’, ‘/preview-post’, array(

‘methods’ => ‘GET’,

‘callback’ => ‘myplugin_get_preview_post’,

‘args’ => array(

‘id’ => array(

‘required’ => true,

‘validate_callback’ => function($param, $request, $key) {

return is_numeric($param);

}

),

‘secret’ => array(

‘required’ => true,

‘validate_callback’ => function($param, $request, $key) {

return is_string($param);

}

)

),

‘permission_callback’ => function ($request) {

// Retrieve your secret key securely (e.g., from an environment variable)

$expected_secret = get_option(‘NEXTJS_PREVIEW_SECRET’, ‘YOUR_SUPER_SECRET_KEY_HERE’);

return $request[‘secret’] === $expected_secret;

}

));

});

function myplugin_get_preview_post($request) {

$post_id = intval($request[‘id’]);

$post = get_post($post_id);

if (!$post || ($post->post_status !== ‘draft’ && $post->post_status !== ‘pending’ && $post->post_status !== ‘future’ && $post->post_status !== ‘private’)) {

return new WP_Error(‘rest_no_preview_post’, ‘No draft post found with that ID or status.’, array(‘status’ => 404));

}

// You might need to process the post data similar to how your main API does

// For example, if you rely on WPGraphQL, you’d replicate some of that logic here

$data = array(

‘id’ => $post->ID,

‘slug’ => $post->post_name,

‘title’ => $post->post_title,

‘content’ => apply_filters(‘the_content’, $post->post_content), // Apply content filters

‘status’ => $post->post_status,

‘date’ => $post->post_date,

// Add other custom fields/ACF data here

);

return new WP_REST_Response($data, 200);

}

“`

This custom endpoint provides a secure gateway to draft content.

2. Configuring Next.js for Draft Mode

Next.js provides a “Draft Mode” (formerly “Preview Mode”) that’s perfect for this.

a. Creating the Next.js API Route for Preview Activation

This API route will receive the request from your WordPress preview link.

Create pages/api/preview.js:

“`javascript

// pages/api/preview.js

export default async function preview(req, res) {

// 1. Validate the secret

const { secret, slug, id, post_type } = req.query;

// Replace with your actual environment variable for the secret

// Make sure this matches the secret generated in WordPress

const NEXTJS_PREVIEW_SECRET = process.env.NEXTJS_PREVIEW_SECRET;

if (!secret || secret !== NEXTJS_PREVIEW_SECRET || (!slug && !id)) {

return res.status(401).json({ message: ‘Invalid token or missing slug/ID’ });

}

// 2. Fetch the draft post data from WordPress

// You’d use the ID or slug here to fetch the specific draft post

// from your custom WordPress REST endpoint or WPGraphQL setup.

// Example using the custom REST endpoint we defined:

let postData;

try {

const wordpressApiBase = process.env.WORDPRESS_API_URL; // e.g., ‘https://yourwordpressdomain.com/wp-json’

const previewEndpoint = ${wordpressApiBase}/myplugin/v1/preview-post?id=${id}&secret=${secret}; // Or slug=${slug}

const response = await fetch(previewEndpoint);

if (!response.ok) {

throw new Error(WordPress API error: ${response.statusText});

}

postData = await response.json();

if (!postData) {

return res.status(404).json({ message: ‘Post not found.’ });

}

} catch (error) {

console.error(‘Error fetching preview post:’, error);

return res.status(500).json({ message: ‘Error fetching preview post data.’ });

}

// 3. Set Next.js Draft Mode cookies

res.setDraftMode({ enable: true });

// 4. Redirect to the content page

const redirectPath = /${post_type || 'post'}/${postData.slug || slug}; // Adjust based on your routing

res.redirect(redirectPath);

}

“`

Environment Variables: Make sure you set NEXTJS_PREVIEW_SECRET and WORDPRESS_API_URL in your .env.local file (and production environment variables).

b. Handling Draft Mode in Your Next.js Pages

In your [slug].js (or similar) content page, you’ll need to adapt getStaticProps or getServerSideProps to check if draft mode is active.

“`javascript

// pages/[postType]/[slug].js (or adjust your dynamic route)

import { draftMode } from ‘next/headers’;

import { notFound } from ‘next/navigation’;

export async function generateStaticParams() {

// Fetch all published post slugs to pre-render static pages

// This function doesn’t need to consider drafts, as drafts are dynamic requests

const publishedPosts = await fetch(${process.env.WORDPRESS_API_URL}/wp/v2/posts?per_page=100)

.then(res => res.json());

return publishedPosts.map(post => ({

postType: ‘posts’, // Or whatever your post type slug is

slug: post.slug,

}));

}

export default async function Page({ params }) {

const { isEnabled } = draftMode();

const { postType, slug } = params;

let post;

if (isEnabled) {

// If in Draft Mode, dynamically fetch the latest (draft) content

// Use the secret from your environment (only available server-side)

const NEXTJS_PREVIEW_SECRET = process.env.NEXTJS_PREVIEW_SECRET;

// Using our custom WordPress preview endpoint

const previewEndpoint = ${process.env.WORDPRESS_API_URL}/myplugin/v1/preview-post?slug=${slug}&secret=${NEXTJS_PREVIEW_SECRET};

try {

const response = await fetch(previewEndpoint, { cache: ‘no-store’ }); // Don’t cache draft content

if (!response.ok) {

throw new Error(Failed to fetch draft post: ${response.statusText});

}

post = await response.json();

} catch (error) {

console.error(“Error fetching draft post:”, error);

notFound(); // Fallback if draft can’t be fetched

}

} else {

// If NOT in Draft Mode, fetch the published content (can be static/ISR)

const publishedEndpoint = ${process.env.WORDPRESS_API_URL}/wp/v2/posts?slug=${slug}&_embed;

try {

const response = await fetch(publishedEndpoint);

if (!response.ok) {

throw new Error(Failed to fetch published post: ${response.statusText});

}

const data = await response.json();

post = data[0]; // REST API returns an array

} catch (error) {

console.error(“Error fetching published post:”, error);

notFound();

}

}

if (!post) {

notFound();

}

return (

{isEnabled &&

PREVIEW MODE ACTIVE

}

{post.title.rendered || post.title}

{/ Handle different title structures /}

{/ Handle different content structures /}

{/ … other post details /}

);

}

“`

Key Points for Draft Mode:

  • draftMode().isEnabled: This boolean tells you if the client is currently in draft mode.
  • cache: 'no-store': Crucial for preview content to ensure you always get the latest draft, not a cached version.
  • Dynamic Data Fetching: When in draft mode, you’ll need to fetch the post data dynamically, typically during the request (e.g., in getServerSideProps or directly within the component if it’s a server component).
  • Security of Secrets: The NEXTJS_PREVIEW_SECRET should never be exposed to the client-side. It should only be used in your Next.js API route and server-side data fetching functions.

3. Exiting Preview Mode

You’ll need a way for editors to easily exit preview mode.

a. Clear Preview Cookies API Route

Create pages/api/exit-preview.js:

“`javascript

// pages/api/exit-preview.js

import { draftMode } from ‘next/headers’;

export default async function exit(req, res) {

res.setDraftMode({ enable: false });

res.redirect(‘/’); // Redirect to the homepage or the original post if you know it

}

“`

b. Adding an “Exit Preview” Link/Button

You can add a UI element to your Next.js site that links to /api/exit-preview. This is especially useful in the preview banner.

“`javascript

function PreviewBanner({ isEnabled }) {

if (!isEnabled) return null;

return (

You are in preview mode.{‘ ‘}

Exit preview

);

}

// Then in your layout or page:

//

“`

Method 2: Custom Preview Endpoints (Less Common with Next.js)

Before Next.js introduced Draft Mode, or if you’re not using Next.js, folks often rolled their own. This involves more manual cookie management and state handling.

1. WordPress: Generate Token and Redirect

This part is similar to Method 1, overriding the preview link with a secret. The key difference is that instead of activating Next.js’s native Draft Mode, you’d be setting your own cookie or state.

2. Next.js: Custom API Route to Set a Preview Cookie

Instead of res.setDraftMode, your API route would set a custom cookie like res.setHeader('Set-Cookie', 'my_preview_token=abc; Path=/; Max-Age=3600');.

3. Next.js: Read Cookie and Fetch Draft Data

On your content pages, you’d manually check for this custom cookie on each request (via getServerSideProps or getInitialProps) and, if present, use its token to fetch draft data.

This approach is more work and less integrated than Next.js’s Draft Mode, so I generally advise against it for Next.js projects unless you have very specific, complex requirements that Draft Mode can’t meet.

When working with a decoupled Next.js frontend, managing WordPress previews can be a bit challenging, but there are effective strategies to streamline the process. For a deeper understanding of how to implement these strategies, you might find it helpful to explore a related article that discusses payment integration in Next.js applications. This resource provides insights that can enhance your overall development experience. You can read more about it in this article on payment integration.

Method 3: Using WPGraphQL and wp-graphql-for-acf

If you’re using WPGraphQL, this approach offers a very clean way to handle previews. WPGraphQL natively supports preview as an argument for nodes, even without extra plugins for simple post/page data. For ACF content, you’ll typically need wp-graphql-for-acf.

1. WordPress: WPGraphQL Setup

Assuming you have WPGraphQL and wp-graphql-for-acf configured:

a. Customizing the Preview Link (still needed)

The WordPress filter preview_post_link will still be used as in Method 1 to redirect to your Next.js application’s API route. The secret and id (or slug) parameters are still crucial.

b. WPGraphQL for Drafts

WPGraphQL, when authenticated, can easily return drafts. The key is in how your Next.js app sends the request to WPGraphQL. For Next.js Draft Mode, the isEnabled flag means you can send a more privileged query to WPGraphQL.

2. Next.js: GraphQL Query with Preview Access

a. API Route (Same as Method 1)

Your pages/api/preview.js will remain largely the same, setting res.setDraftMode({ enable: true }) and redirecting.

b. Content Page with WPGraphQL Queries

In your [slug].js page, the GraphQL query itself will change when in draft mode.

“`javascript

// pages/[postType]/[slug].js

import { draftMode } from ‘next/headers’;

import { notFound } from ‘next/navigation’;

// Assume you have a GraphQL client setup, e.g., using ‘graphql-request’ or Apollo

async function fetchGraphQL(query, { variables = {}, headers = {} } = {}) {

const res = await fetch(${process.env.WORDPRESS_GRAPHQL_URL}, {

method: ‘POST’,

headers: {

‘Content-Type’: ‘application/json’,

// Add authentication header if your WPGraphQL requires it for drafts

// For instance, if using Application Passwords with wp-graphql-jwt-authentication

// ‘Authorization’: Bearer ${process.env.WP_GRAPHQL_AUTH_TOKEN},

…headers

},

body: JSON.stringify({

query,

variables,

}),

cache: ‘no-store’, // Crucial for drafts

});

const json = await res.json();

if (json.errors) {

console.error(json.errors);

throw new Error(‘Failed to fetch GraphQL data’);

}

return json.data;

}

export default async function Page({ params }) {

const { isEnabled } = draftMode();

const { postType, slug } = params;

// Define your base GraphQL query. For preview, we’ll alter it.

const query = `

query GetPostBySlug($slug: String!, $asPreview: Boolean = false, $id: ID) {

postBy(slug: $slug, id: $id, idType: DATABASE_ID, asPreview: $asPreview) {

id

databaseId

slug

title

date

content

status

If using ACF

acf {

someCustomField

}

}

}

`;

let postData;

try {

const data = await fetchGraphQL(query, {

variables: {

slug: slug,

asPreview: isEnabled, // This is the key for WPGraphQL!

// If your preview link passes ‘id’, you can use idType: DATABASE_ID for more reliable fetching

// id: isEnabled ? params.id : null,

},

// You might need to send a temporary token/cookie for WPGraphQL preview

// If your WPGraphQL setup uses JWT for previews, this header would be needed.

headers: isEnabled ? { ‘x-wp-nonce’: process.env.WP_PREVIEW_NONCE } : {} // Example with Nonce

});

postData = data.postBy;

} catch (error) {

console.error(“Error fetching post data:”, error);

notFound();

}

if (!postData) {

notFound();

}

return (

{isEnabled &&

PREVIEW MODE ACTIVE

}

{postData.title}

{//}

);

}

export async function generateStaticParams() {

// Fetch slugs for published posts for static generation.

// Drafts are handled dynamically.

const data = await fetchGraphQL(`

query AllPostsSlugs {

posts(first: 100) {

nodes {

slug

}

}

}

`);

return data.posts.nodes.map((post) => ({

postType: ‘posts’, // Or whatever your post type slug is

slug: post.slug,

}));

}

“`

WPGraphQL Specifics:

  • asPreview: Boolean: This is the powerful argument for postBy, pageBy, etc. When true, it tells WPGraphQL to fetch the latest revision, including drafts.
  • Authentication for asPreview: To use asPreview: true, your GraphQL request from Next.js (when in preview mode) must be authenticated with a WordPress nonce or a JWT token that has edit capabilities.
  • Nonce: You can generate a WordPress nonce on the server side and pass it as a header (e.g., x-wp-nonce). This nonce would need to be passed into your Next.js API route.
  • JWT (with wp-graphql-jwt-authentication): If you’re using this plugin, your Next.js backend can authenticate as a user with appropriate permissions and send a JWT token with the GraphQL request.
  • cache: 'no-store': Still essential for ensuring fresh draft data.

If you’re exploring ways to enhance your WordPress previews in a decoupled Next.js frontend, you might find it helpful to read a related article that discusses best practices for integrating headless CMS solutions with modern frameworks. This resource provides insights into optimizing content delivery and improving user experience. For more information, you can check out the article on best practices for headless CMS integration.

Common Pitfalls and Troubleshooting

Even with clear steps, things can go awry. Here are some common areas to check.

Secret Key Mismatches

  • Typo: Triple-check that the NEXTJS_PREVIEW_SECRET in your Next.js environment variables exactly matches the secret used in your WordPress functions.php.
  • Case Sensitivity: Secrets are usually case-sensitive.
  • Environment Setup: Ensure your NEXTJS_PREVIEW_SECRET is correctly set in your .env.local for development and in your hosting platform’s environment variables for production.

Caching Issues

  • Next.js Caching: Always use cache: 'no-store' when fetching draft content, especially in fetch calls.
  • CDN/Edge Caching: If you’re using a CDN (Vercel Edge, Cloudflare, etc.), make sure your preview routes and APIs are configured not to cache requests that involve draft content or the preview API route. The Cache-Control: no-store header should handle this correctly, but sometimes manual CDN rules are needed.
  • WordPress Object Caching: If you have object caching plugins (Redis, Memcached), ensure they don’t interfere with the live fetching of draft data.

Incorrect Redirects or Routing

  • Next.js Route: Double-check that the redirectPath in your pages/api/preview.js correctly maps to your Next.js content page’s dynamic route (e.g., /[postType]/[slug]).
  • WordPress Preview Link: Verify the nextjs_base_url in your custom_preview_link function in WordPress.

WordPress API Access

  • Permissions: If your custom REST endpoint or WPGraphQL queries aren’t returning drafts, it’s almost always a permissions issue. Ensure the permission_callback for your custom endpoint is correct, or that your WPGraphQL authentication is granting sufficient privileges (edit_posts, etc.).
  • Availability: Is your WordPress API publicly accessible to your Next.js application server? Check firewalls or other restrictions.

Mixed Content

  • If your Next.js site is HTTPS but your WordPress is HTTP, you might encounter mixed content warnings or blocked API requests in the browser console. Always use HTTPS for both.

Revisions vs. Drafts

  • Be clear about whether you want to preview the current draft or a specific revision. The asPreview argument in WPGraphQL typically targets the latest draft/revision. For REST API, you might need to query revisions directly if you need older ones.

Debugging

  • Browser Developer Tools: Watch the network requests when you click the WordPress preview button. Is it hitting your Next.js API route? What status codes are returned? What do the request and response bodies look like?
  • Next.js Console Logs: Add console.log statements in your Next.js API routes and getStaticProps/getServerSideProps to see what data is being received and processed.
  • WordPress Debug Log: Enable WP_DEBUG_LOG in wp-config.php to catch any PHP errors on the WordPress side.

By systematically working through these points, you should be able to get your WordPress previews working seamlessly with your Next.js frontend, giving your content editors the smooth experience they deserve. Good luck!