WordPress is where your content lives. Next.js is where it comes to life.
A practical guide to building modern, accessible, and maintainable
citizen-facing services — your editors never have to leave the dashboard.
A quiet crisis runs through municipal websites across the country: the CMS that empowers editors is the same system that frustrates developers and slows down citizens.
Every city has one. A WordPress site that started as a simple blog in 2014 — a place for press releases, meeting minutes, and the mayor's quarterly letter. Over a decade it grew, like ivy through a wall, into something far larger than anyone intended. Permit applications, business license renewals, code enforcement portals, public records requests — all of them bolted onto a system never designed for any of it.
The result is familiar to anyone who has worked in local government technology: a theme file with seven thousand lines of PHP, a dozen plugins held together by hope and update deferrals, page load times measured with a calendar rather than a stopwatch, and a frontend that treats the citizen like a passenger on a bus with no route map.
There is a way out. It does not require abandoning WordPress. It requires, instead, a kind of elegant divorce — separating the tool your editors love from the experience your citizens deserve.
The proposition is this: keep WordPress as your content backend — your editors never leave the dashboard they know — and hand the frontend to Next.js, a React framework built for speed, accessibility, and the unforgiving standards of modern web performance. What follows is not just a technical guide. It is an argument for a better way to build for the public.
A clean separation, yes — but the beauty is in the details: incremental static regeneration, webhook-triggered revalidation, and a content API your editors never touch.
The architecture is deceptive in its simplicity. A citizen visits services.city.gov/permits. If the page has been visited before, the response comes from a CDN node in their city — fifty milliseconds, maybe less. If it hasn't, Next.js renders it on the server, fetches the content from WordPress via the REST API, and caches the result for the next visitor.
Meanwhile, a city editor publishes a new permit type in WordPress. A webhook fires. Next.js receives the signal and revalidates only the affected pages. The entire process takes seconds. The citizen never knows anything changed behind the scenes.
This is the quiet miracle of Incremental Static Regeneration: pages are static until they need to be dynamic, and they are dynamic only for as long as it takes to make them static again.
// app/services/[slug]/page.tsx
import { WP_API } from '@/lib/wp';
export const revalidate = 300;
export const dynamicParams = true;
export async function generateStaticParams() {
const services = await fetch(`${WP_API}/services`).then(r => r.json());
return services.map(s => ({ slug: s.slug }));
}
export default async function ServicePage({ params }) {
const { slug } = await params;
const service = await getService(slug);
return (
<article>
<h1>{service.title}</h1>
<div dangerouslySetInnerHTML=
{{ __html: service.content }} />
</article>
);
}
Twenty-seven minutes, start to finish. A Next.js application backed by WordPress, deployed and ready for your first citizen.
You don't need a special plugin. WordPress 5.0+ ships with a full REST API out of the box. What you do need is structure: custom post types for your services, Advanced Custom Fields for metadata, and a few carefully registered routes.
// Register a custom post type for government services
add_action('init', function () {
register_post_type('service', [
'public' => true,
'show_in_rest' => true,
'supports' => ['title', 'editor', 'custom-fields'],
'menu_icon' => 'dashicons-building',
'labels' => [
'name' => 'Services',
'singular_name' => 'Service',
],
]);
});
// Expose ACF fields in REST API responses
add_action('rest_api_init', function () {
register_rest_field('service', 'meta', [
'get_callback' => function ($post) {
return get_fields($post['id']);
},
]);
});
The App Router is your starting point. It treats every page.tsx as a route, every layout.tsx as a persistent shell. This is not a configuration framework — it is a file-system-based mental model that maps naturally to the structure of a municipal website.
npx create-next-app@latest gov-services --typescript --tailwind --app
cd gov-services
npm install @tanstack/react-query
npm install @radix-ui/react-dialog @radix-ui/react-select
npm install lucide-react
mkdir -p src/lib src/components/ui
A typed client that maps WordPress data to TypeScript interfaces. This is your contract — the place where the amorphous JSON of the REST API becomes predictable, documented, and safe. Every page component that follows will depend on this layer and nothing else.
const WP_URL = process.env.WORDPRESS_API_URL;
const CONSUMER_KEY = process.env.WP_CONSUMER_KEY;
const CONSUMER_SECRET = process.env.WP_CONSUMER_SECRET;
export interface Service {
id: number;
slug: string;
title: { rendered: string };
content: { rendered: string };
meta?: {
department: string;
fee: string;
processing_time: string;
requirements: string[];
status: 'open' | 'closed';
};
}
export async function getServices(): Promise<Service[]> {
const res = await fetch(`${WP_URL}/wp-json/gov/v1/services`, {
headers: { 'Authorization': 'Basic ' + btoa(`${CONSUMER_KEY}:${CONSUMER_SECRET}`) },
next: { revalidate: 300 },
});
if (!res.ok) throw new Error(`WP API: ${res.status}`);
return res.json();
}
export async function getService(slug: string): Promise<Service> {
const services = await getServices();
const service = services.find(s => s.slug === slug);
if (!service) throw new Error(`Service not found: ${slug}`);
return service;
}
Next.js Server Components fetch data on the server and render to static HTML. The browser receives zero JavaScript for the content — just the final, accessible HTML. This is how you build a site that loads in under a second on a cellular connection.
import { getServices } from '@/lib/wp';
import { ServiceCard } from '@/components/service-card';
export const metadata = {
title: 'City Services',
description: 'Browse and apply for city services online.',
};
export default async function ServicesPage() {
const services = await getServices();
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-2">City Services</h1>
<p className="text-muted mb-8">
Find the service you need, submit your application, track your status.
</p>
<div className="grid gap-4 md:grid-cols-2">
{services.map(service => (
<ServiceCard key={service.id} service={service} />
))}
</div>
</div>
);
}
When an editor publishes a new service in WordPress, a webhook notifies Next.js to revalidate only the affected pages. Your static pages stay warm. Your content stays current. Your infrastructure stays quiet.
// Triggered by WordPress webhook on post publish/update
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag('services');
revalidatePath('/services');
return Response.json({ revalidated: true });
}
Not theoretical. These are applications I have built and seen deployed in cities across the country.
Every city service — permits, licenses, inspections — published as WordPress custom post types, rendered as searchable, filterable Next.js pages with ISR caching and real-time status badges.
Conditional forms built with React Server Components. Save drafts. Upload documents. Pay fees. A single Next.js route handles the entire flow; WordPress stores the submission metadata.
Property records, utility usage, permit history, tax statements — aggregated from WordPress and municipal data sources into a single authenticated dashboard, server-rendered for security and SEO.
Budget visualizations, crime maps, development trends — WordPress stores the structured data via ACF, Next.js renders interactive charts with D3, cached at the edge for instant loading.
Neighborhood-specific news, event calendars, document libraries, and discussion threads — moderated through the WordPress admin, surfaced through tailored Next.js views.
Permits, certificates, inspection reports — generated as PDFs from React components on the server, populated with WordPress data, cached for download at any time.
This demo connects to a real headless WordPress instance running on this server. Every service below is a WordPress post — create, edit, or delete them in real time.
Services are fetched live from /wp-json/gov/v1/services. Edit any service or add a new one below.
"The best municipal websites are invisible. You do not notice the technology. You notice that the permit took three minutes to apply for instead of three days. You notice that the information was accurate. You notice that someone cared about how it felt to use it."
I architect and build Next.js + WordPress solutions for local government. If you have a project in mind, or just want to talk about what headless CMS could mean for your municipality, reach out.
View My Portfolio →