By Devport Team | Last updated: 2025-07-12 | 17 min read

How to Implement Preview Mode in a Decoupled WordPress Setup

One of the biggest challenges when moving to headless WordPress is maintaining the content preview experience that editors love. In traditional WordPress, clicking "Preview" shows exactly how content will appear. In a decoupled setup, this requires custom implementation to bridge the WordPress backend with your frontend application.

This guide provides a complete solution for implementing preview mode in headless WordPress, covering authentication, real-time updates, and integration with popular frameworks like Next.js, Gatsby, and Nuxt.js.

Table of Contents

  1. Understanding Preview Requirements
  2. Authentication and Security
  3. WordPress Backend Setup
  4. Frontend Implementation
  5. Real-time Preview Updates
  6. Framework-Specific Examples
  7. Troubleshooting Common Issues

Understanding Preview Requirements

The Preview Challenge

In headless WordPress, the preview button doesn't work out of the box because:

Traditional WordPress:
WordPress → PHP Theme → Preview

Headless WordPress:
WordPress → API → Frontend App → ???

Preview Mode Requirements

A complete preview solution needs:

  1. Authentication: Secure access to draft content
  2. Data Fetching: Retrieve unpublished content
  3. URL Handling: Redirect from WordPress to frontend
  4. Visual Accuracy: Match production appearance
  5. Real-time Updates: Optional live refresh
  6. User Experience: Seamless editor workflow

Preview Architecture

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│                 │     │                  │     │                 │
│   WordPress     │────▶│  Preview API     │────▶│   Frontend      │
│   Admin         │     │  (JWT Auth)      │     │   Preview       │
│                 │     │                  │     │                 │
└─────────────────┘     └──────────────────┘     └─────────────────┘
      │                          │                         │
      │ Click Preview           │ Validate Token         │ Render Draft
      │                         │ Fetch Draft Data       │ Show Preview
      └─────────────────────────┴─────────────────────────┘

Authentication and Security

JWT Authentication Setup

// wp-content/plugins/headless-preview/includes/jwt-auth.php
<?php
namespace HeadlessPreview;

class JWTAuth {
    private $secret_key;
    private $algorithm = 'HS256';

    public function __construct() {
        $this->secret_key = defined('JWT_AUTH_SECRET_KEY') 
            ? JWT_AUTH_SECRET_KEY 
            : wp_generate_password(64, true, true);
    }

    public function generate_token($user_id, $post_id, $expiry = 3600) {
        $issued_at = time();
        $expire_at = $issued_at + $expiry;

        $payload = [
            'iss' => get_bloginfo('url'),
            'iat' => $issued_at,
            'exp' => $expire_at,
            'user_id' => $user_id,
            'post_id' => $post_id,
            'type' => 'preview',
            'nonce' => wp_create_nonce('preview_' . $post_id)
        ];

        return $this->encode($payload);
    }

    public function validate_token($token) {
        try {
            $decoded = $this->decode($token);

            // Verify nonce
            if (!wp_verify_nonce($decoded->nonce, 'preview_' . $decoded->post_id)) {
                return new \WP_Error('invalid_nonce', 'Invalid preview nonce');
            }

            // Check expiration
            if ($decoded->exp < time()) {
                return new \WP_Error('token_expired', 'Preview token has expired');
            }

            // Verify user permissions
            if (!user_can($decoded->user_id, 'edit_post', $decoded->post_id)) {
                return new \WP_Error('insufficient_permissions', 'User cannot preview this post');
            }

            return $decoded;
        } catch (\Exception $e) {
            return new \WP_Error('invalid_token', $e->getMessage());
        }
    }

    private function encode($payload) {
        return \Firebase\JWT\JWT::encode($payload, $this->secret_key, $this->algorithm);
    }

    private function decode($token) {
        return \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($this->secret_key, $this->algorithm));
    }
}

Secure Preview Endpoint

// wp-content/plugins/headless-preview/includes/preview-endpoint.php
<?php
namespace HeadlessPreview;

class PreviewEndpoint {
    private $jwt_auth;

