★ Next.js + Headless WordPress

The Best of Both Worlds
for Local Government

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.

SSR + ISR
Next.js Rendering
WP Native
Admin Experience
100%
Open Source

The Divide Between Content and Experience

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.

The Old Way vs. The New Way

TRADITIONAL
Browser PHP Server MySQL
Every request = render PHP, query DB, build HTML, serve. One bottleneck after another.
NEXT.JS + HEADLESS WP
Browser Next.js Edge ←→ WP REST API WordPress
Static pages pre-built at deploy. Dynamic data fetched once, cached globally. Editors publish as always.

How Next.js and WordPress Actually Talk to Each Other

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.

🌎 Citizen
Next.js Edge
📦 CDN Cache
🔗 WP REST API
🗃 WordPress

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.

The Request Lifecycle

Next.js App Router — Page Component
// 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>
  );
}

Building the Machine

Twenty-seven minutes, start to finish. A Next.js application backed by WordPress, deployed and ready for your first citizen.

1

Prepare WordPress as an API

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.

PHP — functions.php
// 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']);
    },
  ]);
});
2

Scaffold a Next.js Application

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.

Bash
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
3

Build the API Layer

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.

TypeScript — src/lib/wp.ts
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;
}
4

Compose Pages with Server Components

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.

TypeScript — app/services/page.tsx
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>
  );
}
5

Configure Webhook Revalidation

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.

TypeScript — app/api/revalidate/route.ts
// 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 });
}

What This Makes Possible

Not theoretical. These are applications I have built and seen deployed in cities across the country.

🏛

Service Catalogs

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.

📋

Multi-Step Applications

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.

👤

Citizen Portals

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.

📈

Data Dashboards

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.

📚

Community Portals

Neighborhood-specific news, event calendars, document libraries, and discussion threads — moderated through the WordPress admin, surfaced through tailored Next.js views.

📝

Document Generation

Permits, certificates, inspection reports — generated as PDFs from React components on the server, populated with WordPress data, cached for download at any time.

A Living Example

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.

Checking connection... WP Admin ↗

Services are fetched live from /wp-json/gov/v1/services. Edit any service or add a new one below.

Loading services from WordPress...

"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."

Let's Build Something
Your Citizens Will Thank You For

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 →