Category: Headless Nextjs

  • Headless WordPress SEO Guide: Fixing Yoast Schema & URLs for Astro/Next.js

    Headless WordPress SEO Guide: Fixing Yoast Schema & URLs for Astro/Next.js

    Quick Answer: How to Fix Yoast SEO for Headless WordPress?

    Yoast SEO breaks on headless sites because it assumes the CMS and Frontend are on the same domain. To fix this, you must:

    1. Rewrite URLs: Filter the REST API response to swap cms.yoursite.com with yoursite.com.
    2. Fix Canonical Tags: Manually inject the correct frontend URL into the head tags.
    3. Patch Word Counts: Force Yoast to count words from ACF fields, or Schema will show “0 words.”

    Jump to the Copy-Paste Code Solution ↓

    You built a blazing-fast Headless WordPress site with Astro or Next.js. The Lighthouse score is 100. The design is pixel-perfect.

    But when you check Google Search Console, it’s a disaster.

    Your canonical tags are pointing to the wrong domain (cms.yoursite.com instead of yoursite.com). Your schema data says “0 Words” because the content lives in ACF blocks, not the classic editor. And your internal links are leaking authority to your backend staging site.

    In this guide, I’m sharing the exact “Headless SEO Suite” code I use on production sites to force Yoast to behave. This PHP snippet fixes the Schema, rewrites the URLs, and patches the Word Count bug automatically.


    Why Traditional Plugins Fail in Headless SEO

    The core issue is architectural mismatch. WordPress plugins like Yoast SEO, RankMath, and All in One SEO were built for the monolithic era—where the database, admin panel, and frontend theme all live on the same server and domain.

    In a headless setup, you decouple the frontend (Astro, Next.js, Nuxt) from the backend (WordPress). This creates three major SEO gaps:

    1. Domain Confusion: The plugin generates sitemaps, canonicals, and OpenGraph tags using the backend URL (e.g., admin.mysite.com). If search engines index these, they ignore your actual frontend, leading to duplicate content issues or indexing your login page.
    2. Incomplete Schema Data: Standard SEO plugins scrape the the_content() loop to calculate word counts and “Time to Read” estimates. Since headless sites often use Advanced Custom Fields (ACF) or Gutenberg blocks served via API, the plugin sees an empty post. The result? wordCount: 0 in your schema, telling Google your detailed guide is “thin content.”
    3. Preview & Drafts Break: Without special configuration, the “Preview” button in WordPress tries to load the backend theme (which doesn’t exist) instead of your headless frontend draft.

    Why Go Headless Despite the SEO Challenges?

    If fixing SEO requires custom code, is Headless WordPress worth it? For many enterprise and performance-focused sites, the answer is a resounding yes.

    • Unmatched Performance: By pre-rendering content with static site generators (SSG) like Astro, you serve pure HTML/CSS on the edge. This leads to near-instant load times and Core Web Vitals scores that traditional WordPress struggles to hit without heavy caching plugins.
    • Enhanced Security: Your WordPress database and admin panel are hidden from the public internet. Hackers attacking your frontend URL hit a static CDN, not your SQL database. This drastically reduces the attack surface for common WordPress vulnerabilities.
    • Developer Experience: Frontend teams can use modern JavaScript frameworks (React, Vue, Svelte) and component-based architectures without being tied to PHP templates.

    The goal is to get these benefits without sacrificing the robust SEO tools that content editors love in WordPress. That’s where our patch comes in.


    Key Takeaways

    • The Problem: Yoast SEO defaults to the CMS domain for canonicals and schema, confusing Google.
    • The “0 Word” Bug: If you use ACF or Blocks, Yoast often reports empty content, hurting your E-E-A-T score.
    • The Solution: You don’t need a heavy plugin. A single functions.php filter can intercept and rewrite the Yoast REST API response before it hits your frontend.

    The Code: Headless SEO Patcher

    This script hooks into the WordPress REST API. It grabs the data Yoast is about to send to your Astro/Next.js frontend and “sanitizes” it. It fixes the domain, the canonical tag, and the word count calculation in real-time.

    Instructions: Add this to your theme’s functions.php file or a custom plugin.

    
    /**
     * HEADLESS WORDPRESS SEO SUITE (Final Production Version)
     * Author: MD Pabel
     * 1. Schema Generator: Forces "TechArticle" & Author data for ALL content.
     * 2. URL Fixer: Rewrites CMS URLs to Frontend URLs.
     * 3. Data Patcher: Fixes Word Count & Canonical Tags for Headless.
     */
    
    // ==============================================================================
    // PART 1: SCHEMA CONFIGURATION (The "Expert" Force)
    // ==============================================================================
    
    // 1. Override Yoast Settings (Forces TechArticle for EVERYTHING)
    add_filter( 'option_wpseo_titles', function( $options ) {
        // Custom Types
        $options['schema-article-type-case-study'] = 'TechArticle';
        $options['schema-article-type-malware-log'] = 'TechArticle';
        $options['schema-article-type-guide']       = 'TechArticle';
        
        // Force Standard Blog Posts to be TechArticle too
        $options['schema-article-type-post']        = 'TechArticle'; 
        $options['schema-page-type-post']           = 'WebPage'; 
    
        return $options;
    });
    
    // 2. Register Capabilities (Ensure 'post' is included)
    add_filter( 'wpseo_schema_article_post_types', function( $post_types ) {
        return array_merge( $post_types, ['post', 'case-study', 'malware-log', 'guide'] );
    });
    
    // 3. Force the "Article" Graph Piece (Fixes "WebPage" default)
    add_filter( 'wpseo_schema_needs_article', '__return_true' );
    
    // 4. Force the "Author" Graph Piece (Ensures Profile appears)
    add_filter( 'wpseo_schema_needs_profile', '__return_true' );
    
    // 5. ✅ NEW: Fix "8 Word" Count Issue (Scans ACF/Meta if content is empty)
    add_filter( 'wpseo_schema_article', function( $data, $context ) {
        // If Yoast detects < 50 words, it likely missed the ACF content. Let's recount.
        if ( !isset($data['wordCount']) || $data['wordCount'] < 50 ) { $post = $context->presentation->source;
            
            // Start with standard content
            $raw_text = $post->post_content;
    
            // Only proceed if we actually have a post object with an ID
            if ( ! $post || ! isset( $post->ID ) ) {
                return $data;
            }
    
            // Brute Force: Append all text-based Custom Fields (ACF) to the count
            $meta = get_post_meta($post->ID);
            foreach($meta as $key => $values) {
                // Skip hidden WP internal keys (start with _)
                if ( substr($key, 0, 1) === '_' ) continue;
                
                foreach($values as $val) {
                    // If it looks like a sentence, add it to our text bucket
                    if ( is_string($val) && strlen($val) > 3 ) {
                        $raw_text .= ' ' . $val;
                    }
                }
            }
    
            // Strip HTML tags and count words
            $clean_text = strip_tags($raw_text);
            $real_count = str_word_count($clean_text);
    
            // Update Schema Data
            $data['wordCount'] = $real_count;
            
            // Update TimeToRead (Avg 200 words/min) - Format: PTxM (ISO 8601)
            $minutes = max(1, ceil($real_count / 200)); 
            $data['timeRequired'] = "PT{$minutes}M"; 
        }
        return $data;
    }, 10, 2 );
    
    
    // ==============================================================================
    // PART 2: URL REWRITER (The Sanitizer)
    // ==============================================================================
    
    function mdpabel_headless_seo_rewriter($response, $post, $request) {
        if (!isset($response->data['yoast_head'])) {
            return $response;
        }
    
        // --- CONFIGURATION ---
        $cms_domain      = 'cms.mdpabel.com'; 
        $frontend_domain = 'https://www.mdpabel.com';
    
        // --- FOLDER MAPPING ---
        $folder = '/blog'; 
        if ( isset($post->post_type) ) {
            if ($post->post_type === 'case-study')  $folder = '/case-studies';
            if ($post->post_type === 'malware-log') $folder = '/malware-log';
            if ($post->post_type === 'guide')       $folder = '/guides';
        }
    
        $head = $response->data['yoast_head'];
    
        // 1. Force Indexing
        $head = str_replace('content="noindex', 'content="index', $head);
    
        // 2. Fix Author Link
        $head = str_replace(
            'content="https://www.facebook.com/in/mdpabe1"', 
            'content="' . $frontend_domain . '/author/mdpabel/"', 
            $head
        );
    
        // 3. ✅ FORCE CANONICAL (The Fix)
        // First, REMOVE any existing canonical (even if it points to CMS)
        $head = preg_replace('//i', '', $head);
        
        // Second, CREATE the correct one
        $canonical_url = $frontend_domain . $folder . '/' . $post->post_name . '/';
        
        // Third, PREPEND it to the top (so it is the first thing bots see)
        $head = '' . "\n" . $head;
    
        // 4. Main Domain Rewrite Loop
        $pattern = '/https?:\/\/' . preg_quote($cms_domain, '/') . '(\/[^"\'\s<]*)?/'; $head = preg_replace_callback($pattern, function($matches) use ($frontend_domain, $folder, $post) { $full_url = $matches[0]; $path = isset($matches[1]) ? $matches[1] : ''; // Skip Assets if (strpos($path, '/wp-content/') !== false || strpos($path, '/wp-includes/') !== false || preg_match('/\.(xml|xsl|json)$/', $path)) { return $full_url; } // Rewrite Logic if (strpos($path, $post->post_name) !== false) return $frontend_domain . $folder . '/' . $post->post_name . '/';
            if (strpos($path, '/author/') !== false) return $frontend_domain . $path;
            if (strpos($path, '?s=') !== false) return $frontend_domain . '/blog' . $path;
    
            return $frontend_domain . $path;
    
        }, $head);
    
        $response->data['yoast_head'] = $head;
        return $response;
    }
    
    // Apply Rewriter to All Content Types
    add_filter('rest_prepare_post', 'mdpabel_headless_seo_rewriter', 10, 3);
    add_filter('rest_prepare_page', 'mdpabel_headless_seo_rewriter', 10, 3);
    add_filter('rest_prepare_case-study', 'mdpabel_headless_seo_rewriter', 10, 3);
    add_filter('rest_prepare_malware-log', 'mdpabel_headless_seo_rewriter', 10, 3);
    add_filter('rest_prepare_guide', 'mdpabel_headless_seo_rewriter', 10, 3);
    

    FAQ: Headless WordPress SEO

    Does Yoast SEO work with Headless WordPress?

    Not out of the box. Yoast assumes your CMS and frontend are on the same domain. To make it work, you need to use the REST API to fetch the SEO data and then manually rewrite the URLs (Canonical, OpenGraph, Schema) to match your frontend domain.

    Why does my Headless WordPress site have “0 words” in Schema?

    This happens because Yoast counts words in the standard WordPress editor. If you build pages using ACF (Advanced Custom Fields) or decoupled blocks, Yoast can’t “see” the content. You need a custom PHP filter to count ACF fields and update the Schema.

    Should I use a “Headless SEO” plugin?

    For simple sites, yes. But for complex sites using Astro or Next.js, manual code (like the snippet above) is better because it gives you 100% control over the output and doesn’t add bloat to your API responses.

    Need help implementing this? If you are struggling with a complex Headless WordPress setup or dealing with SEO data leaks, hire me to audit your architecture. I specialize in fixing the technical debt that plugins leave behind.

  • Boost Speed, SEO & Security with Headless WordPress and Next.js

    Boost Speed, SEO & Security with Headless WordPress and Next.js

    TL;DR: Pairing WordPress (as a headless CMS) with Next.js (as your frontend) gives you blistering performance, tighter security, and first-class SEO. You’ll ship faster pages (Core Web Vitals love), keep WordPress safely tucked away, and still enjoy the UI and extensibility you know. This guide shows why the combo works, how to wire it up (REST or GraphQL), and what patterns to use in production—complete with TypeScript examples that match your stack.

    Why go headless with WordPress + Next.js?

    Speed: Pre-render pages and cache API responses; revalidate them on a schedule or on demand. That means static-fast delivery with fresh content when you choose.

    SEO: Next.js ships optimized HTML, stable images, and predictable hydration. When you pull Yoast’s yoast_head_json into your head, you keep all your titles, meta, Open Graph, and schema in sync. Google’s page experience guidance highlights Core Web Vitals as key user-experience signals—fast, stable pages help you win.

    Security: Decoupling reduces your public attack surface. With Jamstack-style hosting (static files at the edge, APIs behind the scenes), there are simply fewer servers to harden.

    Architecture at a glance

    • WordPress = content backend (editors use admin, you expose content via REST or GraphQL)
    • Next.js = React frontend using the App Router, ISR, caching, and revalidation. Deploy to your favorite edge platform
    • SEO = pull yoast_head_json from Yoast’s REST API and render its tags or map to Next’s metadata
    • (Optional) E-commerce = WooCommerce via REST or WPGraphQL for WooCommerce

    Performance wins you can bank on

    Incremental Static Regeneration (ISR)

    Build once, refresh in the background on a timer.

    // Revalidate this page every 60 minutes
    export const revalidate = 3600

    Or at the fetch level:

    await fetch(`${API}/wp-json/wp/v2/posts`, { next: { revalidate: 3600 } })

    Both are first-class in the App Router.

    On-demand / tag revalidation

    Invalidate cached data by tag from a webhook (e.g., when a post publishes).

    // /app/api/revalidate/route.ts
    import { NextResponse } from 'next/server'
    import { revalidateTag } from 'next/cache'
    
    export async function POST(request: Request) {
      const { tag } = await request.json()
      revalidateTag(tag) // e.g. 'posts', 'post:123'
      return NextResponse.json({ revalidated: true, tag })
    }

    Use the plugin WP Webhooks to send a signal whenever something happens in WordPress—for example, when posts, pages, or comments are created, updated, or deleted.

    Image Optimization

    Use <Image> for responsive, lazy-loaded, properly sized images with CLS protection. Configure remote hosts if images live on WordPress.

    Edge-friendly caching

    Frameworks and hosts support persistent caches and global KV (e.g., Cloudflare Pages/Workers KV).

    SEO that actually sticks in a headless build

    Core Web Vitals: LCP/INP/CLS are user-experience signals in Search; improving them improves your chances. Headless + Next.js makes this easier via pre-rendering and image stability.

    Yoast in headless: Use Yoast’s REST API to retrieve all meta + schema for a URL via yoast_head_json. Render those tags directly or map them into generateMetadata. You can toggle the endpoint in Yoast settings.

    Pro tip: Keep canonical URLs consistent with your public domain and ensure your Next.js routes mirror WordPress permalinks to avoid duplicate content.

    Security: shrink your attack surface

    Public site = static assets + edge runtime. Admin/API stay private behind a separate origin, firewall, or VPN. Fewer moving parts exposed → fewer places to harden.

    Keep WordPress core, plugins, and themes updated, and disable unused endpoints. (Yoast’s REST endpoint can be toggled.)

    Two API paths: REST vs GraphQL

    Need Use REST (built-in) Use GraphQL (WPGraphQL)
    “Works out of the box” ✅ WordPress REST API ➖ requires plugin
    Query only what you need ➖ multiple endpoints / shaping ✅ single endpoint, precise queries
    Yoast SEO metadata yoast_head_json ✅ combine WPGraphQL + Yoast REST
    WooCommerce ✅ REST or Woo ✅ WPGraphQL for WooCommerce

    Production patterns (with code that matches your types)

    You shared strong TypeScript models and a WordPressAPI wrapper that enriches posts with authors, media, categories, tags, and yoast_head_json. Here’s how to wire it into the App Router.

    1) Fetch posts with caching + tags

    // lib/wordpress.ts (using your WordPressAPI instance)
    import { wordpress, type WordPressPost } from '@/lib/wordpress'
    
    // Server-friendly helper with cache tags
    export async function getLatestPosts() {
      const res = await wordpress.getPosts({
        perPage: 10,
        orderBy: 'date',
        order: 'desc',
      })
      return res.posts as WordPressPost[]
    }

    With your internal fetches already using next: { revalidate: 3600 }, you can tag downstream fetches by wrapping them or by tagging at the route level and invalidating via revalidateTag('posts').

    2) Render Yoast metadata safely

    // app/blog/[slug]/page.tsx
    import type { Metadata } from 'next'
    import { wordpress } from '@/lib/wordpress'
    
    export async function generateMetadata(
      { params }: { params: { slug: string } }
    ): Promise<Metadata> {
      const post = await wordpress.getPostBySlug(params.slug)
      if (!post?.yoastSEO) return { title: post?.title ?? 'Blog' }
    
      // Minimal mapping from Yoast JSON → Next Metadata
      const y = post.yoastSEO
      return {
        title: y.title ?? post.title,
        description: y.og_description ?? y.twitter_description ?? post.excerpt,
        openGraph: {
          title: y.og_title ?? y.title,
          description: y.og_description,
          url: post.link,
          images: y.og_image?.map((img: any) => ({ url: img.url })) ?? [],
          type: 'article',
        },
        twitter: {
          card: y.twitter_card ?? 'summary_large_image',
          title: y.twitter_title ?? y.title,
          description: y.twitter_description ?? y.og_description,
          images: y.twitter_image ? [y.twitter_image] : undefined,
        },
        alternates: { canonical: y.canonical ?? post.link },
      }
    }
    
    export default async function PostPage({ params }: { params: { slug: string } }) {
      const post = await wordpress.getPostBySlug(params.slug)
      if (!post) return null
    
      return (
        <article>
          <h1>{post.title}</h1>
          {/* Rendered HTML from WP: sanitize/transform as needed */}
          <div dangerouslySetInnerHTML={{ __html: post.content }} />
        </article>
      )
    }

    Yoast’s REST API exposes exactly the SEO metadata you need for headless; mapping to Next’s Metadata keeps things type-safe.

    3) On-demand revalidation from WordPress

    Create a small plugin or webhook in WordPress that hits your POST /api/revalidate endpoint when a post publishes/updates, passing tags like “posts” and “post:{id}”.

    // /app/api/revalidate/route.ts
    import { NextResponse } from 'next/server'
    import { revalidateTag } from 'next/cache'
    
    export async function POST(req: Request) {
      const { tags } = await req.json()
      ;(Array.isArray(tags) ? tags : [tags]).forEach((t) => revalidateTag(t))
      return NextResponse.json({ ok: true, tags })
    }

    This keeps pages fast (static) and fresh (on demand).

    4) Images from WordPress, optimized by Next.js

    import Image from 'next/image'
    
    <Image
      src={post.featuredImage?.sizes.full ?? post.featuredImage?.url ?? ''}
      alt={post.featuredImage?.alt ?? post.title}
      width={post.featuredImage?.width ?? 1200}
      height={post.featuredImage?.height ?? 630}
      priority
    />

    Remember to allow your WordPress domain in next.config.js under images.remotePatterns.

    WooCommerce? You’ve got options

    • REST: stable, widely supported, straightforward
    • GraphQL: install WPGraphQL + WPGraphQL for WooCommerce for a strongly typed schema and fewer over-fetches

    Example: use GraphQL for product lists and carts, REST for webhooks or payment provider callbacks.

    Content workflow niceties

    • Previews: add a /preview route that fetches draft content with a preview token
    • Server Actions: mutate data (e.g., form submissions) without API routes; great for headless forms and gated features
    • Cache strategy: use time-based revalidation for newsy content and tag-based for editorial control

    SEO checklist for headless WordPress

    • Map yoast_head_json → Next metadata (title, description, Open Graph, Twitter, canonical)
    • Optimize images with <Image>; define remote patterns
    • Ship fast HTML (ISR) and keep it fresh (revalidate on schedule or on demand)
    • Monitor Core Web Vitals (Search Console + field tools)

    Security checklist

    • Hide WordPress admin behind a separate origin/WAF; only your Next.js app talks to it. (Decoupling = fewer exposed surfaces.)
    • Keep plugins minimal; disable APIs you don’t need (Yoast endpoint toggle exists)
    • Use HTTPS everywhere; rotate application passwords/JWT secrets; log & rate-limit API access

    Example keyword targets (use naturally)

    Primary: headless wordpress, headless cms, wordpress rest api, wpgraphql, headless wordpress seo, headless wordpress next.js

    Supporting: wordpress api performance, headless wordpress core web vitals, headless woocommerce, decoupled wordpress, jamstack wordpress

    These map to real developer and buyer intent, spanning architecture, SEO, and e-commerce.

    FAQs

    Is headless WordPress good for SEO?

    Yes—if you implement metadata and structured data (e.g., via Yoast’s REST output) and keep pages fast and stable (Core Web Vitals).

    REST or GraphQL—what should I use?

    REST is built-in and pairs perfectly with Yoast metadata. GraphQL (WPGraphQL) is great for shaping responses and minimizing over-fetching, and WooCommerce has a dedicated GraphQL extension.

    How do I keep content fresh without slow SSR?

    Use ISR with time-based (revalidate) or tag-based revalidateTag() from webhooks.

    Can I do e-commerce?

    Yes—WooCommerce works headlessly via REST or WPGraphQL for WooCommerce.

    Further reading

    Copy-paste snippets you can adapt today

    1) Tiny fetch helper with tags (pairs with your WordPressAPI)

    // lib/wp-data.ts
    export async function getPostBySlug(slug: string) {
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_BLOG_API_URL}/wp-json/wp/v2/posts?slug=${slug}&_embed=true`,
        { next: { revalidate: 1800, tags: ['posts', `post:${slug}`] } }
      )
      const [post] = await res.json()
      return post
    }

    (Use revalidateTag('posts') on publish from a WordPress webhook.)

    2) Server Action for CF7 or simple contact forms

    'use server'
    
    export async function submitContact(formData: FormData) {
      // call WordPress endpoint or a serverless function
      // returns success and optionally revalidates a tag
      // revalidateTag('contact-thanks')
    }

    (Server Actions run on the server and remove the need for an API route in many cases.)

    Final thought

    Headless WordPress with Next.js delivers performance that converts, SEO that scales, and security you can sleep on. Start small—migrate your blog or marketing pages—then expand to product catalogs and forms. You’ll feel the lift immediately.

  • Implementing Post View Counters in a Headless WordPress Setup with Next.js

    Implementing Post View Counters in a Headless WordPress Setup with Next.js

    Headless CMS architectures are becoming more popular in modern web development because they are flexible and improve performance. Developers can make fast, dynamic sites that use WordPress’s powerful content management features by separating the backend (like WordPress) from the frontend (like Next.js). However, some features, like tracking and showing how many people have seen a post, need to be carefully integrated using REST APIs.

    This post will show you how to add view counters to your blog posts when you use WordPress and Next.js without a head. To make things easier, we will use the well-known “Post Views Counter” plugin, turn on its REST API mode, and set up a custom endpoint. This method makes sure that tracking is accurate without using standard WordPress themes.

    Why it is important to have view counters in a website

    View counters give you useful information about how popular and engaging your content is. Plugins make this easy with shortcodes or theme hooks on a regular WordPress site. But if you use Next.js to build the front end of a headless site, you have to use WordPress’s REST API to talk to it. This means getting post data (including views) on the server side during server-side rendering (SSR) or static site generation (SSG) and adding counts on the client side to get real user views.

    What is the problem? A lot of plugins do not show view counts in the best way for apps that use APIs. That is when personalization comes into play.

    Step 1: Set up and install the Post Views Counter plugin

    The first step is to install the free “Post Views Counter” plugin from the WordPress plugin repository. It is light and has more than 100,000 active installations. It also works with REST API for headless use.

    1. Go to your WordPress admin dashboard and log in.
    2. To install or activate “Post Views Counter,” go to Plugins > Add New and search for it.
    3. Go to Post Views tab and then Counter Mode.
    4. Choose REST API from the list of options in Counter Mode. This lets you count using an API, which is important for headless setups because it does not need PHP to run in themes or JavaScript to run in the browser.
    5. Set up other options:
      • Post Types: Check the box next to “post” (or any other type you want).
      • Set the time between counts to “1 day” to get unique views per user.
    6. To register the new routes, save your changes and flush the permalinks (Settings > Permalinks > Save Changes).

    With this, the plugin keeps track of views in post meta (post_views_count) and adds an endpoint for adding views: POST /wp-json/post-views-counter/view-post/{post_id}.

    Step 2: The Limitation and Why We Need a Custom Endpoint

    The default increment endpoint works perfectly; it follows rules for uniqueness (like using local storage or cookies) and updates the count on the server. But its answer does not include the new total number of views. It gives back something like this:

    {
      "post_id": 1093,
      "counted": true,
      "storage": {
        "name": ["pvc_visits[0]"],
        "value": ["1756048474b1093"],
        "expiry": [1756048474]
      },
      "type": "post"
    }

    To make this work, we will make a custom REST API endpoint that adds to the view count and sends back the new count in one response.

    Step 3: Putting the Custom Endpoint in functions.php

    Change the functions.php file for your theme (or make a custom plugin to make it easier to keep up with). Put in the following code:

    // Custom REST API endpoint to get and add to the view count
    add_action('rest_api_init', function() {
        register_rest_route('custom/v1', '/post-views/(?P<id>\d+)', array(
            'methods' => 'POST',
            'callback' => 'custom_increment_and_get_views',
            'permission_callback' => '__return_true', // Public; add auth for safety
            'args' => array(
                'id' => array(
                    'validate_callback' => function($param) {
                        return is_numeric($param) && get_post($param);
                    },
                ),
            ),
        ));
    });
    
    function custom_increment_and_get_views($request) {
        $post_id = $request['id'];
        
        if (!function_exists('pvc_view_post') || !function_exists('pvc_get_post_views')) {
            return new WP_Error('plugin_missing', 'Post Views Counter functions not found.', array('status' => 500));
        }
        
        // Add one more view to the post
        pvc_view_post($post_id);
        
        // Get the new total
        $views = pvc_get_post_views($post_id);
        
        return array(
            'post_id' => $post_id,
            'views' => $views,
            'success' => true,
        );
    }
    • Endpoint: POST /wp-json/custom/v1/post-views/{post_id}
    • Example response: {"post_id":1093,"views":42,"success":true}
    • Benefits: One call for both increment and fetch. It uses the plugin’s built-in features, so it gets all of its settings, like uniqueness and exclusions.

    Use curl to test it: curl -X POST https://your-site.com/wp-json/custom/v1/post-views/1093. Add nonce checks or API keys to make it safe in production.

    Step 4: Working with Next.js

    In your Next.js app, use getStaticProps or getServerSideProps to get the initial post data, which includes views. Then, on page load, use a useEffect hook to call the custom endpoint and update the display with the count that comes back.

    In pages/posts/[slug].js, for example:

    import { useEffect, useState } from "react";
    
    export default function Post({ post }) {
        const [views, setViews] = useState(post.meta?.post_views_count || 0);
    
        useEffect(() => {
            const handleViews = async () => {
                const storageKey = "pvc_visits[0]";
                
                // Check if already viewed
                if (localStorage.getItem(storageKey)) return;
    
                try {
                    const response = await fetch(`https://your-site.com/wp-json/custom/v1/post-views/${post.id}`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                    });
                    
                    if (response.ok) {
                        const data = await response.json();
                        setViews(data.views);
                        // Set storage for uniqueness
                        localStorage.setItem(storageKey, `some-value-b${post.id}`);
                    }
                } catch (error) {
                    console.error('Error:', error);
                }
            };
    
            handleViews();
        }, [post.id]);
    
        return (
            <div>
                <h1>{post.title.rendered}</h1>
                <p>Views: {views}</p>
                {/* Content of the post */}
            </div>
        );
    }
    

    This uses Incremental Static Regeneration (ISR) to refresh the page every so often and localStorage to mimic the plugin’s unique logic.

    Final Thoughts

    You can easily keep track of and show view counts in your headless WordPress + Next.js blog by turning on REST API mode in Post Views Counter and adding a custom endpoint. This setup works well, is accurate, and is easy to keep up with. If your endpoint gets a lot of traffic, you might want to add caching (like with Redis) or rate limiting.

    Have you done something like this before? Please tell us about your experiences in the comments. If you want to learn more about headless CMS, read my other posts about how to make Next.js faster.

  • A Complete Guide to Next.js Server Actions and WordPress Contact Form 7 Integration for Building Modern Headless Forms

    A Complete Guide to Next.js Server Actions and WordPress Contact Form 7 Integration for Building Modern Headless Forms

    Headless architecture is now the best way to build websites that load quickly, can handle a lot of traffic, and are optimized for search engines. Next.js and WordPress together as a headless CMS give developers the best of both worlds: WordPress’s content management flexibility and the speed benefits of modern frontend frameworks.

    This detailed guide shows how to set up a full headless contact form solution using Next.js 14+ server actions, Contact Form 7’s REST API, and TypeScript. It is based on a tutorial from CSS-Tricks that has been proven to work.

    We’ll go over the details of how to implement this approach, how to optimize it, and the SEO benefits that make it better for modern web apps.

    Why Use Headless Forms Instead of Regular WordPress Forms?

    Performance Benefits That Help SEO

    WordPress forms that are built in the traditional way are very closely linked to the WordPress frontend, which can have a big effect on how well the site works. Headless forms have a number of benefits that directly affect how high your site ranks in search engines:

    • Lightning-Fast Load Times: You can get rid of server-side bottlenecks by separating forms from WordPress’s PHP rendering engine. Next.js’s optimized JavaScript execution and server-side rendering (SSR) can raise Core Web Vitals scores by as much as 40%.
    • Better Mobile Experience: Google’s main ranking factor is mobile-first indexing, so headless forms are great at giving fast, responsive experiences on all devices. The API-first method makes sure that performance is always the same, no matter what size screen or device you have.
    • Better SEO Performance: Headless forms help search engines rank pages higher by making them load faster, improving user experience metrics, and using cleaner HTML markup that search engines can easily read.

    Modern Development Benefits

    • TypeScript Safety: Headless forms with Next.js fully support TypeScript, unlike regular WordPress forms that use PHP and sometimes inconsistent JavaScript. This cuts down on runtime errors and makes developers more productive.
    • Component Reusability: Make form components once and use them in many projects, platforms, or even mobile apps. This method cuts down on development time and maintenance costs by a lot.
    • Advanced State Management: Use React’s state management features with Next.js server actions to handle forms in a more advanced way, such as real-time validation, progressive enhancement, and optimistic updates.

    Understanding Contact Form 7’s REST API

    Contact Form 7, which powers more than 5 million WordPress sites, has a strong REST API that makes headless integration easy. The plugin has endpoints that take care of form submissions, validation, and response management without needing to build a custom backend.

    API Endpoint Structure

    The Contact Form 7 REST API has a predictable pattern:

    https://your-wordpress-site.com/wp-json/contact-form-7/v1/contact-forms/{FORM_ID}/feedback

    Important: Starting with version 5.8 of Contact Form 7, form IDs are hashed to keep them safe. You can still find the numeric ID you need for API calls in the form’s edit URL in the WordPress admin panel, though.

    Required Fields for API Submission

    For submissions to be processed correctly, Contact Form 7’s API needs certain fields:

    • Form field data: The actual fields on your form, like name, email, message, etc.
    • _wpcf7: The form ID
    • _wpcf7_locale: The language locale, like “en_US”
    • _wpcf7_unit_tag: A unique ID for the form
    • _wpcf7_container_post: The ID of the post where the form is embedded (usually “0” for headless)

    Next.js Server Actions: The Modern Way to Handle Forms

    Server actions were added to Next.js 14+, changing the way we handle form submissions. Server actions have a number of benefits over regular API routes:

    Security Benefits

    • Built-in CSRF Protection: Server actions automatically include CSRF tokens, which protect against cross-site request forgery attacks without any extra setup.
    • Server-Side Execution: The logic for processing forms runs only on the server, which keeps sensitive operations and API keys out of client-side code.
    • Input Sanitization: Server actions promote good data validation and sanitization practices, which lowers the risk of security holes.

    Enhanced Developer Experience

    • Simplified Code Structure: You don’t need separate API routes anymore; you can define server actions next to your components or in separate action files.
    • Progressive Enhancement: Forms work even if JavaScript is turned off, which makes them easier to use and works on more devices.
    • Automatic Error Handling: Built-in error boundaries and status management make it easier to handle errors than traditional fetch-based methods.

    Implementation: Building the Complete Headless Form Solution

    The implementation integrates Next.js server actions with Contact Form 7, following the approach demonstrated in the CSS-Tricks tutorial:

    Server Action Implementation

    The server action handles all the backend communication with the Contact Form 7 REST API:

    "use server";
    
    export async function sendContactForm(data: {
      "your-name": string;
      "your-email": string;
      "your-subject": string;
      "your-message": string;
      "honeypot"?: string;
    }) {
      // Server-side honeypot validation - reject if honeypot is filled
      if (data.honeypot && data.honeypot.trim() !== '') {
        return { success: false, message: "Spam detected" };
      }
    
      const formId = process.env.CF7_FORM_ID || '87';
      const siteUrl = process.env.NEXT_PUBLIC_BLOG_API_URL;
    
      if (!formId || !siteUrl) {
        throw new Error("Missing FormId and/or WordPress Site URL");
      }
    
      const url = `${siteUrl}/wp-json/contact-form-7/v1/contact-forms/${formId}/feedback`;
    
      const formData = new FormData();
      formData.append('your-name', data['your-name']);
      formData.append('your-email', data['your-email']);
      formData.append('your-subject', data['your-subject']);
      formData.append('your-message', data['your-message']);
    
      // Required CF7 fields
      formData.append('_wpcf7', formId);
      formData.append('_wpcf7_locale', 'en_US');
      formData.append('_wpcf7_unit_tag', `wpcf7-f${formId}-o1`);
      formData.append('_wpcf7_container_post', '0');
    
      try {
        const response = await fetch(url, {
          method: "POST",
          body: formData,
        });
    
        const result = await response.json();
    
        if (result.status === "mail_sent") {
          return { success: true };
        } else {
          return { success: false, message: result.message };
        }
      } catch (error) {
        return { success: false, message: "There was a server error." };
      }
    }

    Client Component with Enhanced User Experience

    The client-side implementation shows how to use modern React patterns while maintaining accessibility and built-in spam protection through honeypot fields:

    "use client";
    
    import { useState } from "react";
    import Button from '@/components/ui/button';
    import { Input } from '@/components/ui/input';
    import { Textarea } from '@/components/ui/textarea';
    import { Send } from "lucide-react";
    import { submitContactForm } from './action';
    
    const ContactForm = () => {
      const [name, setName] = useState('');
      const [email, setEmail] = useState('');
      const [subject, setSubject] = useState('');
      const [message, setMessage] = useState('');
      const [honeypot, setHoneypot] = useState(''); // Honeypot field
      const [isSubmitting, setIsSubmitting] = useState(false);
      const [success, setSuccess] = useState(false);
      const [error, setError] = useState('');
    
      const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
    
        // Honeypot spam protection
        if (honeypot !== '') return;
    
        setIsSubmitting(true);
        setError('');
        setSuccess(false);
    
        const formData = {
          "your-name": name,
          "your-email": email,
          "your-subject": subject,
          "your-message": message,
          "honeypot": honeypot, // Include honeypot in server action call
        };
    
        const result = await submitContactForm(formData);
    
        setIsSubmitting(false);
    
        if (result.success) {
          setSuccess(true);
          // Clear form fields
          setName('');
          setEmail('');
          setSubject('');
          setMessage('');
        } else {
          setError(result.message || "Submission failed. Please try again.");
        }
      };
    
      return (
        <div className='bg-white/5 backdrop-blur-xl p-8 border border-white/10 rounded-3xl'>
          <form onSubmit={handleSubmit} className='space-y-6'>
            <Input
              placeholder='Your Name'
              className='bg-white/10 border-white/20 text-white placeholder:text-white/50'
              value={name}
              onChange={(e) => setName(e.target.value)}
              required
            />
            <Input
              type='email'
              placeholder='Your Email'
              className='bg-white/10 border-white/20 text-white placeholder:text-white/50'
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
            <Input
              placeholder='Subject'
              className='bg-white/10 border-white/20 text-white placeholder:text-white/50'
              value={subject}
              onChange={(e) => setSubject(e.target.value)}
              required
            />
            <Textarea
              placeholder='Your Message'
              className='bg-white/10 border-white/20 min-h-[150px] text-white placeholder:text-white/50'
              value={message}
              onChange={(e) => setMessage(e.target.value)}
              required
            />
    
            {/* Honeypot field for spam protection */}
            <Input
              type='text'
              name='hp'
              className='hidden'
              value={honeypot}
              onChange={(e) => setHoneypot(e.target.value)}
              tabIndex={-1}
              autoComplete="off"
            />
    
            <Button
              type='submit'
              disabled={isSubmitting}
              className='bg-gradient-to-r from-purple-600 hover:from-purple-700 to-cyan-600 hover:to-cyan-700 w-full'
            >
              {isSubmitting ? (
                "Sending..."
              ) : (
                <>
                  <Send className='mr-2 w-4 h-4' /> Send Message
                </>
              )}
            </Button>
          </form>
    
          {success && (
            <p className='mt-4 text-green-500'>Message sent successfully!</p>
          )}
          {error && <p className='mt-4 text-red-500'>{error}</p>}
        </div>
      );
    };
    
    export default ContactForm;

    Advanced Features and Security Considerations

    Comprehensive Spam Protection Strategy

    The implementation includes several layers of spam protection that are necessary for production environments:

    • Honeypot Fields: The hidden honeypot field catches automated bots that fill out all form fields indiscriminately. This validation is performed on both client and server sides – the client-side check prevents unnecessary API calls, while the server-side validation ensures security even if the client-side code is bypassed. This method is highly effective while being less intrusive than CAPTCHA.
    • Server-Side Validation: Contact Form 7’s built-in validation runs on the server, preventing malicious users from bypassing client-side restrictions.
    • Rate Limiting: Consider implementing rate limiting in production to prevent abuse and protect your WordPress backend from being overwhelmed.

    Error Handling and User Experience Enhancement

    • Graceful Degradation: Forms continue to work even when JavaScript fails to load, thanks to server actions’ progressive enhancement capabilities.
    • Comprehensive Error States: The implementation handles various scenarios including server errors, network failures, validation failures, and WordPress downtime.
    • Clear User Feedback: Immediate, understandable success and error messages keep users informed about their form submission status.

    SEO Optimization for Headless Forms

    • Improved Core Web Vitals: Headless forms contribute to better Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS) scores. These user experience metrics are increasingly important in search engine ranking algorithms.
    • Clean Markup Structure: Next.js generates semantic, accessible HTML that search engines can easily crawl and understand. This structured markup improves search engine indexing capabilities.
    • Enhanced Mobile Performance: Mobile-first design principles ensure optimal performance across all devices, aligning with Google’s mobile-first indexing approach.

    Troubleshooting Common Implementation Issues

    Contact Form 7 Configuration Problems

    • Hashed ID Resolution: Since CF7 version 5.8, form IDs are hashed in shortcodes, but the numeric ID required for API calls can still be found in the form’s edit URL.
    • CORS Configuration: Ensure your WordPress site allows cross-origin requests from your Next.js domain. This typically requires server configuration or WordPress plugin setup.
    • API Availability: Verify that the Contact Form 7 REST API is enabled and accessible. Some security plugins or hosting providers may restrict API access.

    Next.js Server Action Considerations

    • FormData Handling: Server actions automatically receive FormData objects, but may require special handling for complex nested data structures.
    • Error Boundary Integration: Implement proper error boundaries to handle server action failures gracefully and provide meaningful user feedback.
    • Environment Variable Management: Ensure sensitive configuration like form IDs and WordPress URLs are properly secured in environment variables.

    Best Practices and Recommendations

    • Input Validation: Always validate and sanitize user input on both client and server sides to prevent security vulnerabilities.
    • Data Protection Compliance: Ensure your implementation complies with GDPR, CCPA, and other applicable data protection regulations.
    • Regular Security Updates: Keep Contact Form 7, WordPress, and Next.js dependencies updated to address security vulnerabilities.

    Conclusion

    The integration of Next.js server actions with Contact Form 7’s REST API represents a modern, scalable approach to web form development. This architecture delivers improved performance, security, and SEO outcomes while maintaining the content management advantages that make WordPress the world’s most popular CMS.

    By implementing headless forms, you’re establishing a foundation for future growth that can adapt to new platforms, technologies, and user expectations. The decoupled architecture ensures your forms remain fast, secure, and maintainable as your business scales.

    The implementation demonstrated provides a production-ready foundation that can be extended with additional features like file uploads, multi-step forms, conditional logic, and advanced validation rules. As the headless CMS ecosystem continues to evolve, this approach positions applications for long-term success in an increasingly competitive digital landscape.