    public function __construct() {
        $this->jwt_auth = new JWTAuth();
        add_action('rest_api_init', [$this, 'register_routes']);
    }

    public function register_routes() {
        register_rest_route('preview/v1', '/post/(?P<id>\d+)', [
            'methods' => 'GET',
            'callback' => [$this, 'get_preview_data'],
            'permission_callback' => [$this, 'check_preview_permissions'],
            'args' => [
                'id' => [
                    'validate_callback' => function($param) {
                        return is_numeric($param);
                    }
                ],
                'token' => [
                    'required' => true,
                    'validate_callback' => function($param) {
                        return is_string($param);
                    }
                ]
            ]
        ]);
    }

    public function check_preview_permissions($request) {
        $token = $request->get_param('token');
        $validation = $this->jwt_auth->validate_token($token);

        if (is_wp_error($validation)) {
            return $validation;
        }

        // Store decoded token for use in callback
        $request->set_param('decoded_token', $validation);

        return true;
    }

    public function get_preview_data($request) {
        $post_id = $request->get_param('id');
        $decoded_token = $request->get_param('decoded_token');

        // Verify token matches requested post
        if ($decoded_token->post_id != $post_id) {
            return new \WP_Error('invalid_post', 'Token does not match requested post');
        }

        // Get the latest revision or autosave
        $preview_id = $this->get_preview_id($post_id, $decoded_token->user_id);

        if (!$preview_id) {
            return new \WP_Error('no_preview', 'No preview available');
        }

        // Fetch post data
        $post = get_post($preview_id);

        if (!$post) {
            return new \WP_Error('post_not_found', 'Preview post not found');
        }

        // Prepare response
        return $this->prepare_preview_response($post, $post_id);
    }

    private function get_preview_id($post_id, $user_id) {
        // Check for autosave
        $autosave = wp_get_post_autosave($post_id, $user_id);
        if ($autosave) {
            return $autosave->ID;
        }

        // Check for latest revision
        $revisions = wp_get_post_revisions($post_id, [
            'posts_per_page' => 1,
            'orderby' => 'date',
            'order' => 'DESC'
        ]);

        if (!empty($revisions)) {
            return array_values($revisions)[0]->ID;
        }

        // Fall back to the post itself
        return $post_id;
    }

    private function prepare_preview_response($post, $original_id) {
        // Get post meta
        $meta = get_post_meta($original_id);

        // Get featured image
        $featured_image = null;
        if (has_post_thumbnail($original_id)) {
            $featured_image = [
                'id' => get_post_thumbnail_id($original_id),
                'url' => get_the_post_thumbnail_url($original_id, 'full'),
                'alt' => get_post_meta(get_post_thumbnail_id($original_id), '_wp_attachment_image_alt', true)
            ];
        }

        // Get categories and tags
        $categories = wp_get_post_categories($original_id, ['fields' => 'all']);
        $tags = wp_get_post_tags($original_id, ['fields' => 'all']);

        return [
            'id' => $original_id,
            'title' => $post->post_title,
            'content' => apply_filters('the_content', $post->post_content),
            'excerpt' => $post->post_excerpt,
            'slug' => $post->post_name,
            'status' => $post->post_status,
            'type' => $post->post_type,
            'date' => $post->post_date,
            'modified' => $post->post_modified,
            'author' => [
                'id' => $post->post_author,
                'name' => get_the_author_meta('display_name', $post->post_author)
            ],
            'featured_image' => $featured_image,
            'categories' => $categories,
            'tags' => $tags,
            'meta' => $meta,
            'is_preview' => true
        ];
    }
}

WordPress Backend Setup

Custom Preview Button

// wp-content/plugins/headless-preview/includes/preview-button.php
<?php
namespace HeadlessPreview;

class PreviewButton {
    private $jwt_auth;
    private $frontend_url;

    public function __construct() {
        $this->jwt_auth = new JWTAuth();
        $this->frontend_url = defined('FRONTEND_URL') ? FRONTEND_URL : 'http://localhost:3000';

        add_filter('preview_post_link', [$this, 'modify_preview_link'], 10, 2);
        add_action('admin_footer', [$this, 'add_preview_script']);
    }

