├── .babelrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── package.json ├── src ├── client │ └── index.tsx ├── index.tsx ├── lib │ └── shopify.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@babel/plugin-proposal-optional-chaining", 5 | { 6 | "loose": true 7 | } 8 | ] 9 | ], 10 | "presets": [ 11 | [ 12 | "@babel/preset-env", 13 | { 14 | "targets": { 15 | "node": "current" 16 | } 17 | } 18 | ], 19 | "@babel/preset-typescript", 20 | "@babel/preset-react" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .next 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 basement.studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-shopify 2 | 3 | [![from the basement.](https://basement.studio/gh-badge.svg)](https://basement.studio) 4 | 5 | > 🚨 Shopify is improving their APIs, and we are updating our integration. Don't use just yet. 6 | > 7 | > Take a look at Shopify's [Hydrogen](https://hydrogen.shopify.dev/). 8 | 9 | --- 10 | 11 | A context, a hook, and an API route handler, to manage a Shopify Storefront in your Next.js app. 12 | 13 | - ✅ Easy to use, Next.js friendly implementation of the [Shopify Storefront API](https://shopify.dev/api/storefront). 14 | - 🗄 Store your cart id in [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). 15 | - 🐎 Global app cache with [`react-query`](https://react-query.tanstack.com/). 16 | - 💥 API route handler with [catch-all API routes](https://nextjs.org/docs/api-routes/dynamic-api-routes#catch-all-api-routes). 17 | 18 | ## Install 19 | 20 | ```bash 21 | yarn add next-shopify 22 | ``` 23 | 24 | Or with npm: 25 | 26 | ```bash 27 | npm i next-shopify 28 | ``` 29 | 30 | ## Before You Start 31 | 32 | In order to use the Storefront API, which is what this library uses, you'll need to set up your Shopify Store with a private app. 33 | 34 | 1. Go to your private apps: `https:///admin/apps/private`, and create one. 35 | 2. Down at the bottom of your app's dashboard, you'll need to enable the Storefront API and give it the correct permissions. 36 | 3. Take hold of the Storefront Access Token — we'll need it later. 37 | 38 | ## Usage 39 | 40 | Just three steps and we'll be ready to roll. 41 | 42 | ```bash 43 | yarn add next-shopify 44 | ``` 45 | 46 | ### 1. Wrap Your Application with the Context Provider 47 | 48 | ```tsx 49 | // pages/_app.tsx 50 | import { AppProps } from 'next/app' 51 | import { ShopifyContextProvider } from 'next-shopify' 52 | 53 | const App = ({ Component, pageProps }: AppProps) => { 54 | return ( 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export default App 62 | ``` 63 | 64 | ### 2. Add the API Route 65 | 66 | We add an API Route, and we use `next-shopify`'s built in handler. 67 | 68 | ```ts 69 | // pages/api/shopify/[...storefront].ts 70 | import { handleShopifyStorefront } from 'next-shopify' 71 | 72 | // be sure to add the correct env variables. 73 | 74 | export default handleShopifyStorefront({ 75 | domain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN as string, 76 | storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN as string 77 | }) 78 | ``` 79 | 80 | ### 3. Use the Hook 81 | 82 | This is just an example. 83 | 84 | ```tsx 85 | import { useShopify } from 'next-shopify' 86 | 87 | export const Cart = () => { 88 | const { 89 | cart, 90 | cartToggleState 91 | // cartItemsCount, 92 | // onAddLineItem, 93 | // onRemoveLineItem, 94 | // onUpdateLineItem 95 | } = useShopify() 96 | 97 | if (!cartToggleState.isOn || !cart) return null 98 | return ( 99 |
100 |

Cart

