mirror of
https://github.com/vercel/commerce.git
synced 2025-03-31 01:05:53 +00:00
Adds on-demand revalidation for collections and products. (#1042)
This commit is contained in:
parent
fecc60eb36
commit
e4fcf19321
@ -1,5 +1,6 @@
|
|||||||
TWITTER_CREATOR="@vercel"
|
TWITTER_CREATOR="@vercel"
|
||||||
TWITTER_SITE="https://nextjs.org/commerce"
|
TWITTER_SITE="https://nextjs.org/commerce"
|
||||||
SITE_NAME="Next.js Commerce"
|
SITE_NAME="Next.js Commerce"
|
||||||
|
SHOPIFY_REVALIDATION_SECRET=
|
||||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||||
SHOPIFY_STORE_DOMAIN=
|
SHOPIFY_STORE_DOMAIN=
|
||||||
|
43
README.md
43
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
|
# 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)
|
### 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)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Expand to view detailed walkthrough</summary>
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Using Shopify as a CMS
|
### Using Shopify as a CMS
|
||||||
|
|
||||||
|
37
app/api/revalidate/route.ts
Normal file
37
app/api/revalidate/route.ts
Normal file
@ -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<Response> {
|
||||||
|
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() });
|
||||||
|
}
|
@ -20,6 +20,11 @@ export const sorting: SortFilterItem[] = [
|
|||||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
{ 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 HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||||
export const DEFAULT_OPTION = 'Default Title';
|
export const DEFAULT_OPTION = 'Default Title';
|
||||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
||||||
|
@ -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 { isShopifyError } from 'lib/type-guards';
|
||||||
import {
|
import {
|
||||||
addToCartMutation,
|
addToCartMutation,
|
||||||
@ -52,15 +52,17 @@ const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
|||||||
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
||||||
|
|
||||||
export async function shopifyFetch<T>({
|
export async function shopifyFetch<T>({
|
||||||
query,
|
cache = 'force-cache',
|
||||||
variables,
|
|
||||||
headers,
|
headers,
|
||||||
cache = 'force-cache'
|
query,
|
||||||
|
tags,
|
||||||
|
variables
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
|
||||||
variables?: ExtractVariables<T>;
|
|
||||||
headers?: HeadersInit;
|
|
||||||
cache?: RequestCache;
|
cache?: RequestCache;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
query: string;
|
||||||
|
tags?: string[];
|
||||||
|
variables?: ExtractVariables<T>;
|
||||||
}): Promise<{ status: number; body: T } | never> {
|
}): Promise<{ status: number; body: T } | never> {
|
||||||
try {
|
try {
|
||||||
const result = await fetch(endpoint, {
|
const result = await fetch(endpoint, {
|
||||||
@ -75,7 +77,7 @@ export async function shopifyFetch<T>({
|
|||||||
...(variables && { variables })
|
...(variables && { variables })
|
||||||
}),
|
}),
|
||||||
cache,
|
cache,
|
||||||
next: { revalidate: 900 } // 15 minutes
|
...(tags && { next: { tags } })
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = await result.json();
|
const body = await result.json();
|
||||||
@ -249,6 +251,7 @@ export async function getCart(cartId: string): Promise<Cart | null> {
|
|||||||
export async function getCollection(handle: string): Promise<Collection | undefined> {
|
export async function getCollection(handle: string): Promise<Collection | undefined> {
|
||||||
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
||||||
query: getCollectionQuery,
|
query: getCollectionQuery,
|
||||||
|
tags: [TAGS.collections],
|
||||||
variables: {
|
variables: {
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
@ -268,6 +271,7 @@ export async function getCollectionProducts({
|
|||||||
}): Promise<Product[]> {
|
}): Promise<Product[]> {
|
||||||
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
||||||
query: getCollectionProductsQuery,
|
query: getCollectionProductsQuery,
|
||||||
|
tags: [TAGS.collections, TAGS.products],
|
||||||
variables: {
|
variables: {
|
||||||
handle: collection,
|
handle: collection,
|
||||||
reverse,
|
reverse,
|
||||||
@ -284,7 +288,10 @@ export async function getCollectionProducts({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCollections(): Promise<Collection[]> {
|
export async function getCollections(): Promise<Collection[]> {
|
||||||
const res = await shopifyFetch<ShopifyCollectionsOperation>({ query: getCollectionsQuery });
|
const res = await shopifyFetch<ShopifyCollectionsOperation>({
|
||||||
|
query: getCollectionsQuery,
|
||||||
|
tags: [TAGS.collections]
|
||||||
|
});
|
||||||
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
|
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
|
||||||
const collections = [
|
const collections = [
|
||||||
{
|
{
|
||||||
@ -311,6 +318,7 @@ export async function getCollections(): Promise<Collection[]> {
|
|||||||
export async function getMenu(handle: string): Promise<Menu[]> {
|
export async function getMenu(handle: string): Promise<Menu[]> {
|
||||||
const res = await shopifyFetch<ShopifyMenuOperation>({
|
const res = await shopifyFetch<ShopifyMenuOperation>({
|
||||||
query: getMenuQuery,
|
query: getMenuQuery,
|
||||||
|
tags: [TAGS.collections],
|
||||||
variables: {
|
variables: {
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
@ -344,6 +352,7 @@ export async function getPages(): Promise<Page[]> {
|
|||||||
export async function getProduct(handle: string): Promise<Product | undefined> {
|
export async function getProduct(handle: string): Promise<Product | undefined> {
|
||||||
const res = await shopifyFetch<ShopifyProductOperation>({
|
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||||
query: getProductQuery,
|
query: getProductQuery,
|
||||||
|
tags: [TAGS.products],
|
||||||
variables: {
|
variables: {
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
@ -355,6 +364,7 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
|
|||||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||||
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
||||||
query: getProductRecommendationsQuery,
|
query: getProductRecommendationsQuery,
|
||||||
|
tags: [TAGS.products],
|
||||||
variables: {
|
variables: {
|
||||||
productId
|
productId
|
||||||
}
|
}
|
||||||
@ -374,6 +384,7 @@ export async function getProducts({
|
|||||||
}): Promise<Product[]> {
|
}): Promise<Product[]> {
|
||||||
const res = await shopifyFetch<ShopifyProductsOperation>({
|
const res = await shopifyFetch<ShopifyProductsOperation>({
|
||||||
query: getProductsQuery,
|
query: getProductsQuery,
|
||||||
|
tags: [TAGS.products],
|
||||||
variables: {
|
variables: {
|
||||||
query,
|
query,
|
||||||
reverse,
|
reverse,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user