    public function modify_preview_link($preview_link, $post) {
        if (!current_user_can('edit_post', $post->ID)) {
            return $preview_link;
        }

        // Generate preview token
        $token = $this->jwt_auth->generate_token(
            get_current_user_id(),
            $post->ID,
            HOUR_IN_SECONDS
        );

        // Build frontend preview URL
        $preview_url = add_query_arg([
            'preview' => 'true',
            'token' => $token,
            'id' => $post->ID,
            'type' => $post->post_type
        ], $this->frontend_url . '/api/preview');

        return $preview_url;
    }

    public function add_preview_script() {
        global $post;

        if (!$post || !current_user_can('edit_post', $post->ID)) {
            return;
        }
        ?>
        <script>
        jQuery(document).ready(function($) {
            // Enhance preview button behavior
            $('#post-preview').on('click', function(e) {
                e.preventDefault();

                var $button = $(this);
                var originalText = $button.text();

                // Show loading state
                $button.text('Generating preview...');

                // Save draft first
                wp.autosave.server.triggerSave();

                // Wait for autosave to complete
                setTimeout(function() {
                    // Open preview in new tab
                    window.open($button.attr('href'), 'wp-preview-<?php echo $post->ID; ?>');
                    $button.text(originalText);
                }, 1000);
            });

            // Add live preview button
            var $livePreviewBtn = $('<a>')
                .attr('href', '#')
                .attr('class', 'button')
                .text('Live Preview')
                .css('margin-left', '5px');

            $('#minor-publishing-actions').append($livePreviewBtn);

            $livePreviewBtn.on('click', function(e) {
                e.preventDefault();
                openLivePreview();
            });
        });

        function openLivePreview() {
            // Implementation for live preview modal
            var previewUrl = '<?php echo $this->get_live_preview_url($post); ?>';

            // Create modal or sidebar preview
            var modal = `
                <div id="live-preview-modal" style="position: fixed; top: 0; right: 0; width: 50%; height: 100%; background: white; z-index: 100000; box-shadow: -2px 0 5px rgba(0,0,0,0.1);">
                    <div style="padding: 10px; background: #f1f1f1; display: flex; justify-content: space-between;">
                        <h3>Live Preview</h3>
                        <button onclick="closeLivePreview()">Close</button>
                    </div>
                    <iframe src="${previewUrl}" style="width: 100%; height: calc(100% - 50px); border: none;"></iframe>
                </div>
            `;

            jQuery('body').append(modal);
        }

        function closeLivePreview() {
            jQuery('#live-preview-modal').remove();
        }
        </script>
        <?php
    }

    private function get_live_preview_url($post) {
        $token = $this->jwt_auth->generate_token(
            get_current_user_id(),
            $post->ID,
            HOUR_IN_SECONDS * 4 // Longer expiry for live preview
        );

        return add_query_arg([
            'preview' => 'true',
            'token' => $token,
            'id' => $post->ID,
            'live' => 'true'
        ], $this->frontend_url . '/preview/' . $post->post_name);
    }
}

GraphQL Preview Support

// wp-content/plugins/headless-preview/includes/graphql-preview.php
<?php
namespace HeadlessPreview;

class GraphQLPreview {
    private $jwt_auth;

    public function __construct() {
        $this->jwt_auth = new JWTAuth();

        add_action('graphql_register_types', [$this, 'register_preview_fields']);
        add_filter('graphql_post_object_connection_query_args', [$this, 'filter_preview_args'], 10, 2);
    }

