├── .env.sample
├── .gitignore
├── README.md
├── components
├── App
│ ├── App.graphql
│ ├── App.js
│ ├── App.module.css
│ └── index.js
├── Button
│ ├── Button.js
│ ├── Button.module.css
│ └── index.js
├── Category
│ ├── Category.graphql
│ ├── Category.js
│ ├── Category.module.css
│ └── index.js
├── Home
│ ├── Home.js
│ ├── Home.module.css
│ └── index.js
├── Price
│ ├── Price.js
│ ├── _price_range.graphql
│ └── index.js
├── Product
│ ├── Product.graphql
│ ├── Product.js
│ ├── Product.module.css
│ └── index.js
└── Products
│ ├── Products.graphql
│ ├── Products.js
│ ├── Products.module.css
│ └── index.js
├── jsconfig.json
├── lib
├── apollo-client.js
├── express-middleware.js
└── resolve-image.js
├── next.config.js
├── package.json
├── pages
├── _app.js
├── _url-resolver.js
├── api
│ └── proxy.js
└── index.js
├── public
├── favicon.ico
└── static
│ └── logo.png
├── styles
└── global.css
└── yarn.lock
/.env.sample:
--------------------------------------------------------------------------------
1 | MAGENTO_URL="https://venia.magento.com/"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | .next
3 | node_modules
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # How-to Magento with Next.js
2 | ## vol.1 Getting Started
3 |
4 | [](https://www.youtube.com/embed/FdJAuEGVg9w)
5 |
6 |
7 | __📣 Note: This sample was developed before Next.js 10 and Next.js Commerce. (Update in progress)__
8 |
9 | Next.js is a minimalistic JAMstack framework; most of it is invisible to you (the developer). However, it is extensible when you need to customize and bring other third-party libraries.
10 |
11 | ### What you get out of the box with Next.js
12 | - Pre-rendering. Static generated and server-side rendered.
13 | - Zero Configuration. Auto code splitting, file-system based routing, and universal rendering.
14 | - Static Exporting. It just happens. One command to build.
15 | - Fully Extensible. Complete control over Babel and Webpack. Next-plugins.
16 | - CSS-in-JS. Next comes with styled-jsx included, but it also works with other styling solutions.
17 | - Ready for Production. Optimized for a smaller build size.
18 |
19 | **⚠️ This is proof-of-concept for a Next.js project using data from Magento.**
20 |
21 | ### Getting Started
22 |
23 | ☝️ Create a new `.env` file based on `.env.sample`, and change the value of `MAGENTO_URL` to point to your Magento instance.
24 |
25 | 👌 Install dependencies by running `npm install` or `yarn install`
26 |
27 |
28 | ### Development Mode
29 |
30 | 👌 Run `npm run dev` or `yarn dev` to start the application on development mode, and visit https://localhost:3000
31 |
32 |
33 | ### Production Mode
34 |
35 | ☝️ Run `npm run build` or `yarn build`
36 |
37 | 👌 Run `npm run start` or `yarn start`, and visit https://localhost:3000
38 |
--------------------------------------------------------------------------------
/components/App/App.graphql:
--------------------------------------------------------------------------------
1 | query App {
2 | storeConfig {
3 | id
4 | category_url_suffix # One of those things. 🤷♂️
5 | default_title
6 | base_media_url
7 | logo_alt
8 | header_logo_src
9 | copyright
10 | }
11 |
12 | categoryList {
13 | id
14 | children {
15 | id
16 | url_key
17 | name
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/components/App/App.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client'
2 | import React from 'react'
3 | import styles from './App.module.css'
4 | import APP_QUERY from './App.graphql'
5 | import Link from 'next/link'
6 | import NextNprogress from 'nextjs-progressbar'
7 | import Head from 'next/head'
8 | import { resolveImage } from '~/lib/resolve-image'
9 |
10 | export const App = ({ children }) => {
11 | const { data } = useQuery(APP_QUERY)
12 |
13 | const store = data?.storeConfig
14 |
15 | const categoryUrlSuffix = store?.category_url_suffix ?? ''
16 |
17 | const categories = data?.categoryList[0].children
18 |
19 | return (
20 |
21 |
22 | {store?.default_title}
23 |
24 |
25 |
26 |
32 |
33 |
34 |
35 |
36 |
46 |
47 |
48 |
49 |
69 |
70 |
71 |
{children}
72 |
73 | {store?.copyright && (
74 |
77 | )}
78 |
79 |
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/components/App/App.module.css:
--------------------------------------------------------------------------------
1 | .app {
2 | display: grid;
3 | grid-gap: 20px;
4 | grid-auto-rows: max-content;
5 | }
6 |
7 | .header {
8 | border-bottom: 1px solid #eee;
9 | padding: 10px;
10 | background-color: #f6f6f6;
11 | display: grid;
12 | grid-template-columns: 1fr;
13 | grid-gap: 16px;
14 | align-items: center;
15 | }
16 | .header img {
17 | max-height: 40px;
18 | max-width: 400px;
19 | }
20 |
21 | .categoriesWrapper {
22 | overflow-x: auto;
23 | max-width: 100%;
24 | -ms-overflow-style: none; /* IE and Edge */
25 | scrollbar-width: none; /* Firefox */
26 | text-align: center;
27 | }
28 |
29 | .categoriesWrapper::-webkit-scrollbar {
30 | display: none;
31 | }
32 |
33 | .categories {
34 | display: inline-grid;
35 | grid-auto-flow: column;
36 | grid-auto-columns: max-content;
37 | grid-gap: 20px;
38 | }
39 |
40 | .categories li a {
41 | color: inherit;
42 | text-decoration: none;
43 | }
44 |
45 | .categories li a:hover {
46 | text-decoration: underline;
47 | }
48 |
49 | .content {
50 | padding: 0 10px;
51 | }
52 |
53 | .footer {
54 | border-top: 1px solid #eee;
55 | padding: 40px 20px;
56 | text-align: center;
57 | color: #666;
58 | font-size: 12px;
59 | }
60 |
61 | /* Tablet */
62 | @media (min-width: 600px) {
63 | .header {
64 | grid-template-columns: auto 1fr;
65 | grid-gap: 20px;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/components/App/index.js:
--------------------------------------------------------------------------------
1 | export * from './App'
2 | export { App as default } from './App'
3 |
--------------------------------------------------------------------------------
/components/Button/Button.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styles from './Button.module.css'
3 |
4 | export const Button = ({ ...props }) => {
5 | return
6 | }
7 |
--------------------------------------------------------------------------------
/components/Button/Button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | border: 0 none;
3 | padding: 16px;
4 | border-radius: 10px;
5 | background-color: #222;
6 | color: #fff;
7 | font-size: 16px;
8 | font-weight: 600;
9 | cursor: pointer;
10 | }
11 |
12 | .button:disabled {
13 | opacity: 0.5;
14 | cursor: unset;
15 | }
16 |
--------------------------------------------------------------------------------
/components/Button/index.js:
--------------------------------------------------------------------------------
1 | export * from './Button'
2 | export { Button as default } from './Button'
3 |
--------------------------------------------------------------------------------
/components/Category/Category.graphql:
--------------------------------------------------------------------------------
1 | query CategoryQuery($filters: CategoryFilterInput!) {
2 | storeConfig {
3 | id
4 | category_url_suffix # One of those things. 🤷♂️
5 | }
6 |
7 | categoryList(filters: $filters) {
8 | id
9 | name
10 | display_mode
11 |
12 | children {
13 | id
14 | url_path
15 | name
16 | }
17 |
18 | breadcrumbs {
19 | id: category_id
20 | category_url_path
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/components/Category/Category.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styles from './Category.module.css'
3 | import { useQuery } from '@apollo/client'
4 | import CATEGORY_QUERY from './Category.graphql'
5 | import Products from '~/components/Products'
6 | import Link from 'next/link'
7 | import Head from 'next/head'
8 |
9 | export const Category = ({ filters }) => {
10 | const { loading, data, error } = useQuery(CATEGORY_QUERY, {
11 | variables: { filters },
12 | })
13 |
14 | if (error) {
15 | console.error(error)
16 | return
💩 There was an error.
17 | }
18 |
19 | if (loading && !data) return ⌚️ Loading...
20 |
21 | const category = data.categoryList[0]
22 |
23 | const categoryUrlSuffix = data.storeConfig.category_url_suffix ?? ''
24 |
25 | const backUrl =
26 | category.breadcrumbs &&
27 | category.breadcrumbs[0]?.category_url_path + categoryUrlSuffix
28 |
29 | return (
30 |
31 |
32 | {category.name}
33 |
34 |
35 |
36 |
37 | {backUrl && (
38 |
39 | ⬅
40 |
41 | )}
42 |
43 | {category.name}
44 |
45 |
46 | {/* Show Products only if the Category is set for it */}
47 | {/PRODUCTS/.test(category.display_mode) && (
48 |
49 | {category.children?.length > 0 && (
50 |
72 | )}
73 |
74 |
75 |
76 | )}
77 |
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/components/Category/Category.module.css:
--------------------------------------------------------------------------------
1 | .category {
2 | display: grid;
3 | grid-auto-rows: max-content;
4 | grid-gap: 20px;
5 | }
6 |
7 | .header {
8 | display: grid;
9 | grid-template-columns: auto 1fr;
10 | grid-gap: 16px;
11 | padding: 0 10px;
12 | align-items: center;
13 | font-size: 22px;
14 | }
15 |
16 | .header > h2 {
17 | font-size: 1.2em;
18 | }
19 |
20 | .backLink {
21 | padding: 0;
22 | border: 0 none;
23 | border-radius: 50%;
24 | width: 1.6em;
25 | height: 1.6em;
26 | background: #222;
27 | cursor: pointer;
28 | color: #fff;
29 | font-size: 1em;
30 | opacity: 0.15;
31 | position: relative;
32 | top: 2px;
33 | text-decoration: none;
34 | display: flex;
35 | align-items: center;
36 | justify-content: center;
37 | }
38 |
39 | .backLink:hover {
40 | opacity: 1;
41 | }
42 |
43 | .categoriesListWrapper {
44 | overflow-x: auto;
45 | max-width: 100%;
46 | -ms-overflow-style: none; /* IE and Edge */
47 | scrollbar-width: none; /* Firefox */
48 | }
49 |
50 | .categoriesListWrapper::-webkit-scrollbar {
51 | display: none;
52 | }
53 |
54 | .categoriesList {
55 | display: grid;
56 | grid-auto-flow: column;
57 | grid-auto-columns: max-content;
58 | grid-gap: 10px;
59 | padding: 0 10px;
60 | }
61 |
62 | .categoriesList li {
63 | border: 1px solid #ccc;
64 | border-radius: 20px;
65 | color: #333;
66 | display: flex;
67 | }
68 |
69 | .categoriesList li:hover {
70 | border-color: #333;
71 | color: #222;
72 | }
73 |
74 | .categoriesList li a {
75 | color: inherit;
76 | text-decoration: none;
77 | padding: 10px 18px;
78 | }
79 |
--------------------------------------------------------------------------------
/components/Category/index.js:
--------------------------------------------------------------------------------
1 | export * from './Category'
2 | export { Category as default } from './Category'
3 |
--------------------------------------------------------------------------------
/components/Home/Home.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from 'react'
2 | import styles from './Home.module.css'
3 | import { useDebounce } from 'use-debounce'
4 | import Products from '~/components/Products'
5 |
6 | export const Home = () => {
7 | const [searchQuery, setSearchQuery] = useState('')
8 |
9 | const [debouncedSearchQuery] = useDebounce(searchQuery, 500)
10 |
11 | const handleSearch = useCallback(
12 | (e) => {
13 | e.preventDefault()
14 | e.stopPropagation()
15 |
16 | setSearchQuery(searchQuery)
17 | },
18 | [searchQuery]
19 | )
20 |
21 | return (
22 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/components/Home/Home.module.css:
--------------------------------------------------------------------------------
1 | .home {
2 | display: grid;
3 | grid-auto-rows: max-content;
4 | grid-gap: 20px;
5 | }
6 |
7 | .search {
8 | font-size: 32px;
9 | line-height: 0;
10 | }
11 |
12 | .searchInput {
13 | width: 100%;
14 | padding: 10px;
15 | font-size: 18px;
16 | border: 0 none;
17 | border-bottom: 1px solid #ccc;
18 | }
19 |
20 | .searchInput::placeholder {
21 | font-style: italic;
22 | color: #bbb;
23 | }
24 |
--------------------------------------------------------------------------------
/components/Home/index.js:
--------------------------------------------------------------------------------
1 | export * from './Home'
2 | export { Home as default } from './Home'
3 |
--------------------------------------------------------------------------------
/components/Price/Price.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export const Price = ({ minimum_price, ...rest }) => {
4 | const currency = minimum_price.regular_price.currency || 'USD'
5 |
6 | const price = minimum_price.regular_price.value.toLocaleString('en-US', {
7 | style: 'currency',
8 | currency,
9 | })
10 |
11 | const discount = minimum_price.discount.amount_off
12 | ? minimum_price.discount.amount_off.toLocaleString('en-US', {
13 | style: 'currency',
14 | currency,
15 | })
16 | : null
17 |
18 | return (
19 |
20 | {price} {discount && ({discount} OFF)}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/Price/_price_range.graphql:
--------------------------------------------------------------------------------
1 | fragment price_range on PriceRange {
2 | minimum_price {
3 | discount {
4 | amount_off
5 | }
6 | regular_price {
7 | currency
8 | value
9 | }
10 | }
11 | maximum_price {
12 | discount {
13 | amount_off
14 | }
15 | regular_price {
16 | currency
17 | value
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/components/Price/index.js:
--------------------------------------------------------------------------------
1 | export * from './Price'
2 | export { Price as default } from './Price'
3 |
--------------------------------------------------------------------------------
/components/Product/Product.graphql:
--------------------------------------------------------------------------------
1 | #import "~/components/Price/_price_range.graphql"
2 |
3 | query ProductQuery($filters: ProductAttributeFilterInput!) {
4 | products(filter: $filters) {
5 | items {
6 | id
7 | sku
8 | name
9 |
10 | description {
11 | html
12 | }
13 |
14 | media_gallery {
15 | id: url
16 | label
17 | url
18 | type: __typename
19 | }
20 |
21 | price_range {
22 | ...price_range
23 | }
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/components/Product/Product.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react'
2 | import styles from './Product.module.css'
3 | import { useQuery, useMutation, gql } from '@apollo/client'
4 | import { resolveImage } from '~/lib/resolve-image'
5 | import PRODUCT_QUERY from './Product.graphql'
6 | import Price from '~/components/Price'
7 | import Button from '~/components/Button'
8 | import Head from 'next/head'
9 |
10 | export const Product = ({ filters }) => {
11 | const { loading, data } = useQuery(PRODUCT_QUERY, { variables: { filters } })
12 |
13 | const product = data?.products.items[0]
14 |
15 | if (loading && !data) return ⌚️ Loading...
16 |
17 | return (
18 |
19 |
20 | {product.name}
21 |
22 |
23 |
24 |
25 | {product.media_gallery
26 | .filter((media) => media.type === 'ProductImage')
27 | .map((image, index) => (
28 |

38 | ))}
39 |
40 |
41 |
42 |
43 |
{product.name}
44 |
45 |
SKU. {product.sku}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | {product.description?.html && (
54 |
58 | )}
59 |
60 |
61 |
62 |
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/components/Product/Product.module.css:
--------------------------------------------------------------------------------
1 | .product {
2 | display: grid;
3 | grid-gap: 20px;
4 | grid-template-columns: 1fr;
5 | }
6 |
7 | .gallery {
8 | overflow-x: auto;
9 | scroll-snap-type: x mandatory;
10 | display: flex;
11 | }
12 |
13 | .gallery img {
14 | scroll-snap-align: center;
15 | width: 100%;
16 | height: auto;
17 | }
18 |
19 | .details {
20 | display: grid;
21 | grid-gap: 16px;
22 | grid-auto-rows: max-content;
23 | padding: 0 10px;
24 | }
25 |
26 | .details > h2 {
27 | font-size: 23px;
28 | }
29 |
30 | .sku {
31 | color: #999;
32 | }
33 |
34 | .buttonWrapper {
35 | padding: 10px 0;
36 | }
37 |
38 | .buttonWrapper button {
39 | width: 100%;
40 | }
41 |
42 | .description {
43 | font-size: 14px;
44 | line-height: 1.5;
45 | }
46 |
47 | .description p {
48 | margin-bottom: 20px;
49 | }
50 |
51 | .description ul,
52 | .description ol {
53 | list-style-position: inside;
54 | }
55 |
56 | .description ul {
57 | list-style-type: disc;
58 | }
59 |
60 | .description ol {
61 | list-style-type: decimal;
62 | }
63 |
64 | .description li {
65 | margin-bottom: 10px;
66 | margin-left: 6px;
67 | }
68 |
69 | /* Tablet */
70 | @media (min-width: 600px) {
71 | .product {
72 | grid-template-columns: 1fr 1fr;
73 | grid-gap: 20px;
74 | align-items: center;
75 | }
76 |
77 | .gallery {
78 | overflow-x: auto;
79 | scroll-snap-type: unset;
80 | display: grid;
81 | grid-auto-rows: max-content;
82 | grid-gap: 10px;
83 | }
84 |
85 | .content {
86 | align-self: flex-start;
87 | }
88 |
89 | .detailsWrapper {
90 | position: sticky;
91 | top: 20px;
92 | min-height: 90vh;
93 | display: flex;
94 | justify-content: center;
95 | align-items: center;
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/components/Product/index.js:
--------------------------------------------------------------------------------
1 | export * from './Product'
2 | export { Product as default } from './Product'
3 |
--------------------------------------------------------------------------------
/components/Products/Products.graphql:
--------------------------------------------------------------------------------
1 | #import "~/components/Price/_price_range.graphql"
2 |
3 | query ProductsQuery(
4 | $search: String
5 | $filters: ProductAttributeFilterInput
6 | $pageSize: Int = 12
7 | $currentPage: Int = 1
8 | ) {
9 | storeConfig {
10 | id
11 | product_url_suffix # One of those things. 🤷♂️
12 | }
13 |
14 | products(
15 | search: $search
16 | filter: $filters
17 | pageSize: $pageSize
18 | currentPage: $currentPage
19 | ) {
20 | page_info {
21 | current_page
22 | total_pages
23 | }
24 |
25 | items {
26 | id
27 | url_key
28 | name
29 |
30 | thumbnail {
31 | id: url
32 | url
33 | label
34 | }
35 |
36 | price_range {
37 | ...price_range
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/components/Products/Products.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react'
2 | import { useQuery } from '@apollo/client'
3 | import PRODUCTS_QUERY from './Products.graphql'
4 | import styles from './Products.module.css'
5 | import { resolveImage } from '~/lib/resolve-image'
6 | import Link from 'next/link'
7 | import Price from '~/components/Price'
8 | import Button from '~/components/Button'
9 |
10 | export const Products = ({ search, filters }) => {
11 | const { loading, data, fetchMore } = useQuery(PRODUCTS_QUERY, {
12 | variables: { search, filters },
13 | notifyOnNetworkStatusChange: true,
14 | })
15 |
16 | const page = data?.products.page_info
17 |
18 | const products = data?.products.items || []
19 |
20 | const productUrlSuffix = data?.storeConfig.product_url_suffix ?? ''
21 |
22 | const handleFetchMore = useCallback(() => {
23 | if (loading || !page || page.current_page === page.total_pages) return
24 |
25 | fetchMore({
26 | variables: {
27 | currentPage: page.current_page + 1, // next page
28 | },
29 | updateQuery: (prev, { fetchMoreResult }) => {
30 | if (!fetchMoreResult) return prev
31 |
32 | return {
33 | ...prev,
34 | products: {
35 | ...prev.products,
36 | ...fetchMoreResult.products,
37 | items: [...prev.products.items, ...fetchMoreResult.products.items],
38 | },
39 | }
40 | },
41 | })
42 | }, [loading, page, fetchMore])
43 |
44 | if (loading && !data) return ⌚️ Loading...
45 |
46 | if (products.length === 0) return 🧐 No products found.
47 |
48 | return (
49 |
50 |
88 |
89 | {page && page.current_page !== page.total_pages && (
90 |
93 | )}
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/components/Products/Products.module.css:
--------------------------------------------------------------------------------
1 | .products {
2 | display: grid;
3 | grid-auto-rows: max-content;
4 | grid-gap: 40px;
5 | }
6 |
7 | .productsList {
8 | display: grid;
9 | grid-template-columns: 1fr;
10 | grid-gap: 20px;
11 | }
12 |
13 | .productsList > a {
14 | text-decoration: none;
15 | color: inherit;
16 | }
17 |
18 | .productItem {
19 | display: grid;
20 | grid-auto-rows: max-content;
21 | background-color: rgba(34, 34, 34, 0.05);
22 | border-radius: 20px;
23 | overflow: hidden;
24 | }
25 |
26 | .productWrapper {
27 | position: relative;
28 | line-height: 0;
29 | }
30 |
31 | .productWrapper::after {
32 | pointer-events: none;
33 | content: '';
34 | position: absolute;
35 | top: 0px;
36 | left: 0px;
37 | width: 100%;
38 | height: 100%;
39 | z-index: 1;
40 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 100px inset;
41 | cursor: inherit;
42 | }
43 |
44 | .productImage {
45 | width: 100%;
46 | height: auto;
47 | object-fit: cover;
48 | }
49 |
50 | .productName {
51 | padding: 20px;
52 | font-size: 14px;
53 | display: grid;
54 | grid-gap: 10px;
55 | }
56 |
57 | .productName > span {
58 | opacity: 0.7;
59 | font-size: 0.9em;
60 | }
61 |
62 | /* Tablet */
63 | @media (min-width: 600px) {
64 | .productsList {
65 | grid-template-columns: 1fr 1fr;
66 | }
67 | }
68 |
69 | /* Desktop */
70 | @media (min-width: 992px) {
71 | .productsList {
72 | grid-template-columns: 1fr 1fr 1fr;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/components/Products/index.js:
--------------------------------------------------------------------------------
1 | export * from './Products'
2 | export { Products as default } from './Products'
3 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "~/*": [
6 | "./*"
7 | ]
8 | }
9 | },
10 | "exclude": [
11 | "./node_modules/*",
12 | ]
13 | }
--------------------------------------------------------------------------------
/lib/apollo-client.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
3 | import { concatPagination } from '@apollo/client/utilities'
4 |
5 | /**
6 | * Polyfill Global Variables in Server
7 | */
8 | if (!process.browser) {
9 | global.URL = require('url').URL
10 | }
11 |
12 | let apolloClient
13 |
14 | function createApolloClient() {
15 | const uri = process.browser
16 | ? new URL('/graphql', location.href)
17 | : new URL('/graphql', process.env.MAGENTO_URL).href
18 |
19 | return new ApolloClient({
20 | ssrMode: !process.browser,
21 | credentials: 'include',
22 | link: new HttpLink({
23 | uri,
24 | credentials: 'include', // Additional fetch() options like `credentials` or `headers`
25 | }),
26 | cache: new InMemoryCache({
27 | typePolicies: {
28 | Query: {
29 | fields: {
30 | allPosts: concatPagination(),
31 | },
32 | },
33 | },
34 | }),
35 | })
36 | }
37 |
38 | export function initializeApollo(initialState = null) {
39 | const _apolloClient = apolloClient ?? createApolloClient()
40 |
41 | // If your page has Next.js data fetching methods that use Apollo Client, the initial state
42 | // gets hydrated here
43 | if (initialState) {
44 | // Get existing cache, loaded during client side data fetching
45 | const existingCache = _apolloClient.extract()
46 | // Restore the cache using the data passed from getStaticProps/getServerSideProps
47 | // combined with the existing cached data
48 | _apolloClient.cache.restore({ ...existingCache, ...initialState })
49 | }
50 | // For SSG and SSR always create a new Apollo Client
51 | if (typeof window === 'undefined') return _apolloClient
52 | // Create the Apollo Client once in the client
53 | if (!apolloClient) apolloClient = _apolloClient
54 |
55 | return _apolloClient
56 | }
57 |
58 | export function useApollo(initialState) {
59 | const store = useMemo(() => initializeApollo(initialState), [initialState])
60 | return store
61 | }
62 |
--------------------------------------------------------------------------------
/lib/express-middleware.js:
--------------------------------------------------------------------------------
1 | // Helper method to wait for a middleware to execute before continuing
2 | // And to throw an error when an error happens in a middleware
3 | export const runMiddleware = (req, res, fn) => {
4 | return new Promise((resolve, reject) => {
5 | fn(req, res, (result) => {
6 | if (result instanceof Error) {
7 | return reject(result)
8 | }
9 |
10 | return resolve(result)
11 | })
12 | })
13 | }
--------------------------------------------------------------------------------
/lib/resolve-image.js:
--------------------------------------------------------------------------------
1 | if (!process.browser) {
2 | const { URL } = require('url')
3 | global.URL = URL
4 | }
5 |
6 | export const resolveImage = (url) => {
7 | if (!url) return undefined
8 |
9 | const { pathname } = new URL(url)
10 |
11 | if (pathname) {
12 | return `/store${pathname}`
13 | } else {
14 | return url
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const { URL } = require('url')
3 |
4 | module.exports = {
5 | webpack: (config) => {
6 | config.module.rules.push({
7 | test: /\.(graphql|gql)$/,
8 | loader: 'graphql-tag/loader',
9 | })
10 |
11 | config.resolve.alias = {
12 | ...config.resolve.alias,
13 | '~': path.resolve(__dirname),
14 | }
15 |
16 | return config
17 | },
18 | async rewrites() {
19 | return [
20 | /**
21 | * Rewrite /graphql requests to Magento
22 | */
23 | {
24 | source: '/graphql/:pathname*',
25 | destination: new URL('graphql', process.env.MAGENTO_URL).href,
26 | },
27 |
28 | /**
29 | * Sample of how to use APIs to Proxy Images
30 | */
31 | {
32 | source: '/store/:pathname*',
33 | destination: '/api/proxy',
34 | },
35 |
36 | /**
37 | * URlResolver 🙌
38 | */
39 | {
40 | source: '/:pathname*',
41 | destination: '/_url-resolver',
42 | },
43 | ]
44 | },
45 | }
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "magento-nextjs-starter",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "author": "Carlos A. Cabrera @fnhipster",
6 | "license": "MIT",
7 | "scripts": {
8 | "dev": "next dev",
9 | "build": "next build",
10 | "start": "next start"
11 | },
12 | "dependencies": {
13 | "@apollo/client": "^3.2.3",
14 | "graphql": "^15.3.0",
15 | "http-proxy-middleware": "^1.0.5",
16 | "next": "^9.5.5",
17 | "nextjs-progressbar": "^0.0.6",
18 | "react": "^16.13.1",
19 | "react-dom": "^16.13.1",
20 | "reset-css": "^5.0.1",
21 | "use-debounce": "^5.0.1"
22 | },
23 | "devDependencies": {
24 | "graphql-tag": "^2.11.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import { ApolloProvider } from '@apollo/client'
2 | import { useApollo } from '~/lib/apollo-client'
3 | import App from '~/components/App'
4 | import 'reset-css'
5 | import '~/styles/global.css'
6 |
7 | export default function NextApp({ Component, pageProps }) {
8 | const apolloClient = useApollo(pageProps.initialApolloState)
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/pages/_url-resolver.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Error from 'next/error'
3 | import { gql } from '@apollo/client'
4 | import { initializeApollo } from '~/lib/apollo-client'
5 |
6 | import APP_QUERY from '~/components/App/App.graphql'
7 | import PRODUCTS_QUERY from '~/components/Products/Products.graphql'
8 |
9 | import Category from '~/components/Category'
10 | import CATEGORY_QUERY from '~/components/Category/Category.graphql'
11 |
12 | import Product from '~/components/Product'
13 | import PRODUCT_QUERY from '~/components/Product/Product.graphql'
14 |
15 | const CONTENT_TYPE = {
16 | CMS_PAGE: 'CMS_PAGE',
17 | CATEGORY: 'CATEGORY',
18 | PRODUCT: 'PRODUCT',
19 | NOT_FOUND: '404',
20 | }
21 |
22 | const URLResolver = ({ type, urlKey }) => {
23 | if (type === CONTENT_TYPE.CMS_PAGE) {
24 | return 🥴 "CMS_PAGE" is not implemented in this sample.
25 | }
26 |
27 | if (type === CONTENT_TYPE.CATEGORY) {
28 | return
29 | }
30 |
31 | if (type === CONTENT_TYPE.PRODUCT) {
32 | return
33 | }
34 |
35 | return
36 | }
37 |
38 | URLResolver.getInitialProps = async ({ req, res, query, asPath }) => {
39 | res?.setHeader('cache-control', 's-maxage=1, stale-while-revalidate')
40 |
41 | const apolloClient = initializeApollo()
42 |
43 | const pathname = query?.pathname.join('/')
44 |
45 | const urlKey = query?.pathname?.pop().split('.')?.shift() || ''
46 |
47 | /** If a type has been provided then return the props and render the Component ... */
48 | if (query.type) {
49 | return { type: query.type, urlKey }
50 | }
51 |
52 | /** ... if not, let's resolver the URL ... */
53 | const { data } = await apolloClient.query({
54 | query: gql`
55 | query UrlResolver($url: String!) {
56 | urlResolver(url: $url) {
57 | id
58 | type
59 | }
60 | }
61 | `,
62 | variables: {
63 | url: pathname,
64 | },
65 | })
66 |
67 | /** ... if not found, return 404 ... */
68 | if (!data?.urlResolver) {
69 | if (res) res.statusCode = 404
70 | return { type: '404', pathname }
71 | }
72 |
73 | const { type, id } = data.urlResolver
74 |
75 | /** ... if the request is done by the server, then let's load the data in cache of SSR goodness ... */
76 | if (req) {
77 | await apolloClient.query({ query: APP_QUERY }) // Preload App Data
78 |
79 | switch (type) {
80 | case CONTENT_TYPE.CMS_PAGE:
81 | // Not implemented...
82 | break
83 | case CONTENT_TYPE.CATEGORY:
84 | const { data } = await apolloClient.query({
85 | query: CATEGORY_QUERY,
86 | variables: { filters: { url_key: { eq: urlKey } } },
87 | })
88 |
89 | /** If the category is set to show products, then load those products as well */
90 | if (/PRODUCTS/.test(data.categoryList[0].display_mode)) {
91 | await apolloClient.query({
92 | query: PRODUCTS_QUERY,
93 | variables: { filters: { category_id: { eq: id } } },
94 | })
95 | }
96 | break
97 | case CONTENT_TYPE.PRODUCT:
98 | await apolloClient.query({
99 | query: PRODUCT_QUERY,
100 | variables: { filters: { url_key: { eq: urlKey } } },
101 | })
102 | break
103 | default:
104 | break
105 | }
106 | }
107 |
108 | /** Return Props */
109 | return {
110 | type,
111 | urlKey,
112 | initialApolloState: apolloClient.cache.extract(), // load cached data from queries above into the initial state of the app
113 | }
114 | }
115 |
116 | export default URLResolver
117 |
--------------------------------------------------------------------------------
/pages/api/proxy.js:
--------------------------------------------------------------------------------
1 | import { createProxyMiddleware } from 'http-proxy-middleware'
2 | import { URL } from 'url'
3 | import { runMiddleware } from '~/lib/express-middleware'
4 |
5 | const magentoProxyApi = async (req, res) => {
6 | const target = new URL(process.env.MAGENTO_URL).href
7 |
8 | await runMiddleware(
9 | req,
10 | res,
11 | createProxyMiddleware({
12 | target,
13 | changeOrigin: true,
14 | secure: false,
15 | logLevel: 'error',
16 | pathRewrite: {
17 | '^/store': '/', // remove path
18 | },
19 | })
20 | )
21 | }
22 |
23 | export default magentoProxyApi
24 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Home from '~/components/Home'
3 | import { initializeApollo } from '~/lib/apollo-client'
4 | import APP_QUERY from '~/components/App/App.graphql'
5 | import PRODUCTS_QUERY from '~/components/Products/Products.graphql'
6 |
7 | const HomePage = () => {
8 | return
9 | }
10 |
11 | export const getStaticProps = async () => {
12 | const apolloClient = initializeApollo()
13 |
14 | await apolloClient.query({
15 | query: APP_QUERY,
16 | })
17 |
18 | await apolloClient.query({
19 | query: PRODUCTS_QUERY,
20 | variables: { search: '' },
21 | })
22 |
23 | return {
24 | props: {
25 | initialApolloState: apolloClient.cache.extract(),
26 | },
27 | revalidate: 1,
28 | }
29 | }
30 |
31 | export default HomePage
32 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fnhipster/magento-with-next-sample/6b445babadbc20e1b0dc76ab5ea1c26d11f8860b/public/favicon.ico
--------------------------------------------------------------------------------
/public/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fnhipster/magento-with-next-sample/6b445babadbc20e1b0dc76ab5ea1c26d11f8860b/public/static/logo.png
--------------------------------------------------------------------------------
/styles/global.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0 auto;
3 | max-width: 1600px;
4 | width: 100%;
5 | font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
6 | font-size: 14px;
7 | }
8 |
--------------------------------------------------------------------------------