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 @@ -[![Deploy with Vercel](https://vercel.com/button)](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) +[![Deploy with Vercel](https://vercel.com/button)](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. + ![Shopify store webhooks](https://github.com/vercel/commerce/assets/446260/3d713fd7-b642-46e2-b2ce-f2b695ff6d2b) + ![Shopify store add webhook](https://github.com/vercel/commerce/assets/446260/f0240a22-be07-42bc-bf6c-b97873868677) + +#### 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. + ![ngrok](https://github.com/vercel/commerce/assets/446260/5dc09c5d-0e48-479c-ab64-de8dc9a2c4b1) + ![Shopify store edit webhook](https://github.com/vercel/commerce/assets/446260/13fd397d-4666-4e8d-b25f-4adc674345c0) +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. + ![Shopify store webhook send test notification](https://github.com/vercel/commerce/assets/446260/e872e233-1663-446d-961f-8c9455358530) + +
### 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 { const res = await shopifyFetch({ query: getMenuQuery, + tags: [TAGS.collections], variables: { handle } @@ -344,6 +352,7 @@ export async function getPages(): Promise { export async function getProduct(handle: string): Promise { const res = await shopifyFetch({ query: getProductQuery, + tags: [TAGS.products], variables: { handle } @@ -355,6 +364,7 @@ export async function getProduct(handle: string): Promise { export async function getProductRecommendations(productId: string): Promise { const res = await shopifyFetch({ query: getProductRecommendationsQuery, + tags: [TAGS.products], variables: { productId } @@ -374,6 +384,7 @@ export async function getProducts({ }): Promise { const res = await shopifyFetch({ query: getProductsQuery, + tags: [TAGS.products], variables: { query, reverse,