    public function register_preview_fields() {
        register_graphql_field('RootQuery', 'previewPost', [
            'type' => 'Post',
            'description' => 'Get a preview of a post',
            'args' => [
                'id' => [
                    'type' => ['non_null' => 'ID'],
                    'description' => 'The ID of the post to preview'
                ],
                'token' => [
                    'type' => ['non_null' => 'String'],
                    'description' => 'Preview authentication token'
                ]
            ],
            'resolve' => function($root, $args, $context, $info) {
                // Validate token
                $validation = $this->jwt_auth->validate_token($args['token']);

                if (is_wp_error($validation)) {
                    throw new \GraphQL\Error\UserError($validation->get_error_message());
                }

                // Get post ID from global ID
                $id_parts = \GraphQLRelay\Relay::fromGlobalId($args['id']);
                $post_id = absint($id_parts['id']);

                // Verify token matches post
                if ($validation->post_id != $post_id) {
                    throw new \GraphQL\Error\UserError('Token does not match requested post');
                }

                // Get preview post
                $preview_id = $this->get_preview_id($post_id, $validation->user_id);
                $post = get_post($preview_id);

                if (!$post) {
                    return null;
                }

                return new \WPGraphQL\Model\Post($post);
            }
        ]);
    }

    private function get_preview_id($post_id, $user_id) {
        $autosave = wp_get_post_autosave($post_id, $user_id);
        if ($autosave) {
            return $autosave->ID;
        }

        $revisions = wp_get_post_revisions($post_id, [
            'posts_per_page' => 1,
            'orderby' => 'date',
            'order' => 'DESC'
        ]);

        if (!empty($revisions)) {
            return array_values($revisions)[0]->ID;
        }

        return $post_id;
    }
}

Frontend Implementation

Preview API Route Handler

// pages/api/preview.ts (Next.js)
import { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    // Check for required parameters
    const { token, id, type = 'post' } = req.query;

    if (!token || !id) {
        return res.status(400).json({ 
            error: 'Missing required parameters' 
        });
    }

    try {
        // Validate token with WordPress
        const validation = await validatePreviewToken(token as string);

        if (!validation.valid) {
            return res.status(401).json({ 
                error: 'Invalid preview token' 
            });
        }

        // Enable preview mode
        res.setPreviewData({
            token,
            id,
            type,
            expires: validation.expires
        });

        // Redirect to the appropriate page
        const redirectUrl = getRedirectUrl(type as string, id as string);
        res.redirect(redirectUrl);
    } catch (error) {
        console.error('Preview error:', error);
        res.status(500).json({ 
            error: 'Failed to generate preview' 
        });
    }
}

async function validatePreviewToken(token: string): Promise<{
    valid: boolean;
    expires?: number;
}> {
    try {
        const response = await fetch(
            `${process.env.WORDPRESS_API_URL}/wp-json/preview/v1/validate`,
            {
                headers: {
                    'Authorization': `Bearer ${token}`
                }
            }
        );

        if (!response.ok) {
            return { valid: false };
        }

        const data = await response.json();
        return {
            valid: true,
            expires: data.exp
        };
    } catch (error) {
        return { valid: false };
    }
}

function getRedirectUrl(type: string, id: string): string {
    const routes: Record<string, (id: string) => string> = {
        post: (id) => `/blog/preview/${id}`,
        page: (id) => `/preview/${id}`,
        product: (id) => `/products/preview/${id}`
    };

    const routeGenerator = routes[type] || routes.post;
    return routeGenerator(id);
}

Preview Page Component

// pages/blog/preview/[id].tsx
import { GetServerSideProps } from 'next';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Post } from '@/types';
import { fetchPreviewPost } from '@/lib/api';
import PostLayout from '@/components/PostLayout';
import PreviewBar from '@/components/PreviewBar';

interface PreviewPageProps {
    post: Post | null;
    error?: string;
}

export default function PreviewPage({ post, error }: PreviewPageProps) {
    const router = useRouter();
    const [isRefreshing, setIsRefreshing] = useState(false);

    // Auto-refresh preview
    useEffect(() => {
        if (!post) return;

        const interval = setInterval(async () => {
            setIsRefreshing(true);
            try {
                const updated = await fetchPreviewPost(
                    post.id,
                    router.query.token as string
                );

                if (updated) {
                    router.replace(router.asPath);
                }
            } catch (error) {
                console.error('Failed to refresh preview:', error);
            } finally {
                setIsRefreshing(false);
            }
        }, 5000); // Refresh every 5 seconds

        return () => clearInterval(interval);
    }, [post, router]);

    if (error) {
        return (
            <div className="error-container">
                <h1>Preview Error</h1>
                <p>{error}</p>
                <button onClick={() => router.push('/')}>
                    Return Home
                </button>
            </div>
        );
    }

    if (!post) {
        return <div>Loading preview...</div>;
    }

    return (
        <>
            <PreviewBar 
                isRefreshing={isRefreshing}
                onExit={() => router.push('/')}
            />
            <PostLayout post={post} isPreview={true} />
        </>
    );
}

