Headless Next.js Storefront with WordPress Content and Shopify Commerce, plus a Shopify App
One Next.js front end that reads content from WordPress and products from Shopify, with a unified cart, a custom embedded Shopify app, and webhook-driven revalidation.
- Client
- DTC brand with a WordPress marketing site and a Shopify store
- My role
- Full-stack developer (Next.js, WordPress and Shopify app)
- Next.js App Router
- WordPress REST
- WPGraphQL
- ACF
- Shopify Storefront API
- Shopify Admin app
- ISR
- TypeScript
- front end for WordPress content and Shopify commerce
- 1
- mandatory GDPR webhooks implemented
- 3
- product and order webhooks registered
- 3
- PCI scope kept by using Shopify's hosted checkout
- SAQ A
front end for WordPress content and Shopify commerce
mandatory GDPR webhooks implemented
product and order webhooks registered
PCI scope kept by using Shopify's hosted checkout
The challenge
A DTC brand wanted a marketing site on WordPress and a storefront on Shopify, with a single front end that felt like one brand. Their previous setup was two disconnected sites with duplicate navigation and a checkout that dropped people onto a different-looking domain.
What I built
- 01
Next.js App Router front end
Content comes from WordPress (REST and WPGraphQL) and commerce from Shopify (Storefront API, GraphQL). Marketing pages use ISR from WordPress, while product and collection pages are server-rendered from Shopify with revalidation on webhook.
- 02
Unified cart
The Shopify cart ID is persisted in an HTTP-only cookie and cart mutations go through the Storefront API. Checkout is handed off to Shopify's hosted checkout, which keeps PCI scope at SAQ A.
- 03
Embedded Shopify app (Smartlink Shopify app)
An embedded admin app syncs product data to an external service. It registers webhooks for products/create, products/update and orders/paid, and uses session tokens for authenticated admin requests.
- 04
App install and compliance
The app has an OAuth install flow, HMAC verification on every webhook, and the mandatory GDPR webhook set: customers/data_request, customers/redact and shop/redact.
- 05
WordPress endpoints for the front end
Custom fields are exposed with register_rest_field, a WPGraphQL schema extension covers ACF fields, and a preview endpoint lets editors see drafts in the headless front end through Next.js draft mode.
- 06
Webhook-driven revalidation
Shopify product and collection updates, and WordPress publish events, call a Next.js revalidation route with a shared secret. The front end updates without a full rebuild.
Gotchas and how I fixed them
Read the raw body before verifying the HMAC
Shopify signs the exact request body. Call req.text() before any JSON parsing, compute the HMAC from that raw string, and compare with timingSafeEqual.
Multi-currency needs @inContext
Without the @inContext directive on Storefront API queries, every market sees base-currency prices.
Expose only what the front end renders
Returning just the needed ACF fields through register_rest_field keeps payloads small and avoids leaking admin-only field groups.
Code from the build
import crypto from 'crypto';
export async function POST(req: Request) {
const raw = await req.text(); // MUST be before JSON.parse
const hmac = req.headers.get('x-shopify-hmac-sha256') ?? '';
const digest = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
.update(raw, 'utf8')
.digest('base64');
if (
digest.length !== hmac.length ||
!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac))
) {
return new Response('Unauthorized', { status: 401 });
}
const topic = req.headers.get('x-shopify-topic');
const payload = JSON.parse(raw);
// Respond fast; do the work in the background.
if (topic === 'products/update') {
await revalidateProduct(payload.handle);
await revalidateCollectionsForProduct(payload.id);
}
return new Response('ok', { status: 200 });
}Awaiting req.text() before any parsing is the whole trick, and timingSafeEqual is what makes this a real signature check.
const PRODUCT_QUERY = `
query Product($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
id
title
descriptionHtml
featuredImage { url altText width height }
priceRange { minVariantPrice { amount currencyCode } }
variants(first: 50) {
nodes { id title availableForSale price { amount currencyCode } selectedOptions { name value } }
}
}
}
`;@inContext is not optional for multi-currency, because without it every market sees base-currency prices.
add_action( 'rest_api_init', function () {
register_rest_field( 'post', 'acf_fields', [
'get_callback' => function ( $post_arr ) {
return [
'hero' => get_field( 'hero', $post_arr['id'] ),
'excerpt' => get_field( 'custom_excerpt', $post_arr['id'] ),
];
},
'schema' => [
'description' => 'ACF fields for the headless front end.',
'type' => 'object',
],
] );
} );Exposing only the fields the front end renders keeps the payload small and avoids leaking admin-only field groups.
Results
- One Next.js front end replaced two disconnected sites, so navigation and branding are shared instead of duplicated.
- Checkout stays on Shopify's hosted checkout, keeping PCI scope at SAQ A.
- The Shopify app registers 3 webhooks (products/create, products/update, orders/paid) and implements all 3 mandatory GDPR webhooks, with HMAC verification on every request.
- Product, collection and WordPress content updates reach the site through webhook-triggered revalidation, with no full rebuild.
Questions people ask
Can I use WordPress for content and Shopify for products on one Next.js site?
Yes. The Next.js App Router front end pulls content from WordPress through REST and WPGraphQL and commerce from the Shopify Storefront API. Marketing pages use ISR from WordPress, while product and collection pages are server-rendered from Shopify.
How does checkout work in a headless Shopify setup?
The Shopify cart ID is kept in an HTTP-only cookie, cart changes go through the Storefront API, and checkout is handed off to Shopify's hosted checkout. That keeps PCI scope at SAQ A.
How do I verify Shopify webhooks in Next.js?
Read the raw body with await req.text() before any JSON parsing, compute an HMAC SHA-256 with your webhook secret, base64-encode it, and compare it to the x-shopify-hmac-sha256 header with crypto.timingSafeEqual. Return 401 on a mismatch.
How do editors preview WordPress drafts on a headless front end?
A preview endpoint on the WordPress side works with Next.js draft mode, so editors can see unpublished content on the real front end.
What does a custom Shopify app need to pass review basics?
This app uses an OAuth install flow, session tokens for admin requests, HMAC verification on every webhook, and the mandatory GDPR webhooks: customers/data_request, customers/redact and shop/redact.