Add a headless blog to your Next.js app in 20 minutes with NextPress

20 minutes. Five files. A create-next-app project pulls posts from a WordPress install over WPGraphQL, the editor still owns every paragraph, and next build ships static pages with revalidate set to whatever number you want. The walk-through below points at woographqldemo.wpengine.com — swap in your own domain at the end and the same six steps apply.

What’s in the box

@axistaylor/nextpress is one npm package and roughly 6,000 lines of TypeScript. It carries three pieces you wire into a Next.js 16 app:

  • withWCR — a NextConfig wrapper that sets up the WP reverse-proxy domain and forwards /wp-content, /wp-admin, and /graphql through the Next runtime.
  • <Content> — a React component that takes the raw HTML WordPress returns and renders it as real React nodes, plus the per-block CSS each one needs.
  • nextImageParser — a parser that swaps every <img> in WP content for a next/image with width, height, and sizes pulled from the block attributes.

Backend requirement: WordPress with WPGraphQL active. Any host works — WP Engine, Pantheon, Bluehost, a self-managed VPS — as long as /graphql responds.


Step 1 — Bootstrap the Next app

Stock create-next-app with the TypeScript template, then add @axistaylor/nextpress from npm:

npx create-next-app@latest nextpress-quickstart
cd nextpress-quickstart
npm install @axistaylor/nextpress

Step 2 — next.config.ts

withWCR takes three arguments: the base Next config, the WordPress origin, and the public-facing frontend origin. The wrapper rewrites every absolute WordPress URL the editor authored into a frontend-relative path so links inside rendered post content land back on your Next routes.

next.config.ts
import type { NextConfig } from "next";
import { withWCR } from "@axistaylor/nextpress/withWCR";

const wpDomain = "woographqldemo.wpengine.com";
const wpProtocol = "https";

const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    remotePatterns: [{ protocol: "https", hostname: wpDomain }],
  },
  env: {
    GRAPHQL_ENDPOINT: `${wpProtocol}://${wpDomain}/graphql`,
  },
};

export default withWCR(
  nextConfig,
  {
    wpDomain,
    wpProtocol,
    wpHomeUrl: `${wpProtocol}://${wpDomain}`,
    wpSiteUrl: `${wpProtocol}://${wpDomain}`,
  },
  {
    frontendDomain: "localhost:3000",
    frontendProtocol: "http",
  },
);

Step 3 — src/proxy.ts

Next 16 renamed middleware.ts to proxy.ts; the role and matcher behaviour didn’t change. The handler does two things — forward the /wp-* paths through proxyByWCR so the browser never touches the WordPress origin, and set an x-uri header on every non-proxied request so server components downstream know which URI to ask the backend for enqueued scripts and stylesheets:

src/proxy.ts
import { NextResponse, NextRequest } from "next/server";
import { proxyByWCR, isProxiedRoute } from "@axistaylor/nextpress/proxyByWCR";

export const proxy = async (request: NextRequest) => {
  const pathname = request.nextUrl.pathname;

  if (isProxiedRoute(pathname)) {
    return proxyByWCR(request);
  }

  const headers = new Headers(request.headers);
  headers.set("x-uri", pathname);
  return NextResponse.next({ request: { headers } });
};

export const config = {
  matcher: [
    "/atx/:instance/proxiee",
    "/atx/:instance/wp",
    "/atx/:instance/wc",
    "/atx/:instance/wp-internal-assets/:path*",
    "/atx/:instance/wp-assets/:path*",
    "/atx/:instance/wp-json/:path*",
    "/((?!_next|api|favicon.ico|sw.js|.*\\.).*)",
  ],
};

Step 4 — src/lib/wp.ts

Three async functions, one shared fetch wrapper. fetchPosts feeds the index page, fetchPostBySlug feeds the post page, fetchPostSlugs drives generateStaticParams. The next: { revalidate: 60 } hint on the fetch is the only cache control you need — Next handles the rest:

src/lib/wp.ts
const ENDPOINT = process.env.GRAPHQL_ENDPOINT!;

async function gql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error(`WPGraphQL ${res.status}`);
  const { data, errors } = await res.json();
  if (errors?.length) throw new Error(errors[0].message);
  return data as T;
}

export interface PostSummary {
  slug: string;
  title: string;
  date: string;
  excerpt: string | null;
  featuredImage: { sourceUrl: string; altText: string } | null;
}

export interface Post extends PostSummary {
  content: string;
  contentCssClasses: string;
}

export async function fetchPosts(first = 20): Promise<PostSummary[]> {
  const data = await gql<{ posts: { nodes: PostSummary[] } }>(`
    query Posts($first: Int!) {
      posts(first: $first, where: { status: PUBLISH, orderby: { field: DATE, order: DESC } }) {
        nodes {
          slug title date excerpt
          featuredImage { node { sourceUrl altText } }
        }
      }
    }`, { first });
  return data.posts.nodes;
}

export async function fetchPostSlugs(): Promise<string[]> {
  const data = await gql<{ posts: { nodes: { slug: string }[] } }>(`
    { posts(first: 100, where: { status: PUBLISH }) { nodes { slug } } }`);
  return data.posts.nodes.map(n => n.slug);
}

export async function fetchPostBySlug(slug: string): Promise<Post | null> {
  const data = await gql<{ post: Post | null }>(`
    query Post($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        slug title date excerpt
        content(format: RENDERED)
        contentCssClasses
        featuredImage { node { sourceUrl altText } }
      }
    }`, { slug });
  return data.post;
}

Step 5 — /blog index

A server component, one await, no client hooks. The post array is just JSX:

src/app/blog/page.tsx
import Link from "next/link";
import { fetchPosts } from "@/lib/wp";

export const metadata = { title: "Blog" };

export default async function BlogIndex() {
  const posts = await fetchPosts(20);
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          <time>{new Date(post.date).toLocaleDateString()}</time>
        </li>
      ))}
    </ul>
  );
}

Step 6 — /blog/[slug]

generateStaticParams turns every published slug into a build-time route. The render is two lines: a heading and a <Content>.

src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { Content, nextImageParser } from "@axistaylor/nextpress";
import { fetchPostBySlug, fetchPostSlugs } from "@/lib/wp";

export async function generateStaticParams() {
  const slugs = await fetchPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function PostPage({ params }: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await fetchPostBySlug(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <Content
        content={post.content}
        contentCssClasses={post.contentCssClasses}
        parsers={[nextImageParser()]}
      />
    </article>
  );
}

Five files, two domains, one npx. The editor never moves; the delivery layer changes underneath it.

The WordPress block editor on the left and the same content rendered through Next.js on the right — paragraph, heading, and list blocks come across identically with no template duplication.

What <Content> does for you

The post body is a string of WordPress block HTML. Pasting it into a dangerouslySetInnerHTML would render it, but you’d lose three things in the process. <Content> handles them:

  • Parses the HTML into a React tree. Every <p>, <figure>, <blockquote> is a real React node you can override with a parser — that’s how nextImageParser swaps <img> for next/image with zero markup changes upstream.
  • Pulls each block’s CSS into the response. Block-supports inline rules, theme.json palette tokens, plugin-injected sheets — they ship inline with the page so there’s no layout shift after hydration.
  • Keeps <script type="application/json"> blobs intact. That’s how interactive blocks (motion engines, accordions, anything with a view-script) hand state to their client-side code without a separate API roundtrip.

Next moves

The same six files extend in a few obvious directions. Categories and tags are one more WPGraphQL where argument on fetchPosts. Comments and pagination ride along on the same query. Search hits the search argument and you ship a /search?q=... route in another twenty minutes.

Full source on GitHub. Package docs at /docs/nextpress. If you want the architectural why behind keeping WordPress and adding Next.js as a delivery layer, that piece is coming.



Leave a Reply

Your email address will not be published. Required fields are marked *