export const getServerSideProps: GetServerSideProps = async (context) => {
    const { id } = context.params!;
    const previewData = context.previewData as {
        token: string;
        id: string;
    };

    // Check if we're in preview mode
    if (!previewData || !previewData.token) {
        return {
            redirect: {
                destination: '/',
                permanent: false
            }
        };
    }

    try {
        const post = await fetchPreviewPost(
            id as string,
            previewData.token
        );

        if (!post) {
            return {
                props: {
                    post: null,
                    error: 'Post not found'
                }
            };
        }

        return {
            props: {
                post
            }
        };
    } catch (error) {
        console.error('Preview fetch error:', error);
        return {
            props: {
                post: null,
                error: 'Failed to load preview'
            }
        };
    }
};

Preview Bar Component

// components/PreviewBar.tsx
import { useState } from 'react';
import styles from './PreviewBar.module.css';

interface PreviewBarProps {
    isRefreshing: boolean;
    onExit: () => void;
}

export default function PreviewBar({ isRefreshing, onExit }: PreviewBarProps) {
    const [showDevices, setShowDevices] = useState(false);
    const [currentDevice, setCurrentDevice] = useState('desktop');

    const devices = {
        desktop: { width: '100%', label: 'Desktop' },
        tablet: { width: '768px', label: 'Tablet' },
        mobile: { width: '375px', label: 'Mobile' }
    };

    return (
        <div className={styles.previewBar}>
            <div className={styles.left}>
                <span className={styles.badge}>Preview Mode</span>
                {isRefreshing && (
                    <span className={styles.refreshing}>
                        <span className={styles.spinner} />
                        Refreshing...
                    </span>
                )}
            </div>

            <div className={styles.center}>
                <button
                    onClick={() => setShowDevices(!showDevices)}
                    className={styles.deviceToggle}
                >
                    {devices[currentDevice].label}
                </button>

                {showDevices && (
                    <div className={styles.deviceMenu}>
                        {Object.entries(devices).map(([key, device]) => (
                            <button
                                key={key}
                                onClick={() => {
                                    setCurrentDevice(key);
                                    setShowDevices(false);
                                    updateViewport(device.width);
                                }}
                                className={styles.deviceOption}
                            >
                                {device.label}
                            </button>
                        ))}
                    </div>
                )}
            </div>

            <div className={styles.right}>
                <button 
                    onClick={onExit}
                    className={styles.exitButton}
                >
                    Exit Preview
                </button>
            </div>
        </div>
    );
}

function updateViewport(width: string) {
    const content = document.querySelector('main');
    if (content) {
        content.style.maxWidth = width;
        content.style.margin = '0 auto';
    }
}

Real-time Preview Updates

WebSocket Preview Server

// preview-server.js
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const redis = require('redis');

class PreviewServer {
    constructor() {
        this.wss = new WebSocket.Server({ port: 8080 });
        this.redis = redis.createClient();
        this.connections = new Map();

        this.setupWebSocket();
        this.setupWordPressHooks();
    }

    setupWebSocket() {
        this.wss.on('connection', (ws, req) => {
            const token = this.extractToken(req.url);

            if (!token) {
                ws.close(1008, 'Missing token');
                return;
            }

            // Validate token
            const decoded = this.validateToken(token);
            if (!decoded) {
                ws.close(1008, 'Invalid token');
                return;
            }

            // Store connection
            const connectionId = `${decoded.user_id}-${decoded.post_id}`;
            this.connections.set(connectionId, ws);

            ws.on('close', () => {
                this.connections.delete(connectionId);
            });

            // Send initial data
            this.sendPreviewData(ws, decoded.post_id);
        });
    }