101 | 102 | {cart.lineItems.map(lineItem => { 103 | return ( 104 |
105 |

{lineItem.title}

106 |
107 | ) 108 | })} 109 | Checkout 110 |
111 | ) 112 | } 113 | 114 | export const Header = () => { 115 | const { cartToggleState } = useShopify() 116 | 117 | return ( 118 | <> 119 |
120 | Logo 121 | 122 |
123 | 124 | 125 | ) 126 | } 127 | ``` 128 | 129 | ## Fetching Products 130 | 131 | In the following example, we explain how to use some helper methods to fetch products. Be aware that `shopify-buy` typings are wrong, and thus our methods can receive a custom `formatProduct` function that can help you have a better TypeScript experience. 132 | 133 | ```ts 134 | // lib/shopify.ts 135 | import { createClient } from 'next-shopify' 136 | 137 | const { fetchAllProducts, fetchProductByHandle, client } = createClient({ 138 | domain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN as string, 139 | storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN as string 140 | }) 141 | 142 | fetchAllProducts().then(products => { 143 | console.log(products) 144 | }) 145 | 146 | fetchProductByHandle('').then(product => { 147 | console.log(product) 148 | }) 149 | 150 | // Passing a formatter (for better TypeScript experience) -------- 151 | 152 | function formatProduct(p: ShopifyBuy.Product) { 153 | return { 154 | id: p.id.toString(), 155 | title: p.title, 156 | slug: (p as any).handle as string, // shopify buy typings are wrong, sorry for this... 157 | images: p.images.map(img => ({ 158 | src: img.src, 159 | alt: (img as any).altText ?? null 160 | })) 161 | } 162 | } 163 | 164 | fetchAllProducts(formatProduct).then(products => { 165 | console.log(products) 166 | }) 167 | 168 | fetchProductByHandle('', formatProduct).then(product => { 169 | console.log(product) 170 | }) 171 | 172 | // We also expose the whole client ------------------------------- 173 | 174 | console.log(client) 175 | ``` 176 | 177 | ## Using Other `shopify-buy` Methods 178 | 179 | [`shopify-buy`](https://www.npmjs.com/package/shopify-buy) is the official Storefront API JavaScript SDK. It's robust, but not easy to integrate — precisely why we created `next-shopify`. Therefore, if you still need to use other `shopify-buy` methods, we expose the whole client like this: 180 | 181 | ```ts 182 | // lib/shopify.ts 183 | import { createClient } from 'next-shopify' 184 | 185 | export const { client } = createClient({ 186 | domain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN as string, 187 | storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN as string 188 | }) 189 | ``` 190 | 191 | --- 192 | 193 | ![we make cool sh*t that performs](https://basement.studio/images/index/twitter-card.png) 194 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-shopify", 3 | "author": { 4 | "email": "julianbenegas99@gmail.com", 5 | "name": "Julian Benegas", 6 | "url": "https://julianbenegas.com" 7 | }, 8 | "version": "0.6.10", 9 | "main": "./dist/index.js", 10 | "module": "./dist/index.modern.js", 11 | "types": "./dist/index.d.ts", 12 | "source": "./src/index.tsx", 13 | "license": "MIT", 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "prepublish": "yarn build", 19 | "prebuild": "rm -rf dist", 20 | "build": "microbundle --jsx React.createElement --compress --no-sourcemap", 21 | "test": "jest" 22 | }, 23 | "dependencies": { 24 | "@types/shopify-buy": "^2.10.7", 25 | "react-query": "^3.21.0", 26 | "shopify-buy": "^2.11.0" 27 | }, 28 | "peerDependencies": { 29 | "next": "*", 30 | "react": "*", 31 | "react-dom": "*" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.13.10", 35 | "@babel/plugin-proposal-optional-chaining": "^7.11.0", 36 | "@babel/preset-env": "^7.13.10", 37 | "@babel/preset-react": "^7.12.13", 38 | "@babel/preset-typescript": "^7.13.0", 39 | "@testing-library/react": "^11.2.5", 40 | "@types/jest": "^26.0.21", 41 | "@types/next": "^9.0.0", 42 | "@types/react": "^16.9.53", 43 | "@typescript-eslint/eslint-plugin": "^4.31.0", 44 | "@typescript-eslint/parser": "^4.31.0", 45 | "babel-jest": "^26.6.3", 46 | "eslint": "^7.32.0", 47 | "eslint-config-prettier": "^8.3.0", 48 | "eslint-plugin-jsx-a11y": "^6.4.1", 49 | "eslint-plugin-prettier": "^4.0.0", 50 | "eslint-plugin-react": "^7.25.1", 51 | "eslint-plugin-react-hooks": "^4.2.0", 52 | "jest": "^26.6.3", 53 | "microbundle": "^0.12.3", 54 | "prettier": "^2.2.1", 55 | "react": "^17.0.1", 56 | "react-dom": "^17.0.1", 57 | "typescript": "^4.0.3" 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "https://github.com/basementstudio/next-shopify.git" 62 | }, 63 | "prettier": { 64 | "semi": false, 65 | "singleQuote": true, 66 | "trailingComma": "none", 67 | "arrowParens": "avoid" 68 | }, 69 | "eslintConfig": { 70 | "parser": "@typescript-eslint/parser", 71 | "plugins": [ 72 | "react", 73 | "react-hooks", 74 | "@typescript-eslint" 75 | ], 76 | "extends": [ 77 | "eslint:recommended", 78 | "plugin:react/recommended", 79 | "plugin:@typescript-eslint/recommended", 80 | "prettier", 81 | "plugin:prettier/recommended" 82 | ], 83 | "env": { 84 | "es6": true, 85 | "browser": true, 86 | "node": true 87 | }, 88 | "rules": { 89 | "react/react-in-jsx-scope": 0, 90 | "react/display-name": 0, 91 | "react/prop-types": 0, 92 | "@typescript-eslint/explicit-function-return-type": 0, 93 | "@typescript-eslint/explicit-member-accessibility": 0, 94 | "@typescript-eslint/indent": 0, 95 | "@typescript-eslint/member-delimiter-style": 0, 96 | "@typescript-eslint/no-explicit-any": 0, 97 | "@typescript-eslint/no-var-requires": 0, 98 | "@typescript-eslint/no-use-before-define": 0, 99 | "@typescript-eslint/ban-ts-comment": 0, 100 | "react-hooks/exhaustive-deps": "warn", 101 | "react/no-unescaped-entities": 0, 102 | "curly": [ 103 | "error", 104 | "multi-line" 105 | ], 106 | "react/jsx-no-target-blank": [ 107 | 2, 108 | { 109 | "allowReferrer": true 110 | } 111 | ], 112 | "@typescript-eslint/no-unused-vars": [ 113 | 2, 114 | { 115 | "argsIgnorePattern": "^_" 116 | } 117 | ], 118 | "no-console": [ 119 | 1, 120 | { 121 | "allow": [ 122 | "warn", 123 | "error" 124 | ] 125 | } 126 | ], 127 | "prettier/prettier": [ 128 | "warn" 129 | ], 130 | "@typescript-eslint/explicit-module-boundary-types": "off" 131 | }, 132 | "settings": { 133 | "react": { 134 | "version": "detect" 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import { ClientConfig, createClient } from '../lib/shopify' 2 | import * as React from 'react' 3 | import { 4 | QueryClient, 5 | QueryClientProvider, 6 | useMutation, 7 | useQuery, 8 | useQueryClient 9 | } from 'react-query' 10 | import { useToggleState, ToggleState } from '../utils' 11 | 12 | export type LineItem = { 13 | id: string 14 | title: string 15 | quantity: number 16 | variant: { 17 | title: string 18 | available: boolean 19 | image: { src: string; altText?: string } 20 | price: string 21 | sku: string 22 | selectedOptions: { name: string; value: string }[] 23 | product: { 24 | id: string 25 | handle: string 26 | } 27 | } 28 | } 29 | 30 | export type Cart = Omit< 31 | ShopifyBuy.Cart, 32 | 'checkoutUrl' | 'lineItems' | 'lineItemCount' | 'attrs' 33 | > & { 34 | webUrl: string 35 | lineItems: LineItem[] 36 | createdAt: string 37 | updatedAt: string 38 | currencyCode: string 39 | ready: boolean 40 | } 41 | 42 | type Context = { 43 | cartToggleState: ToggleState 44 | cart: Cart | undefined | null 45 | cartItemsCount: number | undefined 46 | onAddLineItem: (vars: { 47 | variantId: string 48 | quantity: number 49 | }) => Promise 50 | onRemoveLineItem: (vars: { variantId: string }) => Promise 51 | onUpdateLineItem: (vars: { 52 | variantId: string 53 | quantity: number 54 | }) => Promise 55 | } 56 | 57 | const ShopifyContext = React.createContext(undefined) 58 | 59 | const getQueryKey = (checkoutId: string | null) => ['checkout', checkoutId] 60 | 61 | const ContextProvider = ({ 62 | children, 63 | config, 64 | canCreateCheckout 65 | }: { 66 | children?: React.ReactNode 67 | config: ClientConfig 68 | canCreateCheckout?: () => boolean | Promise 69 | }) => { 70 | const cartToggleState = useToggleState() 71 | const [localStorageCheckoutId, setLocalStorageCheckoutId] = React.useState< 72 | string | null 73 | >(null) 74 | const queryClient = useQueryClient() 75 | 76 | const { client } = React.useMemo(() => { 77 | return createClient(config) 78 | }, [config]) 79 | 80 | React.useEffect(() => { 81 | const checkoutId = localStorage.getItem('checkout-id') 82 | if (checkoutId) setLocalStorageCheckoutId(checkoutId) 83 | }, []) 84 | 85 | const { data: cart } = useQuery( 86 | getQueryKey(localStorageCheckoutId), 87 | { 88 | enabled: !!localStorageCheckoutId, 89 | queryFn: async () => { 90 | if (!localStorageCheckoutId) return undefined 91 | const checkout = await client.checkout.fetch(localStorageCheckoutId) 92 | if (!checkout) { 93 | // checkout has expired or doesn't exist 94 | setLocalStorageCheckoutId(null) 95 | localStorage.removeItem('checkout-id') 96 | return null 97 | } 98 | return (checkout as unknown) as Cart 99 | }, 100 | refetchOnWindowFocus: false 101 | } 102 | ) 103 | 104 | const createCheckout = React.useCallback(async () => { 105 | // TODO here we should implement a queue system to prevent throttling the Storefront API 106 | // Remember: 1k created checkouts per minute is the limit (4k for Shopify Plus) 107 | if (canCreateCheckout && !(await canCreateCheckout())) return 108 | const checkout = await client.checkout.create() 109 | const checkoutId = checkout.id.toString() 110 | queryClient.setQueryData(getQueryKey(checkoutId), checkout) 111 | localStorage.setItem('checkout-id', checkoutId) 112 | setLocalStorageCheckoutId(checkoutId) 113 | return checkout 114 | }, [canCreateCheckout, client.checkout, queryClient]) 115 | 116 | const requestCheckoutId = React.useCallback(async () => { 117 | let checkoutId = localStorageCheckoutId 118 | if (!checkoutId) { 119 | checkoutId = (await createCheckout())?.id.toString() ?? null 120 | } 121 | return checkoutId 122 | }, [createCheckout, localStorageCheckoutId]) 123 | 124 | const { mutateAsync: onAddLineItem } = useMutation({ 125 | mutationFn: async ({ 126 | variantId, 127 | quantity 128 | }: { 129 | variantId: string 130 | quantity: number 131 | }) => { 132 | const checkoutId = await requestCheckoutId() 133 | if (!checkoutId) throw new Error('checkout id not found') 134 | 135 | const checkout = await client.checkout.addLineItems(checkoutId, [ 136 | { variantId, quantity } 137 | ]) 138 | return (checkout as unknown) as Cart 139 | }, 140 | onSuccess: newCheckout => { 141 | queryClient.setQueryData( 142 | getQueryKey(newCheckout.id.toString()), 143 | newCheckout 144 | ) 145 | } 146 | }) 147 | 148 | const { mutateAsync: onUpdateLineItem } = useMutation({ 149 | mutationFn: async ({ 150 | variantId, 151 | quantity 152 | }: { 153 | variantId: string 154 | quantity: number 155 | }) => { 156 | const checkoutId = await requestCheckoutId() 157 | if (!checkoutId) throw new Error('checkout id not found') 158 | 159 | const checkout = await client.checkout.updateLineItems(checkoutId, [ 160 | { quantity, id: variantId } 161 | ]) 162 | return (checkout as unknown) as Cart 163 | }, 164 | onSuccess: newCheckout => { 165 | queryClient.setQueryData( 166 | getQueryKey(newCheckout.id.toString()), 167 | newCheckout 168 | ) 169 | } 170 | }) 171 | 172 | const { mutateAsync: onRemoveLineItem } = useMutation({ 173 | mutationFn: async ({ variantId }: { variantId: string }) => { 174 | const checkoutId = await requestCheckoutId() 175 | if (!checkoutId) throw new Error('checkout id not found') 176 | 177 | const checkout = await client.checkout.removeLineItems(checkoutId, [ 178 | variantId 179 | ]) 180 | return (checkout as unknown) as Cart 181 | }, 182 | onSuccess: newCheckout => { 183 | queryClient.setQueryData( 184 | getQueryKey(newCheckout.id.toString()), 185 | newCheckout 186 | ) 187 | } 188 | }) 189 | 190 | const cartItemsCount = React.useMemo(() => { 191 | let result = 0 192 | cart?.lineItems?.forEach(i => { 193 | result += i.quantity 194 | }) 195 | return result 196 | }, [cart?.lineItems]) 197 | 198 | return ( 199 | 209 | {children} 210 | 211 | ) 212 | } 213 | 214 | const queryClient = new QueryClient() 215 | 216 | export const ShopifyContextProvider: React.FC<{ config: ClientConfig }> = ({ 217 | children, 218 | config 219 | }) => { 220 | return ( 221 | 222 | {children} 223 | 224 | ) 225 | } 226 | 227 | export const useShopify = () => { 228 | const ctx = React.useContext(ShopifyContext) 229 | if (ctx === undefined) { 230 | throw new Error('useShopify must be used below ') 231 | } 232 | return ctx 233 | } 234 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { ShopifyContextProvider, useShopify } from './client' 2 | export type { Cart, LineItem } from './client' 3 | export { createClient } from './lib/shopify' 4 | export { useToggleState } from './utils' 5 | -------------------------------------------------------------------------------- /src/lib/shopify.ts: -------------------------------------------------------------------------------- 1 | import Client from 'shopify-buy' 2 | 3 | export type ClientConfig = { domain: string; storefrontAccessToken: string } 4 | 5 | export const createClient = ({ 6 | domain, 7 | storefrontAccessToken 8 | }: ClientConfig) => { 9 | const client = Client.buildClient({ domain, storefrontAccessToken }) 10 | 11 | function fetchAllProducts(): Promise 12 | function fetchAllProducts( 13 | formatProduct: (p: ShopifyBuy.Product) => T 14 | ): Promise 15 | async function fetchAllProducts(formatProduct?: any) { 16 | const products = await client.product.fetchAll() 17 | if (formatProduct) return products.map(p => formatProduct(p)) 18 | return products 19 | } 20 | 21 | function fetchProductByHandle(handle: string): Promise 22 | function fetchProductByHandle( 23 | handle: string, 24 | formatProduct: (p: ShopifyBuy.Product) => T 25 | ): Promise 26 | async function fetchProductByHandle(handle: string, formatProduct?: any) { 27 | const product = await client.product.fetchByHandle(handle) 28 | if (formatProduct) return formatProduct(product) 29 | return product 30 | } 31 | 32 | return { client, fetchAllProducts, fetchProductByHandle } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const useToggleState = (initialState = false) => { 4 | const [isOn, setIsOn] = React.useState(initialState) 5 | 6 | const handleOn = React.useCallback(() => { 7 | setIsOn(true) 8 | }, []) 9 | 10 | const handleOff = React.useCallback(() => { 11 | setIsOn(false) 12 | }, []) 13 | 14 | const handleToggle = React.useCallback(() => { 15 | setIsOn(p => !p) 16 | }, []) 17 | 18 | return { isOn, handleToggle, handleOn, handleOff } 19 | } 20 | 21 | export type ToggleState = ReturnType 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowJs": true, 5 | "jsx": "preserve", 6 | "target": "esnext", 7 | "module": "esnext", 8 | "lib": ["dom", "es2019"], 9 | "noEmit": true, 10 | "moduleResolution": "node", 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "strictBindCallApply": true, 17 | "strictPropertyInitialization": true, 18 | "noImplicitThis": true, 19 | "alwaysStrict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "skipLibCheck": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "esModuleInterop": true, 26 | "resolveJsonModule": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------