A modern headless blog built with Next.js 16, React 19, and TailwindCSS. This application decouples the frontend from the backend, allowing you to manage content via a headless CMS while displaying it through a fast, responsive Next.js interface.
- Headless Architecture: Separates content management from presentation
- Server-Side Rendering: Fast page loads with Next.js SSR capabilities
- Responsive Design: Built with TailwindCSS for mobile-first styling
- User Authentication: Login and user management support
- Comments System: Interactive comment functionality
- Category & Tag Support: Organize posts by categories and tags
- Dynamic Routing: URL-based post, category, and tag pages
headless-simple-blog/
├── components/ # Reusable React components
│ ├── ArchiveTemplate.js # Shared layout for category and tag archives
│ ├── CommentForm.js
│ ├── CommentThread.js
│ ├── CurrentUser.js # Fetches the logged-in WordPress user
│ ├── DashboardLayout.js
│ ├── Footer.js
│ ├── Header.js
│ ├── Layout.js
│ └── WPMenu.js # WordPress menu
├── docker/
│ └── wordpress/
│ ├── mu-plugins/ # Optional must-use plugins, mounted into WordPress
│ ├── plugins/ # Mounted as wp-content/plugins, filled by setup.sh (git-ignored)
│ └── setup.sh # Installs WordPress, plugins and demo content
├── lib/ # Data fetching and helpers
│ ├── api.js # WordPress REST API calls
│ ├── content.js # Table of contents from post headings
│ ├── engagement.js # Saved posts and comment upvotes (browser storage)
│ ├── format.js # Dates, reading time, text helpers
│ ├── httpClient.js # HTTP client with retries
│ ├── sanitize.js # Sanitizes WordPress HTML
│ ├── useAuth.js # Reads the login cookie
│ ├── useCurrentUser.js # Login cookie plus the full user record
│ └── wp.js # Helpers for embedded REST data
├── pages/ # Routes (Next.js Pages Router)
│ ├── category/ # Category index and archive
│ ├── dashboard/ # Logged-in area: overview, posts, comments, saved, profile
│ ├── posts/[slug].js # Single post with comments
│ ├── tag/ # Tag index and archive
│ ├── _app.js
│ ├── 404.js
│ ├── index.js # Home: latest posts
│ ├── login.js # JWT login
│ └── search.js # Search
├── public/ # Static assets
├── styles/
│ ├── blog.css
│ ├── globals.css
│ └── modernist.css
├── .dockerignore # Keeps env files and build output out of the image
├── .env # Setup B: JWT secret for Docker Compose (git-ignored, you create it)
├── .env.docker # Setup B: frontend env for Docker (git-ignored, you create it)
├── .env.local # Setup A: frontend env without Docker (git-ignored, you create it)
├── .gitignore
├── compose.yaml # Setup B: MySQL, WordPress, WP-CLI, frontend
├── Dockerfile # Setup B: production image for the frontend
├── eslint.config.mjs
├── next.config.ts # Next.js config (image hosts, standalone output for Docker)
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── README.md
├── tailwind.config.js
└── tsconfig.json
There are two separate ways to run this project on your machine. Pick one, and run only one at a time.
| Setup A: Local (without Docker) | Setup B: Docker | |
|---|---|---|
| WordPress | Your own site, for example Local by Flywheel | A container at http://wp.localhost:8090 |
| Frontend | npm on your machine |
Containers |
| Frontend URL | http://localhost:3000 | http://localhost:3000 (dev) and http://localhost:3002 (production image) |
| Env files | .env.local |
.env and .env.docker |
| You install | Node.js and a WordPress site | Docker Desktop only |
| Demo content | Whatever is in your WordPress | Imported automatically |
| Good for | Daily work against your own WordPress | A complete, repeatable stack and testing the production image |
Both setups serve the frontend on port 3000, and each one talks to a different WordPress.
-
Before Setup A, stop Docker:
docker compose --profile prod stop
-
Before Setup B, stop the local server: press Ctrl+C in the terminal running
npm run devornpm start. -
After switching, log out and log in again in the frontend. A login from one WordPress is not valid on the other.
| Command | Setup | Reads | Talks to |
|---|---|---|---|
npm run dev |
A | .env.local |
Your own WordPress |
npm run build, npm start |
A | .env.local |
Your own WordPress |
docker compose up -d |
B | .env, .env.docker |
Docker WordPress |
docker compose --profile prod up -d frontend-prod |
B | .env, .env.docker |
Docker WordPress |
All three files are git-ignored. Create only the ones your setup needs.
| File | Read by | Setup | Holds |
|---|---|---|---|
.env.local |
Next.js on your machine | A | Addresses of your own WordPress |
.env |
Docker Compose | B | JWT_AUTH_SECRET_KEY for the WordPress container |
.env.docker |
The frontend containers | B | Addresses of the Docker WordPress |
npmcommands on your machine never read.env.docker.- Keep only
JWT_AUTH_SECRET_KEYin.env. Next.js also reads.env, so frontend variables placed there would leak into Setup A. - Inside the Docker dev container, values from
.env.dockeralways win over.env.local.
The frontend variables are the same in .env.local and .env.docker; only the addresses differ.
| Variable | What it is |
|---|---|
SITE_DOMAIN |
WordPress hostname without http://. Images are allowed from this host. |
NEXT_PUBLIC_API_SITE_URL |
WordPress base URL |
NEXT_PUBLIC_API_URL |
REST API base, ending in /wp-json/wp/v2 |
NEXT_PUBLIC_API_URL_JWT |
REST API root, ending in /wp-json |
NEXT_PUBLIC_API_FOR_JWT_TOKEN |
JWT login endpoint, ending in /wp-json/jwt-auth/v1/token |
NEXT_PUBLIC_POSTS_PER_PAGE |
Posts per page |
ALLOW_LOCAL_IMAGE_IP |
true lets production mode optimize images from a WordPress on a private address. Local use only. |
- Node.js 20.9 or newer, with npm
- A WordPress site your machine can reach (Local by Flywheel, MAMP, a staging site) with:
-
permalinks set to "Post name" (Settings → Permalinks)
-
JWT Authentication for WP REST API installed and active
-
these two lines in
wp-config.php:define( 'JWT_AUTH_SECRET_KEY', 'a-long-random-string' ); define( 'JWT_AUTH_CORS_ENABLE', true );
-
a few published posts with featured images. wp-cli-post-importer can create them with
wp start import-posts.
-
On Apache, the Authorization header also has to reach PHP. The JWT plugin's installation notes show the .htaccess line for that.
git clone https://github.com/anamwp/headless-simple-blog.git
cd headless-simple-blog
npm installReplace your-site.local with the address of your WordPress, and http with https if your site uses it.
SITE_DOMAIN=your-site.local
NEXT_PUBLIC_API_SITE_URL=http://your-site.local
NEXT_PUBLIC_API_URL=http://your-site.local/wp-json/wp/v2
NEXT_PUBLIC_API_URL_JWT=http://your-site.local/wp-json
NEXT_PUBLIC_API_FOR_JWT_TOKEN=http://your-site.local/wp-json/jwt-auth/v1/token
NEXT_PUBLIC_POSTS_PER_PAGE=9.env and .env.docker are not used in this setup.
Start your WordPress site, then:
docker compose --profile prod stop # only if the Docker setup is running
npm run devOpen http://localhost:3000.
npm run build
npm startWordPress must be running during the build, because pages are generated from the REST API.
Production mode optimizes images on the server. That adds two requirements when WordPress runs on your own machine:
-
Add
ALLOW_LOCAL_IMAGE_IP=trueto.env.localand build again. Without it, Next.js refuses image hosts on a private address. -
If WordPress uses a self-signed HTTPS certificate (Local by Flywheel does), tell Node to trust it when you start the server. Local by Flywheel usually keeps the certificate here:
NODE_EXTRA_CA_CERTS="$HOME/Library/Application Support/Local/run/router/nginx/certs/your-site.local.crt" npm start
Neither is needed for npm run dev, because dev mode does not optimize images.
Run the whole stack with one tool: MySQL, WordPress (with demo posts), and the Next.js frontend. No local PHP, MySQL, or Node install is needed.
| Service | Image | URL | Purpose |
|---|---|---|---|
db |
mysql:8.4 |
internal only | WordPress database |
wordpress |
wordpress:php8.3-apache |
http://wp.localhost:8090 | Headless CMS and REST API |
wpcli |
wordpress:cli |
– | One-off WP-CLI commands (profile tools) |
frontend |
node:22-alpine |
http://localhost:3000 | next dev with hot reload |
frontend-prod |
built from Dockerfile |
http://localhost:3002 | Production image (profile prod) |
- Docker Desktop with Compose v2
- Git
Developed and tested with Docker Desktop on macOS (Apple Silicon).
Stop any npm run dev or npm start running on your machine first. It holds port 3000, which the Docker frontend needs.
git clone https://github.com/anamwp/headless-simple-blog.git
cd headless-simple-blogBoth files are git-ignored and never copied into an image.
.env is read by Docker Compose and holds the JWT signing key for WordPress:
echo "JWT_AUTH_SECRET_KEY=$(openssl rand -hex 32)" > .env.env.docker is read by the frontend containers:
cat > .env.docker <<'EOF'
SITE_DOMAIN=wp.localhost
NEXT_PUBLIC_API_SITE_URL=http://wp.localhost:8090
NEXT_PUBLIC_API_URL=http://wp.localhost:8090/wp-json/wp/v2
NEXT_PUBLIC_API_URL_JWT=http://wp.localhost:8090/wp-json
NEXT_PUBLIC_API_FOR_JWT_TOKEN=http://wp.localhost:8090/wp-json/jwt-auth/v1/token
NEXT_PUBLIC_POSTS_PER_PAGE=9
ALLOW_LOCAL_IMAGE_IP=true
EOF.env.local is not needed for this setup.
docker compose up -d db wordpress
docker compose run --rm wpcli sh /setup.shsetup.sh is safe to run again. It:
- installs WordPress at
http://wp.localhost:8090(loginadmin/admin) - enables pretty permalinks so
/wp-json/works - installs and activates JWT Authentication for WP REST API
- downloads the latest wp-cli-post-importer from GitHub (re-run
setup.shany time to update it) - makes Apache pass the
Authorizationheader to PHP - imports demo posts with featured images (skipped when 5 or more posts exist)
- flushes permalinks, the same as saving Settings → Permalinks in wp-admin
docker compose up -d frontend
docker compose logs -f frontend # first start runs npm ci, wait for "Ready"Open http://localhost:3000. WordPress admin is at http://wp.localhost:8090/wp-admin.
docker compose up -d # start db, wordpress, frontend
docker compose stop # stop, keep all data
docker compose down # remove containers, keep data
docker compose down -v # remove everything, including the databaseAfter editing .env.docker, recreate the frontend so it reads the new values:
docker compose up -d --force-recreate frontenddocker compose run --rm wpcli wp start import-posts # first batch
docker compose run --rm wpcli wp start import-all-posts # everything
docker compose run --rm wpcli wp start delete-posts # remove the first batch
docker compose run --rm wpcli wp start delete-all-posts # remove everything importedAny other WP-CLI command works the same way, for example docker compose run --rm wpcli wp plugin list.
WordPress only accepts REST comments from logged-in users by default. To let guests comment, create docker/wordpress/mu-plugins/allow-guest-comments.php:
<?php
add_filter( 'rest_allow_anonymous_comments', '__return_true' );The folder is mounted into WordPress, so the file is active right away.
Dockerfile is a three-stage build (deps → build → runtime) that runs as the non-root node user. It sets BUILD_STANDALONE=true, which switches next.config.ts to Next.js standalone output. Outside Docker that variable is unset, so npm run build and hosted builds are unaffected.
WordPress must be running during the build, because static pages are generated from the REST API.
docker compose up -d db wordpress
docker compose --profile prod build frontend-prod
docker compose --profile prod up -d frontend-prodOpen http://localhost:3002. It can run next to the dev container on port 3000.
frontend-prod is in the prod profile, so plain docker compose stop and docker compose down skip it. Include the profile to stop everything:
docker compose --profile prod stop
docker compose --profile prod downThings to know:
-
.env.dockerreaches the build as a BuildKit secret, so its values are not stored in any image layer. -
NEXT_PUBLIC_*values are baked in at build time. Docker does not detect changes inside a secret, so after editing.env.dockerrebuild with--no-cache:docker compose --profile prod build --no-cache frontend-prod docker compose --profile prod up -d frontend-prod
-
ALLOW_LOCAL_IMAGE_IP=truelets the Next.js image optimizer fetch images from WordPress on a private address. It is only for local use. Leave it unset on real deployments.
The browser and the Next.js server both call WordPress at http://wp.localhost:8090:
- In the browser,
*.localhostresolves to your machine (Chrome and Firefox do this automatically). - Inside the frontend containers,
extra_hostsmapswp.localhostto the Docker host.
Safari does not resolve *.localhost. Add it once:
echo "127.0.0.1 wp.localhost" | sudo tee -a /etc/hosts| Symptom | Fix |
|---|---|
port is already allocated / address already in use |
Another process uses 3000, 3002 or 8090, most often a local npm run dev from Setup A. Stop it, or change the left side of the port mapping in compose.yaml. |
| Dashboard returns 403 | The browser holds a login from the other setup or an old secret. Log out (or delete the user_data cookie) and log in again. |
npm run build fails while building frontend-prod |
WordPress was not running during the build. Run docker compose up -d db wordpress first, then build again. |
| Images missing on port 3002 only | Check ALLOW_LOCAL_IMAGE_IP=true is in .env.docker, then rebuild with --no-cache. |
jwt_auth_bad_config on login |
JWT_AUTH_SECRET_KEY is missing from .env. Add it, then docker compose up -d --force-recreate wordpress. |
Error: 'start' is not a registered wp command |
The importer plugin did not download. Check your internet connection and run setup.sh again. |
| Frontend shows no posts | Check WordPress answers: curl http://wp.localhost:8090/wp-json/wp/v2/posts?per_page=1. |
frontend-prod still running after docker compose stop |
Add the profile: docker compose --profile prod stop. |
- Framework: Next.js 16
- UI Library: React 19
- Styling: TailwindCSS 4
- HTTP Client: Axios
- Linting: ESLint 10
- Language: TypeScript 5
- TypeScript Config:
tsconfig.json - Next.js Config:
next.config.ts - TailwindCSS Config:
tailwind.config.js - PostCSS Config:
postcss.config.js
Deploy on Vercel for the best experience with Next.js:
- Push your code to a Git repository
- Connect your repository to Vercel
- Add the frontend variables from the Environment files table in the Vercel project settings, pointing at a WordPress that is reachable from the internet. Do not set
ALLOW_LOCAL_IMAGE_IPorBUILD_STANDALONEthere. - Deploy
For other hosting options, check the Next.js deployment documentation.