    setupWordPressHooks() {
        // Subscribe to WordPress save events via Redis
        this.redis.subscribe('wordpress_post_save');

        this.redis.on('message', (channel, message) => {
            const data = JSON.parse(message);
            this.handlePostUpdate(data);
        });
    }

    handlePostUpdate(data) {
        const { post_id, user_id, content } = data;
        const connectionId = `${user_id}-${post_id}`;
        const ws = this.connections.get(connectionId);

        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({
                type: 'update',
                data: {
                    post_id,
                    content,
                    timestamp: Date.now()
                }
            }));
        }
    }

    async sendPreviewData(ws, postId) {
        try {
            const data = await this.fetchPreviewData(postId);

            ws.send(JSON.stringify({
                type: 'initial',
                data
            }));
        } catch (error) {
            ws.send(JSON.stringify({
                type: 'error',
                error: error.message
            }));
        }
    }

    validateToken(token) {
        try {
            return jwt.verify(token, process.env.JWT_SECRET);
        } catch (error) {
            return null;
        }
    }

    extractToken(url) {
        const match = url.match(/token=([^&]+)/);
        return match ? match[1] : null;
    }
}

// Start server
new PreviewServer();

Real-time Preview Hook

// hooks/useRealtimePreview.ts
import { useEffect, useState, useRef } from 'react';
import { Post } from '@/types';

interface UseRealtimePreviewOptions {
    postId: string;
    token: string;
    onUpdate?: (post: Post) => void;
}

export function useRealtimePreview({
    postId,
    token,
    onUpdate
}: UseRealtimePreviewOptions) {
    const [isConnected, setIsConnected] = useState(false);
    const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
    const wsRef = useRef<WebSocket | null>(null);

    useEffect(() => {
        const ws = new WebSocket(
            `${process.env.NEXT_PUBLIC_WS_URL}?token=${token}`
        );

        ws.onopen = () => {
            setIsConnected(true);
            console.log('Preview WebSocket connected');
        };

        ws.onmessage = (event) => {
            const message = JSON.parse(event.data);

            switch (message.type) {
                case 'update':
                    handleUpdate(message.data);
                    break;
                case 'error':
                    console.error('Preview error:', message.error);
                    break;
            }
        };

        ws.onclose = () => {
            setIsConnected(false);
            console.log('Preview WebSocket disconnected');
        };

        wsRef.current = ws;

        return () => {
            ws.close();
        };
    }, [postId, token]);

    const handleUpdate = (data: any) => {
        setLastUpdate(new Date());

        if (onUpdate) {
            onUpdate(data);
        }
    };

    const refresh = () => {
        if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
            wsRef.current.send(JSON.stringify({
                type: 'refresh',
                postId
            }));
        }
    };

    return {
        isConnected,
        lastUpdate,
        refresh
    };
}

Framework-Specific Examples

Gatsby Preview Implementation

// gatsby-node.js
exports.createPages = async ({ actions }) => {
    const { createPage } = actions;

    // Create preview page
    createPage({
        path: '/preview',
        component: path.resolve('./src/templates/preview.js'),
        context: {
            isPreview: true
        }
    });
};

// src/templates/preview.js
import React, { useEffect, useState } from 'react';
import { navigate } from 'gatsby';
import PostTemplate from './post';

const PreviewTemplate = ({ location }) => {
    const [post, setPost] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        const params = new URLSearchParams(location.search);
        const token = params.get('token');
        const id = params.get('id');

        if (!token || !id) {
            navigate('/');
            return;
        }

        fetchPreview(id, token);
    }, [location]);

    const fetchPreview = async (id, token) => {
        try {
            const response = await fetch(
                `${process.env.GATSBY_WORDPRESS_URL}/wp-json/preview/v1/post/${id}?token=${token}`
            );

            if (!response.ok) {
                throw new Error('Failed to fetch preview');
            }

            const data = await response.json();
            setPost(data);
        } catch (error) {
            setError(error.message);
        } finally {
            setLoading(false);
        }
    };

    if (loading) return <div>Loading preview...</div>;
    if (error) return <div>Error: {error}</div>;
    if (!post) return <div>No preview available</div>;

    return <PostTemplate pageContext={{ post }} isPreview />;
};

