Step-by-Step Guide to building a custom broken link checker block for Gutenberg using Next.js headless configurations
Setting Up the Headless WordPress Environment
Before diving into the Gutenberg block development, ensure your WordPress instance is configured for headless operation. This typically involves enabling the REST API and potentially setting up an application password for authenticated requests. For this guide, we’ll assume a standard WordPress installation accessible via its REST API endpoints.
The core of our headless setup will be a Next.js application. Initialize a new Next.js project using Create Next App:
npx create-next-app@latest my-headless-wp-app cd my-headless-wp-app
Next, we need to install the necessary dependencies for making HTTP requests. `axios` is a popular choice:
npm install axios
For interacting with the WordPress REST API, it’s beneficial to have a utility function. Create a file, for example, lib/api.js, to house these functions.
import axios from 'axios';
const WP_API_URL = process.env.NEXT_PUBLIC_WP_API_URL || 'http://your-wordpress-site.com/wp-json/wp/v2';
export async function getPosts() {
try {
const response = await axios.get(`${WP_API_URL}/posts?_embed`);
return response.data;
} catch (error) {
console.error('Error fetching posts:', error);
return [];
}
}
export async function getPostBySlug(slug) {
try {
const response = await axios.get(`${WP_API_URL}/posts?slug=${slug}&_embed`);
return response.data[0]; // Assuming slug is unique
} catch (error) {
console.error(`Error fetching post with slug ${slug}:`, error);
return null;
}
}
// Add other API functions as needed (e.g., for pages, custom post types)
Don’t forget to set the NEXT_PUBLIC_WP_API_URL environment variable in your .env.local file:
NEXT_PUBLIC_WP_API_URL=http://your-wordpress-site.com/wp-json/wp/v2
Developing the Gutenberg Block
Gutenberg blocks are developed using JavaScript, often with React. For a headless setup, the block’s functionality will primarily reside in the frontend Next.js application, but we still need a minimal PHP plugin to register the block and define its editor-side interface.
First, create a simple WordPress plugin. Navigate to your WordPress installation’s wp-content/plugins/ directory and create a new folder, e.g., broken-link-checker-block. Inside this folder, create a main PHP file, broken-link-checker-block.php.
<?php
/**
* Plugin Name: Broken Link Checker Block
* Description: A custom Gutenberg block to display broken links.
* Version: 1.0.0
* Author: Your Name
*/
function register_broken_link_checker_block() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'register_broken_link_checker_block' );
?>
Now, let’s set up the frontend for the block. We’ll use `@wordpress/scripts` for building our JavaScript assets. In your plugin directory (broken-link-checker-block), run:
npm init -y npm install @wordpress/scripts --save-dev
Add a build script to your package.json:
{
"name": "broken-link-checker-block",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@wordpress/scripts": "^26.7.0"
}
}
Create a JavaScript file for your block’s source code, e.g., src/index.js. This file will define both the editor and frontend views of your block. For a headless setup, the editor view will be minimal, primarily for configuration, while the actual rendering and logic will be handled by your Next.js app.
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
registerBlockType('my-blocks/broken-link-checker', {
title: __('Broken Link Checker', 'my-blocks'),
icon: 'admin-links',
category: 'widgets',
attributes: {
postToCheck: {
type: 'string',
default: '',
},
},
edit: ({ attributes, setAttributes }) => {
const blockProps = useBlockProps();
const { postToCheck } = attributes;
return [
<InspectorControls>
<PanelBody title={__('Link Checker Settings', 'my-blocks')} initialOpen={true}>
<TextControl
label={__('Post Slug to Check', 'my-blocks')}
value={postToCheck}
onChange={(newSlug) => setAttributes({ postToCheck: newSlug })}
help={__('Enter the slug of the post you want to check links for.', 'my-blocks')}
/>
</PanelBody>
</InspectorControls>,
<div {...blockProps}>
{postToCheck ?
__(`Checking links for post: ${postToCheck}`, 'my-blocks') :
__('Select a post slug in the block settings.', 'my-blocks')
}
</div>,
];
},
save: () => {
// In a headless setup, the 'save' function typically returns null
// as the frontend rendering is handled by the Next.js application.
// We can use a placeholder or a data attribute to pass information.
return null;
},
});
After creating src/index.js, run the build command in your plugin directory:
npm run build
This will generate a build folder containing the compiled JavaScript. Activate your “Broken Link Checker Block” plugin in WordPress. You should now be able to add the block to your posts/pages. Note that the Save function returns null. This is crucial for headless WordPress; the block’s content and functionality won’t be directly saved in the post’s HTML content but rather managed through attributes and rendered on the frontend by your Next.js app.
Frontend Rendering and Link Checking Logic in Next.js
The real work happens in your Next.js application. We need to fetch the block’s attributes and then implement the link checking logic.
First, let’s create a component that will render the block’s content. Create a file like components/BrokenLinkChecker.js.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const BrokenLinkChecker = ({ postSlug }) => {
const [brokenLinks, setBrokenLinks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!postSlug) return;
const checkLinks = async () => {
setLoading(true);
setError(null);
setBrokenLinks([]);
try {
// 1. Fetch the post content from WordPress
const postResponse = await axios.get(`/wp-json/wp/v2/posts?slug=${postSlug}&_embed`);
const post = postResponse.data[0];
if (!post || !post.content.rendered) {
setError('Post not found or has no content.');
setLoading(false);
return;
}
// 2. Extract all anchor tags (links) from the content
const parser = new DOMParser();
const doc = parser.parseFromString(post.content.rendered, 'text/html');
const links = Array.from(doc.querySelectorAll('a[href]'));
const foundBrokenLinks = [];
// 3. Check each link
for (const link of links) {
const url = link.href;
// Basic check: ignore internal WordPress links or anchors
if (url.startsWith(window.location.origin) || url.startsWith('#')) {
continue;
}
try {
// Use a HEAD request for efficiency, fall back to GET if HEAD is not allowed
const response = await axios.head(url, {
timeout: 5000, // Set a timeout
headers: {
'User-Agent': 'WordPressBrokenLinkChecker/1.0' // Some servers might block requests without a User-Agent
}
});
if (response.status >= 400) {
foundBrokenLinks.push({ url, status: response.status });
}
} catch (linkError) {
// Handle network errors, timeouts, or 404s etc.
if (linkError.response) {
foundBrokenLinks.push({ url, status: linkError.response.status });
} else if (linkError.request) {
foundBrokenLinks.push({ url, status: 'Network Error' });
} else {
foundBrokenLinks.push({ url, status: 'Error' });
}
}
}
setBrokenLinks(foundBrokenLinks);
} catch (err) {
console.error('Error during link checking:', err);
setError('Failed to fetch post or check links.');
} finally {
setLoading(false);
}
};
checkLinks();
}, [postSlug]); // Re-run effect if postSlug changes
if (loading) {
return <div>Checking links...</div>;
}
if (error) {
return <div style={{ color: 'red' }}>Error: {error}</div>;
}
if (brokenLinks.length === 0) {
return <div>No broken links found (or no links to check).</div>;
}
return (
<div>
<h3>Broken Links Found:</h3>
<ul>
{brokenLinks.map((link, index) => (
<li key={index}>
<a href={link.url} target="_blank" rel="noopener noreferrer">{link.url}</a> - Status: {link.status}
</li>
))}
</ul>
</div>
);
};
export default BrokenLinkChecker;
Now, integrate this component into your Next.js pages. For example, in pages/posts/[slug].js (assuming you have dynamic routing for posts):
import { useRouter } from 'next/router';
import { getPostBySlug, getPosts } from '../../lib/api'; // Adjust path as needed
import BrokenLinkChecker from '../../components/BrokenLinkChecker'; // Adjust path as needed
export default function PostPage({ post }) {
const router = useRouter();
// Find the block attributes from the post content.
// This is a simplified approach. A more robust solution might involve
// parsing the content for specific block structures or using a library.
// For this example, we'll assume the block is added and its attributes
// are somehow accessible or we can infer the slug.
// A better approach for headless might be to store the slug in post meta.
// For demonstration, let's assume we can get the slug from somewhere,
// perhaps a custom field or by parsing the block's saved data if it were saved.
// *** IMPORTANT NOTE FOR HEADLESS ***
// Since the 'save' function returns null, the block's attributes (like postToCheck)
// are NOT saved in the post_content. To make this work effectively in headless,
// you would typically:
// 1. Store the 'postToCheck' slug in a custom field (post meta) for the post
// where the block is *intended* to be used.
// 2. Fetch this custom field data in your Next.js app.
// 3. Pass the fetched slug to the BrokenLinkChecker component.
// For this example, let's *simulate* having the slug.
// In a real scenario, you'd fetch this from post meta.
// const postToCheckSlug = post.meta.broken_link_post_slug; // Example if using ACF or similar
// Placeholder: If you don't have post meta, you might hardcode it for testing
// or derive it if the block itself was configured to reference its own post.
// Let's assume for this example that the post slug itself is what we want to check.
const postToCheckSlug = post?.slug; // This is a simplification.
if (router.isFallback) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{post.title.rendered}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
{/* Render the BrokenLinkChecker component */}
{postToCheckSlug && (
<div style={{ marginTop: '40px', borderTop: '1px solid #eee', paddingTop: '20px' }}>
<BrokenLinkChecker postSlug={postToCheckSlug} />
</div>
)}
</div>
);
}
export async function getStaticPaths() {
const posts = await getPosts();
const paths = posts.map((post) => ({
params: { slug: post.slug },
}));
return { paths, fallback: 'blocking' };
}
export async function getStaticProps({ params }) {
const post = await getPostBySlug(params.slug);
// Fetch post meta if you are using it for the slug
// const postMeta = await getPostMeta(post.id); // Hypothetical function
return {
props: {
post,
// postMeta, // Pass meta if fetched
},
revalidate: 10, // Revalidate every 10 seconds
};
}
Explanation of the Headless Integration:
- `BrokenLinkChecker.js` Component: This React component takes a
postSlugas a prop. It usesuseEffectto fetch the specified post’s content from WordPress, parses the HTML to find all links, and then attempts to check the status of each link usingaxios.head(oraxios.getas a fallback). It displays loading states, errors, and the list of broken links. - `pages/posts/[slug].js` (Dynamic Post Page): This Next.js page fetches a specific post using its slug. The critical part is how the
postToCheckSlugis determined. In a true headless setup where the Gutenberg block’ssavefunction returnsnull, the block’s attributes aren’t saved in the post content. Therefore, you need an alternative mechanism to pass the slug to theBrokenLinkCheckercomponent. The most common and robust method is to use WordPress custom fields (post meta). You would add a custom field (e.g., “Post Slug to Check”) to the post type where you intend to use this block, populate it with the desired slug, and then fetch this meta data ingetStaticProps. The example above simulates this by defaulting to the current post’s slug, which might be useful if you want to check links within the current page itself. - `getStaticProps` and `getStaticPaths`: These Next.js functions are used for static site generation, fetching data at build time. Ensure your API functions (`getPostBySlug`, `getPosts`) are correctly implemented in
lib/api.js.
Refinements and Considerations
Error Handling and Robustness: The link checking logic can be expanded. Consider implementing retry mechanisms for network errors, handling different HTTP status codes more granularly, and potentially using a dedicated link-checking library if complexity increases.
Performance: Checking many links can be time-consuming. For production, consider:
- Background Processing: Offload the link checking to a background job or a serverless function rather than performing it on every page request or build.
- Caching: Cache the results of link checks to avoid redundant requests.
- Rate Limiting: Be mindful of external servers’ rate limits. Implement delays between requests.
Security: Ensure your WordPress REST API is secured appropriately. For the link checker itself, be cautious about the URLs being checked. Malicious URLs could potentially exploit vulnerabilities in your frontend or backend if not handled carefully.
User Experience: Provide clear feedback to the user during the checking process. Displaying results in a user-friendly format is essential.
Alternative to `save: () => null`: While returning null is standard for headless, if you need to pass *some* data directly via the block’s saved content (e.g., for simpler blocks or fallback rendering), you could return a placeholder element with data attributes:
// In src/index.js, for the save function:
save: ({ attributes }) => {
const { postToCheck } = attributes;
// Return a div with data attributes that Next.js can read
return (
<div
className="custom-block-placeholder"
data-block="my-blocks/broken-link-checker"
data-post-to-check={postToCheck}
/>
);
},
Then, in your Next.js component, you would parse this placeholder element’s data attributes instead of relying solely on post meta. This can be more complex to manage reliably across different WordPress versions and block editor updates.