diff --git a/.env.example b/.env.example
index 088014635..dc6582eb5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,6 @@
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
+SHOPIFY_REVALIDATION_SECRET=
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
SHOPIFY_STORE_DOMAIN=
diff --git a/README.md b/README.md
index da03958fe..c9342d40a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=SHOPIFY_STOREFRONT_ACCESS_TOKEN,SHOPIFY_STORE_DOMAIN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
+[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SHOPIFY_STORE_DOMAIN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
# Next.js Commerce
@@ -157,7 +157,46 @@ You can use Shopify's admin to customize these pages to match your brand and des
### Configure webhooks for on-demand incremental static regeneration (ISR)
-Coming soon.
+Utilizing [Shopify's webhooks](https://shopify.dev/docs/apps/webhooks), and listening for select [Shopify webhook event topics](https://shopify.dev/docs/api/admin-rest/2022-04/resources/webhook#event-topics), we can use [Next'js on-demand revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating#using-on-demand-revalidation) to keep data fetches indefinitely cached until certain events in the Shopify store occur.
+
+Next.js is pre-configured to listen for the following Shopify webhook events and automatically revalidate fetches.
+
+- `collections/create`
+- `collections/delete`
+- `collections/update`
+- `products/create`
+- `products/delete`
+- `products/update` (this also includes when variants are added, updated, and removed as well as when products are purchased so inventory and out of stocks can be updated)
+
+
+ Expand to view detailed walkthrough
+
+#### Setup secret for secure revalidation
+
+1. Create your own secret or [generate a random UUID](https://www.uuidgenerator.net/guid).
+1. Create a [Vercel Environment Variable](https://vercel.com/docs/concepts/projects/environment-variables) named `SHOPIFY_REVALIDATION_SECRET` and use the value from above.
+
+#### Configure Shopify webhooks
+
+1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/notifications`.
+1. Add webhooks for all six event topics listed above. You can add more sets for other preview urls, environments, or local development. Append `?secret=[SECRET]` to each url, where `[SECRET]` is the secret you created above.
+ 
+ 
+
+#### Testing webhooks during local development
+
+The easiest way to test webhooks while developing locally is to use [ngrok](https://ngrok.com).
+
+1. [Install and configure ngrok](https://ngrok.com/download) (you will need to create an account).
+1. Run your app locally, `npm run dev`.
+1. In a separate terminal session, run `ngrok http 3000`.
+1. Use the url generated by ngrok and add or update your webhook urls in Shopify.
+ 
+ 
+1. You can now make changes to your store and your local app should receive updates. You can also use the `Send test notification` button to trigger a generic webhook test.
+ 
+
+
### Using Shopify as a CMS
diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts
new file mode 100644
index 000000000..94ddfff9b
--- /dev/null
+++ b/app/api/revalidate/route.ts
@@ -0,0 +1,37 @@
+import { TAGS } from 'lib/constants';
+import { revalidateTag } from 'next/cache';
+import { headers } from 'next/headers';
+import { NextRequest, NextResponse } from 'next/server';
+
+export const runtime = 'edge';
+
+// We always need to respond with a 200 status code to Shopify,
+// otherwise it will continue to retry the request.
+export async function POST(req: NextRequest): Promise {
+ const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
+ const productWebhooks = ['products/create', 'products/delete', 'products/update'];
+ const topic = headers().get('x-shopify-topic') || 'unknown';
+ const secret = req.nextUrl.searchParams.get('secret');
+ const isCollectionUpdate = collectionWebhooks.includes(topic);
+ const isProductUpdate = productWebhooks.includes(topic);
+
+ if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
+ console.error('Invalid revalidation secret.');
+ return NextResponse.json({ status: 200 });
+ }
+
+ if (!isCollectionUpdate && !isProductUpdate) {
+ // We don't need to revalidate anything for any other topics.
+ return NextResponse.json({ status: 200 });
+ }
+
+ if (isCollectionUpdate) {
+ revalidateTag(TAGS.collections);
+ }
+
+ if (isProductUpdate) {
+ revalidateTag(TAGS.products);
+ }
+
+ return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
+}
diff --git a/lib/constants.tsx b/lib/constants.tsx
index 9038127e3..99711221a 100644
--- a/lib/constants.tsx
+++ b/lib/constants.tsx
@@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
];
+export const TAGS = {
+ collections: 'collections',
+ products: 'products'
+};
+
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
export const DEFAULT_OPTION = 'Default Title';
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts
index 5d3972536..2e23a7f78 100644
--- a/lib/shopify/index.ts
+++ b/lib/shopify/index.ts
@@ -1,4 +1,4 @@
-import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT } from 'lib/constants';
+import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import {
addToCartMutation,
@@ -52,15 +52,17 @@ const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch({
- query,
- variables,
+ cache = 'force-cache',
headers,
- cache = 'force-cache'
+ query,
+ tags,
+ variables
}: {
- query: string;
- variables?: ExtractVariables;
- headers?: HeadersInit;
cache?: RequestCache;
+ headers?: HeadersInit;
+ query: string;
+ tags?: string[];
+ variables?: ExtractVariables;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
@@ -75,7 +77,7 @@ export async function shopifyFetch({
...(variables && { variables })
}),
cache,
- next: { revalidate: 900 } // 15 minutes
+ ...(tags && { next: { tags } })
});
const body = await result.json();
@@ -249,6 +251,7 @@ export async function getCart(cartId: string): Promise {
export async function getCollection(handle: string): Promise {
const res = await shopifyFetch({
query: getCollectionQuery,
+ tags: [TAGS.collections],
variables: {
handle
}
@@ -268,6 +271,7 @@ export async function getCollectionProducts({
}): Promise {
const res = await shopifyFetch({
query: getCollectionProductsQuery,
+ tags: [TAGS.collections, TAGS.products],
variables: {
handle: collection,
reverse,
@@ -284,7 +288,10 @@ export async function getCollectionProducts({
}
export async function getCollections(): Promise {
- const res = await shopifyFetch({ query: getCollectionsQuery });
+ const res = await shopifyFetch({
+ query: getCollectionsQuery,
+ tags: [TAGS.collections]
+ });
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
@@ -311,6 +318,7 @@ export async function getCollections(): Promise {
export async function getMenu(handle: string): Promise