export default PreviewTemplate;

Nuxt.js Preview Mode

// plugins/preview.js
export default function ({ query, enablePreview, $config }) {
    if (query.preview && query.token) {
        enablePreview({
            token: query.token,
            id: query.id
        });
    }
}

// pages/preview/_slug.vue
<template>
    <div>
        <PreviewBar v-if="$nuxt.isPreview" @exit="exitPreview" />
        <ArticleLayout :article="article" />
    </div>
</template>

<script>
export default {
    async asyncData({ $axios, params, $preview }) {
        if (!$preview || !$preview.token) {
            throw new Error('No preview data');
        }

        const { data } = await $axios.get(
            `/preview/v1/post/${$preview.id}`,
            {
                headers: {
                    Authorization: `Bearer ${$preview.token}`
                }
            }
        );

        return {
            article: data
        };
    },

    methods: {
        exitPreview() {
            this.$router.push('/');
        }
    }
}
</script>

Troubleshooting Common Issues

Preview Token Issues

// Debug token validation
function debugToken(token) {
    try {
        // Decode without verification to inspect
        const decoded = jwt.decode(token);

        console.log('Token payload:', decoded);
        console.log('Expires at:', new Date(decoded.exp * 1000));
        console.log('Issued at:', new Date(decoded.iat * 1000));

        // Check if expired
        if (decoded.exp < Date.now() / 1000) {
            console.error('Token is expired');
        }

        return decoded;
    } catch (error) {
        console.error('Invalid token format:', error);
        return null;
    }
}

CORS Configuration

// WordPress CORS headers for preview
add_action('rest_api_init', function() {
    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');

    add_filter('rest_pre_serve_request', function($value) {
        $origin = get_http_origin();

        // Allow specific frontend origins
        $allowed_origins = [
            'http://localhost:3000',
            'https://preview.yoursite.com',
            FRONTEND_URL
        ];

        if (in_array($origin, $allowed_origins)) {
            header('Access-Control-Allow-Origin: ' . $origin);
            header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
            header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
            header('Access-Control-Allow-Credentials: true');
        }

        return $value;
    });
}, 15);

Preview Data Caching

// Prevent preview data from being cached
export function configurePreviewCache(res) {
    res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
    res.setHeader('Pragma', 'no-cache');
    res.setHeader('Expires', '0');
    res.setHeader('Surrogate-Control', 'no-store');
}

Best Practices

Security Checklist

## Preview Security Checklist

- [ ] Use short-lived JWT tokens (1-4 hours)
- [ ] Validate user permissions on every request
- [ ] Implement rate limiting on preview endpoints
- [ ] Use HTTPS for all preview URLs
- [ ] Validate token matches requested content
- [ ] Log preview access for auditing
- [ ] Implement CORS properly
- [ ] Sanitize preview content before rendering

Performance Optimization

// Optimize preview loading
const PreviewOptimizations = {
    // Lazy load non-critical assets
    lazyLoadImages: true,

    // Skip analytics in preview mode
    disableAnalytics: true,

    // Reduce API calls
    cachePreviewData: {
        ttl: 60, // 1 minute
        storage: 'sessionStorage'
    },

    // Debounce real-time updates
    updateDebounce: 500
};

Conclusion

Implementing preview mode in headless WordPress requires careful coordination between the backend and frontend, but the result is a seamless content editing experience. By following this guide, you can provide editors with instant, accurate previews while maintaining security and performance.

Key takeaways: - JWT tokens provide secure, stateless authentication - Real-time updates enhance the preview experience - Framework-specific implementations share common patterns - Security and performance must be balanced - A good preview system makes headless WordPress viable for content teams

The effort invested in a robust preview system pays dividends in editor satisfaction and content quality.


Explore more headless WordPress patterns in our comprehensive Headless WordPress Guide.