├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .node-version ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── apps ├── api │ ├── .env │ ├── .eslintrc.json │ ├── db │ │ └── mikro-orm.config.ts │ ├── jest.config.ts │ ├── project.json │ ├── src │ │ ├── app │ │ │ ├── app.module.ts │ │ │ ├── config │ │ │ │ └── database.config.ts │ │ │ ├── products │ │ │ │ ├── products.controller.ts │ │ │ │ └── products.module.ts │ │ │ ├── session │ │ │ │ ├── database.session-storage.ts │ │ │ │ ├── session.entity.ts │ │ │ │ ├── session.module.ts │ │ │ │ └── session.repository.ts │ │ │ ├── shopify │ │ │ │ ├── after-auth │ │ │ │ │ ├── after-auth-handler.service.ts │ │ │ │ │ └── after-auth.module.ts │ │ │ │ ├── config │ │ │ │ │ ├── core.config.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── offline.config.ts │ │ │ │ │ └── online.config.ts │ │ │ │ ├── services │ │ │ │ │ ├── shopify-core-config.service.ts │ │ │ │ │ ├── shopify-offline-config.service.ts │ │ │ │ │ └── shopify-online-config.service.ts │ │ │ │ └── webhooks │ │ │ │ │ ├── handlers │ │ │ │ │ └── products-create.webhook-handler.ts │ │ │ │ │ └── webhooks.module.ts │ │ │ └── shops │ │ │ │ ├── shop.entity.ts │ │ │ │ ├── shop.repository.ts │ │ │ │ ├── shops.module.ts │ │ │ │ └── shops.service.ts │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json └── web │ ├── .env │ ├── .eslintrc.json │ ├── components │ └── ProductsCard.tsx │ ├── index.d.ts │ ├── jest.config.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── pages │ ├── _app.tsx │ ├── _document.tsx │ └── index.tsx │ ├── project.json │ ├── public │ └── .gitkeep │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── utils │ └── userLoggedInFetch.ts ├── babel.config.json ├── jest.config.ts ├── jest.preset.js ├── libs └── .gitkeep ├── migrations.json ├── nx.json ├── package-lock.json ├── package.json ├── renovate.json ├── tools ├── generators │ └── .gitkeep └── tsconfig.tools.json └── tsconfig.base.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_KEY= 2 | SHOPIFY_API_SECRET= 3 | SHOP= 4 | HOST= 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nrwl/nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nrwl/nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nrwl/nx/javascript"], 32 | "rules": {} 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | /.env 11 | *.sqlite3 12 | 13 | # IDEs and editors 14 | /.idea 15 | .project 16 | .classpath 17 | .c9/ 18 | *.launch 19 | .settings/ 20 | *.sublime-workspace 21 | 22 | # IDE - VSCode 23 | .vscode/* 24 | !.vscode/settings.json 25 | !.vscode/tasks.json 26 | !.vscode/launch.json 27 | !.vscode/extensions.json 28 | 29 | # misc 30 | /.sass-cache 31 | /connect.lock 32 | /coverage 33 | /libpeerconnection.log 34 | npm-debug.log 35 | yarn-error.log 36 | testem.log 37 | /typings 38 | 39 | # System Files 40 | .DS_Store 41 | Thumbs.db 42 | 43 | # Next.js 44 | .next -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.17 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example @nestjs-shopify application 2 | 3 | Uses [NX monorepo](https://nx.dev) under the hood. An example Shopify application 4 | with a [NestJS](https://nestjs.com) API backend, and a [NextJS](https://nextjs.org) frontend. 5 | 6 | Uses [@nestjs-shopify/*](https://github.com/nestjs-shopify/nestjs-shopify) packages. 7 | 8 | ## Architecture 9 | 10 | The NestJS `api` application is proxied via NX proxies to `/api`. The `api` application also contains 11 | a global prefix to `/api`. 12 | 13 | Because we use NX proxies, we basically disable the usage of NextJS API requests in the `pages/api` folder because the requests are always proxied to the backend. 14 | 15 | This application uses [Mikro-ORM](https://mikro-orm.io) for it's database. When performing offline auth, the authenticated shop gets inserted into the `shops` table with an offline token. This token can then be used for webhook/background operations. 16 | 17 | ## Setup 18 | 19 | Install dependencies 20 | 21 | ``` 22 | npm install 23 | ``` 24 | 25 | Copy the example environment variables and fill in yours: 26 | 27 | ``` 28 | cp .env.example .env 29 | ``` 30 | 31 | The `HOST` env var should be your full Ngrok URL eg: https://7c350f27f75f.ngrok.io 32 | 33 | Run the migrations: 34 | 35 | ``` 36 | cd apps/api 37 | npx mikro-orm schema:update -r 38 | ``` 39 | 40 | ## Running 41 | 42 | On terminal window 1: 43 | 44 | ``` 45 | npx nx run api:serve 46 | ``` 47 | 48 | On terminal window 2: 49 | 50 | ``` 51 | npx nx run web:serve 52 | ``` 53 | 54 | Visit `https:///?shop=` to start the OAuth installation procedure of your app. 55 | 56 | ## Authentication with Shopify 57 | 58 | The application allows for both Online and Offline authentication. But Shopify recommends using 59 | `offline` auth for only installing your application, and `online` auth for loading data in your frontend. 60 | 61 | The `ProductsController` in this application that returns the total product count in Shopify, utilizes `@ShopifyOnlineAuth()` decorator. That signals our application to look for online JWT tokens when calling the `GET /api/products/count` route. 62 | 63 | The frontend utilizes `@shopify/app-bridge` to transparently fetch online tokens for us using the `userLoggedInfetch` helper function. 64 | -------------------------------------------------------------------------------- /apps/api/.env: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/db/mikro-orm.config.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '@mikro-orm/core'; 2 | import { ShopEntity } from '../src/app/shops/shop.entity'; 3 | import path from 'path'; 4 | import { SessionEntity } from '../src/app/session/session.entity'; 5 | 6 | const baseDir = path.resolve(__dirname, '../../..'); 7 | 8 | const config: Options = { 9 | entities: [ShopEntity, SessionEntity], 10 | baseDir, 11 | type: 'sqlite', 12 | forceUtcTimezone: true, 13 | timezone: 'Europe/Amsterdam', 14 | dbName: 'api.sqlite3', 15 | debug: true, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /apps/api/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'api', 4 | preset: '../../jest.preset.js', 5 | globals: {}, 6 | testEnvironment: 'node', 7 | transform: { 8 | '^.+\\.[tj]s$': [ 9 | 'ts-jest', 10 | { 11 | tsconfig: '/tsconfig.spec.json', 12 | }, 13 | ], 14 | }, 15 | moduleFileExtensions: ['ts', 'js', 'html'], 16 | coverageDirectory: '../../coverage/apps/api', 17 | }; 18 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/api/src", 5 | "projectType": "application", 6 | "targets": { 7 | "build": { 8 | "executor": "@nrwl/webpack:webpack", 9 | "outputs": [ 10 | "{options.outputPath}" 11 | ], 12 | "options": { 13 | "outputPath": "dist/apps/api", 14 | "main": "apps/api/src/main.ts", 15 | "tsConfig": "apps/api/tsconfig.app.json", 16 | "target": "node", 17 | "compiler": "tsc" 18 | }, 19 | "configurations": { 20 | "production": { 21 | "optimization": true, 22 | "extractLicenses": true, 23 | "inspect": false, 24 | "fileReplacements": [ 25 | { 26 | "replace": "apps/api/src/environments/environment.ts", 27 | "with": "apps/api/src/environments/environment.prod.ts" 28 | } 29 | ] 30 | } 31 | } 32 | }, 33 | "serve": { 34 | "executor": "@nrwl/js:node", 35 | "options": { 36 | "buildTarget": "api:build" 37 | }, 38 | "configurations": { 39 | "production": { 40 | "buildTarget": "api:build:production" 41 | } 42 | } 43 | }, 44 | "lint": { 45 | "executor": "@nrwl/linter:eslint", 46 | "outputs": [ 47 | "{options.outputFile}" 48 | ], 49 | "options": { 50 | "lintFilePatterns": [ 51 | "apps/api/**/*.ts" 52 | ] 53 | } 54 | }, 55 | "test": { 56 | "executor": "@nrwl/jest:jest", 57 | "outputs": [ 58 | "{workspaceRoot}/coverage/apps/api" 59 | ], 60 | "options": { 61 | "jestConfig": "apps/api/jest.config.ts", 62 | "passWithNoTests": true 63 | } 64 | } 65 | }, 66 | "tags": [] 67 | } 68 | -------------------------------------------------------------------------------- /apps/api/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 2 | import { ShopifyAuthModule } from '@nestjs-shopify/auth'; 3 | import { ShopifyExpressModule } from '@nestjs-shopify/express'; 4 | import { ShopifyGraphqlProxyModule } from '@nestjs-shopify/graphql'; 5 | import { Module } from '@nestjs/common'; 6 | import { ConfigModule } from '@nestjs/config'; 7 | import { databaseConfig } from './config/database.config'; 8 | import { ProductsModule } from './products/products.module'; 9 | import { SessionModule } from './session/session.module'; 10 | import { AfterAuthModule } from './shopify/after-auth/after-auth.module'; 11 | import { 12 | shopifyCoreConfig, 13 | shopifyOfflineConfig, 14 | shopifyOnlineConfig, 15 | } from './shopify/config'; 16 | import { ShopifyCoreConfigService } from './shopify/services/shopify-core-config.service'; 17 | import { ShopifyOfflineConfigService } from './shopify/services/shopify-offline-config.service'; 18 | import { ShopifyOnlineConfigService } from './shopify/services/shopify-online-config.service'; 19 | import { WebhooksModule } from './shopify/webhooks/webhooks.module'; 20 | 21 | @Module({ 22 | imports: [ 23 | ConfigModule.forRoot({ 24 | cache: true, 25 | isGlobal: true, 26 | }), 27 | MikroOrmModule.forRootAsync(databaseConfig.asProvider()), 28 | ShopifyExpressModule.forRootAsync({ 29 | imports: [ConfigModule.forFeature(shopifyCoreConfig), SessionModule], 30 | useClass: ShopifyCoreConfigService, 31 | }), 32 | ShopifyAuthModule.forRootAsyncOffline({ 33 | imports: [ConfigModule.forFeature(shopifyOfflineConfig), AfterAuthModule], 34 | useClass: ShopifyOfflineConfigService, 35 | }), 36 | ShopifyAuthModule.forRootAsyncOnline({ 37 | imports: [ConfigModule.forFeature(shopifyOnlineConfig), AfterAuthModule], 38 | useClass: ShopifyOnlineConfigService, 39 | }), 40 | ShopifyGraphqlProxyModule, 41 | WebhooksModule, 42 | ProductsModule, 43 | ], 44 | }) 45 | export class AppModule {} 46 | -------------------------------------------------------------------------------- /apps/api/src/app/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModuleOptions } from '@mikro-orm/nestjs'; 2 | import { Logger } from '@nestjs/common'; 3 | import { registerAs } from '@nestjs/config'; 4 | import config from '../../../db/mikro-orm.config'; 5 | 6 | const logger = new Logger('MikroORM'); 7 | export const getDatabaseConfig = (): MikroOrmModuleOptions => ({ 8 | ...config, 9 | dbName: 'apps/api/api.sqlite3', 10 | logger: logger.log.bind(logger), 11 | }); 12 | 13 | export const databaseConfig = registerAs('database', getDatabaseConfig); 14 | -------------------------------------------------------------------------------- /apps/api/src/app/products/products.controller.ts: -------------------------------------------------------------------------------- 1 | import { CurrentSession, UseShopifyAuth } from '@nestjs-shopify/auth'; 2 | import { InjectShopify } from '@nestjs-shopify/core'; 3 | import { Controller, Get } from '@nestjs/common'; 4 | import { ConfigParams, Shopify } from '@shopify/shopify-api'; 5 | import { restResources } from '@shopify/shopify-api/rest/admin/2023-07'; 6 | import { SessionEntity } from '../session/session.entity'; 7 | 8 | @UseShopifyAuth() 9 | @Controller('products') 10 | export class ProductsController { 11 | constructor( 12 | @InjectShopify() 13 | private readonly shopifyApi: Shopify> 14 | ) {} 15 | 16 | @Get('count') 17 | async count(@CurrentSession() session: SessionEntity) { 18 | return await this.shopifyApi.rest.Product.count({ session }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/api/src/app/products/products.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductsController } from './products.controller'; 3 | 4 | @Module({ 5 | controllers: [ProductsController], 6 | }) 7 | export class ProductsModule {} 8 | -------------------------------------------------------------------------------- /apps/api/src/app/session/database.session-storage.ts: -------------------------------------------------------------------------------- 1 | import { InjectRepository } from '@mikro-orm/nestjs'; 2 | import { SessionStorage } from '@nestjs-shopify/core'; 3 | import { Injectable, Logger } from '@nestjs/common'; 4 | import { SessionEntity } from './session.entity'; 5 | import { SessionRepository } from './session.repository'; 6 | 7 | @Injectable() 8 | export class DatabaseSessionStorage implements SessionStorage { 9 | private readonly logger = new Logger('SessionStorage'); 10 | 11 | constructor( 12 | @InjectRepository(SessionEntity) private readonly repo: SessionRepository 13 | ) {} 14 | 15 | async storeSession(session: SessionEntity): Promise { 16 | let entity = await this.loadSession(session.id); 17 | if (!entity) { 18 | entity = this.repo.create(session); 19 | } else { 20 | entity = this.repo.assign(entity, session); 21 | } 22 | 23 | try { 24 | await this.repo.getEntityManager().persistAndFlush(entity); 25 | return true; 26 | } catch (err) { 27 | this.logger.error(err); 28 | } 29 | 30 | return false; 31 | } 32 | 33 | async loadSession(id: string): Promise { 34 | return await this.repo.findOne(id); 35 | } 36 | 37 | async deleteSession(id: string): Promise { 38 | try { 39 | const session = await this.repo.findOneOrFail(id); 40 | await this.repo.getEntityManager().removeAndFlush(session); 41 | return true; 42 | } catch (err) { 43 | this.logger.error(err); 44 | } 45 | 46 | return false; 47 | } 48 | 49 | async deleteSessions(ids: string[]): Promise { 50 | const em = this.repo.getEntityManager(); 51 | const sessions = await this.repo.find(ids); 52 | sessions.forEach((s) => em.remove(s)); 53 | await em.flush(); 54 | 55 | return true; 56 | } 57 | 58 | async findSessionsByShop(shop: string): Promise { 59 | const sessions = await this.repo.find({ shop }); 60 | 61 | return sessions; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /apps/api/src/app/session/session.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import { OnlineAccessInfo, Session } from '@shopify/shopify-api'; 3 | import { SessionRepository } from './session.repository'; 4 | 5 | @Entity({ 6 | tableName: 'sessions', 7 | customRepository: () => SessionRepository, 8 | }) 9 | export class SessionEntity extends Session { 10 | @PrimaryKey({ type: 'string' }) 11 | public id: string; 12 | 13 | @Property() 14 | public shop!: string; 15 | 16 | @Property() 17 | public state: string; 18 | 19 | @Property() 20 | public isOnline: boolean; 21 | 22 | @Property({ nullable: true }) 23 | public scope?: string; 24 | 25 | @Property({ type: Date, nullable: true }) 26 | public expires?: Date; 27 | 28 | @Property({ nullable: true }) 29 | public accessToken?: string; 30 | 31 | @Property({ type: 'json', nullable: true }) 32 | public onlineAccessInfo?: OnlineAccessInfo; 33 | } 34 | -------------------------------------------------------------------------------- /apps/api/src/app/session/session.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 2 | import { Module } from '@nestjs/common'; 3 | import { DatabaseSessionStorage } from './database.session-storage'; 4 | import { SessionEntity } from './session.entity'; 5 | 6 | @Module({ 7 | imports: [MikroOrmModule.forFeature([SessionEntity])], 8 | providers: [DatabaseSessionStorage], 9 | exports: [DatabaseSessionStorage], 10 | }) 11 | export class SessionModule {} 12 | -------------------------------------------------------------------------------- /apps/api/src/app/session/session.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from '@mikro-orm/sqlite'; 2 | import { SessionEntity } from './session.entity'; 3 | 4 | export class SessionRepository extends EntityRepository {} 5 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/after-auth/after-auth-handler.service.ts: -------------------------------------------------------------------------------- 1 | import { ShopifyAuthAfterHandler } from '@nestjs-shopify/auth'; 2 | import { ShopifyWebhooksService } from '@nestjs-shopify/webhooks'; 3 | import { Injectable, Logger } from '@nestjs/common'; 4 | import { Request, Response } from 'express'; 5 | import { SessionEntity } from '../../session/session.entity'; 6 | import { ShopsService } from '../../shops/shops.service'; 7 | 8 | @Injectable() 9 | export class AfterAuthHandlerService implements ShopifyAuthAfterHandler { 10 | constructor( 11 | private readonly shopsService: ShopsService, 12 | private readonly webhookService: ShopifyWebhooksService 13 | ) {} 14 | 15 | async afterAuth( 16 | req: Request, 17 | res: Response, 18 | session: SessionEntity 19 | ): Promise { 20 | const { isOnline, shop, accessToken } = session; 21 | const { host } = req.query; 22 | 23 | if (isOnline) { 24 | if (!(await this.shopsService.exists(shop))) { 25 | return res.redirect(`/api/offline/auth?shop=${shop}`); 26 | } 27 | 28 | return res.redirect(`/?shop=${shop}&host=${host}`); 29 | } 30 | 31 | await this.shopsService.findOrCreate(shop, accessToken); 32 | Logger.log('Registering webhooks'); 33 | await this.webhookService.registerWebhooks(session); 34 | 35 | return res.redirect(`/api/online/auth?shop=${shop}`); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/after-auth/after-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ShopsModule } from '../../shops/shops.module'; 3 | import { AfterAuthHandlerService } from './after-auth-handler.service'; 4 | 5 | @Module({ 6 | imports: [ShopsModule], 7 | providers: [AfterAuthHandlerService], 8 | exports: [AfterAuthHandlerService], 9 | }) 10 | export class AfterAuthModule {} 11 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/config/core.config.ts: -------------------------------------------------------------------------------- 1 | import { ShopifyCoreOptions } from '@nestjs-shopify/core'; 2 | import { Logger } from '@nestjs/common'; 3 | import { ConfigType, registerAs } from '@nestjs/config'; 4 | import { ApiVersion, LogSeverity } from '@shopify/shopify-api'; 5 | import { restResources } from '@shopify/shopify-api/rest/admin/2023-07'; 6 | 7 | const logger = new Logger('Shopify'); 8 | 9 | export const getShopifyCoreConfig = (): Omit< 10 | ShopifyCoreOptions, 11 | 'sessionStorage' 12 | > => ({ 13 | apiKey: process.env.SHOPIFY_API_KEY, 14 | apiSecretKey: process.env.SHOPIFY_API_SECRET, 15 | apiVersion: ApiVersion.July23, 16 | hostName: process.env.HOST.replace(/https?:\/\//, ''), 17 | isEmbeddedApp: true, 18 | scopes: ['write_products'], 19 | hostScheme: 'https', 20 | isCustomStoreApp: false, 21 | restResources, 22 | logger: { 23 | httpRequests: false, 24 | level: 25 | process.env.NODE_ENV !== 'production' 26 | ? LogSeverity.Debug 27 | : LogSeverity.Info, 28 | log: (_severity, msg) => logger.log(msg), 29 | timestamps: false, 30 | }, 31 | }); 32 | 33 | export const shopifyCoreConfig = registerAs( 34 | 'shopifyCore', 35 | getShopifyCoreConfig 36 | ); 37 | 38 | export type ShopifyCoreConfig = ConfigType; 39 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core.config'; 2 | export * from './offline.config'; 3 | export * from './online.config'; 4 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/config/offline.config.ts: -------------------------------------------------------------------------------- 1 | import { ShopifyAuthModuleOptions } from '@nestjs-shopify/auth'; 2 | import { ConfigType, registerAs } from '@nestjs/config'; 3 | 4 | export const SHOPIFY_OFFLINE_BASE_PATH = '/offline'; 5 | 6 | export const getShopifyOfflineConfig = (): ShopifyAuthModuleOptions => ({ 7 | basePath: SHOPIFY_OFFLINE_BASE_PATH, 8 | useGlobalPrefix: true, 9 | }); 10 | 11 | export const shopifyOfflineConfig = registerAs( 12 | 'shopifyOffline', 13 | getShopifyOfflineConfig 14 | ); 15 | 16 | export type ShopifyOfflineConfig = ConfigType; 17 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/config/online.config.ts: -------------------------------------------------------------------------------- 1 | import { ShopifyAuthModuleOptions } from '@nestjs-shopify/auth'; 2 | import { ConfigType, registerAs } from '@nestjs/config'; 3 | 4 | export const SHOPIFY_ONLINE_BASE_PATH = '/online'; 5 | 6 | export const getShopifyOnlineConfig = (): ShopifyAuthModuleOptions => ({ 7 | basePath: SHOPIFY_ONLINE_BASE_PATH, 8 | useGlobalPrefix: true, 9 | returnHeaders: true, 10 | }); 11 | 12 | export const shopifyOnlineConfig = registerAs( 13 | 'shopifyOnline', 14 | getShopifyOnlineConfig 15 | ); 16 | 17 | export type ShopifyOnlineConfig = ConfigType; 18 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/services/shopify-core-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ShopifyCoreConfig, shopifyCoreConfig } from '../config'; 3 | import { DatabaseSessionStorage } from '../../session/database.session-storage'; 4 | import { ShopifyCoreOptions } from '@nestjs-shopify/core'; 5 | 6 | @Injectable() 7 | export class ShopifyCoreConfigService { 8 | constructor( 9 | @Inject(shopifyCoreConfig.KEY) 10 | private readonly config: ShopifyCoreConfig, 11 | private readonly sessionStorage: DatabaseSessionStorage 12 | ) {} 13 | 14 | public create(): ShopifyCoreOptions { 15 | return { 16 | ...this.config, 17 | sessionStorage: this.sessionStorage, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/services/shopify-offline-config.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ShopifyAuthModuleOptions, 3 | ShopifyAuthOptionsFactory, 4 | } from '@nestjs-shopify/auth'; 5 | import { Inject, Injectable } from '@nestjs/common'; 6 | import { 7 | ShopifyOfflineConfig, 8 | shopifyOfflineConfig, 9 | } from '../config/offline.config'; 10 | import { AfterAuthHandlerService } from '../after-auth/after-auth-handler.service'; 11 | 12 | @Injectable() 13 | export class ShopifyOfflineConfigService implements ShopifyAuthOptionsFactory { 14 | constructor( 15 | @Inject(shopifyOfflineConfig.KEY) 16 | private readonly config: ShopifyOfflineConfig, 17 | private readonly afterAuthHandler: AfterAuthHandlerService 18 | ) {} 19 | 20 | createShopifyAuthOptions(): ShopifyAuthModuleOptions { 21 | return { 22 | ...this.config, 23 | afterAuthHandler: this.afterAuthHandler, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/services/shopify-online-config.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ShopifyAuthModuleOptions, 3 | ShopifyAuthOptionsFactory, 4 | } from '@nestjs-shopify/auth'; 5 | import { Inject, Injectable } from '@nestjs/common'; 6 | import { 7 | ShopifyOnlineConfig, 8 | shopifyOnlineConfig, 9 | } from '../config/online.config'; 10 | import { AfterAuthHandlerService } from '../after-auth/after-auth-handler.service'; 11 | 12 | @Injectable() 13 | export class ShopifyOnlineConfigService implements ShopifyAuthOptionsFactory { 14 | constructor( 15 | @Inject(shopifyOnlineConfig.KEY) 16 | private readonly config: ShopifyOnlineConfig, 17 | private readonly afterAuthHandler: AfterAuthHandlerService 18 | ) {} 19 | 20 | createShopifyAuthOptions(): ShopifyAuthModuleOptions { 21 | return { 22 | ...this.config, 23 | afterAuthHandler: this.afterAuthHandler, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/webhooks/handlers/products-create.webhook-handler.ts: -------------------------------------------------------------------------------- 1 | import { InjectRepository } from '@mikro-orm/nestjs'; 2 | import { 3 | ShopifyWebhookHandler, 4 | WebhookHandler, 5 | } from '@nestjs-shopify/webhooks'; 6 | import { Logger } from '@nestjs/common'; 7 | import { ShopEntity } from '../../../shops/shop.entity'; 8 | import { ShopRepository } from '../../../shops/shop.repository'; 9 | 10 | @WebhookHandler('PRODUCTS_CREATE') 11 | export class ProductsCreateWebhookHandler extends ShopifyWebhookHandler { 12 | private readonly logger = new Logger('PRODUCTS_CREATE'); 13 | 14 | constructor( 15 | @InjectRepository(ShopEntity) private readonly shopRepo: ShopRepository 16 | ) { 17 | super(); 18 | } 19 | 20 | async handle( 21 | domain: string, 22 | data: unknown, 23 | webhookId: string 24 | ): Promise { 25 | const shop = await this.shopRepo.findOneOrFail({ domain }); 26 | 27 | this.logger.log(`Webhook ${webhookId} called for shop ID ${shop}`); 28 | this.logger.log(data); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/api/src/app/shopify/webhooks/webhooks.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 2 | import { ShopifyWebhooksModule } from '@nestjs-shopify/webhooks'; 3 | import { Module } from '@nestjs/common'; 4 | import { ShopEntity } from '../../shops/shop.entity'; 5 | import { ProductsCreateWebhookHandler } from './handlers/products-create.webhook-handler'; 6 | 7 | @Module({ 8 | imports: [ 9 | MikroOrmModule.forFeature([ShopEntity]), 10 | ShopifyWebhooksModule.forRoot({ 11 | path: '/shopify/webhooks', 12 | }), 13 | ], 14 | providers: [ProductsCreateWebhookHandler], 15 | }) 16 | export class WebhooksModule {} 17 | -------------------------------------------------------------------------------- /apps/api/src/app/shops/shop.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import { v4 } from 'uuid'; 3 | import { ShopRepository } from './shop.repository'; 4 | 5 | @Entity({ 6 | tableName: 'shops', 7 | customRepository: () => ShopRepository, 8 | }) 9 | export class ShopEntity { 10 | @PrimaryKey() 11 | public id: string = v4(); 12 | 13 | @Property() 14 | public domain: string; 15 | 16 | @Property() 17 | public accessToken: string; 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/src/app/shops/shop.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from '@mikro-orm/sqlite'; 2 | import { ShopEntity } from './shop.entity'; 3 | 4 | export class ShopRepository extends EntityRepository {} 5 | -------------------------------------------------------------------------------- /apps/api/src/app/shops/shops.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 2 | import { Module } from '@nestjs/common'; 3 | import { ShopEntity } from './shop.entity'; 4 | import { ShopsService } from './shops.service'; 5 | 6 | @Module({ 7 | imports: [MikroOrmModule.forFeature([ShopEntity])], 8 | providers: [ShopsService], 9 | exports: [ShopsService], 10 | }) 11 | export class ShopsModule {} 12 | -------------------------------------------------------------------------------- /apps/api/src/app/shops/shops.service.ts: -------------------------------------------------------------------------------- 1 | import { InjectRepository } from '@mikro-orm/nestjs'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ShopEntity } from './shop.entity'; 4 | import { ShopRepository } from './shop.repository'; 5 | 6 | @Injectable() 7 | export class ShopsService { 8 | constructor( 9 | @InjectRepository(ShopEntity) private readonly repo: ShopRepository 10 | ) {} 11 | 12 | async findOrCreate(domain: string, accessToken: string): Promise { 13 | let shop = await this.repo.findOne({ domain }); 14 | 15 | if (shop) { 16 | shop = this.repo.assign(shop, { accessToken }); 17 | } else { 18 | shop = this.repo.create({ 19 | domain, 20 | accessToken, 21 | }); 22 | } 23 | 24 | await this.repo.getEntityManager().persistAndFlush(shop); 25 | return shop; 26 | } 27 | 28 | async exists(domain: string): Promise { 29 | return !!(await this.repo.findOne({ domain })); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | }; 4 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is not a production server yet! 3 | * This is only a minimal backend to get started. 4 | */ 5 | 6 | import '@shopify/shopify-api/adapters/node'; 7 | import { Logger } from '@nestjs/common'; 8 | import { NestFactory } from '@nestjs/core'; 9 | 10 | import { AppModule } from './app/app.module'; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule, { rawBody: true }); 14 | const globalPrefix = 'api'; 15 | app.enableShutdownHooks(); 16 | app.setGlobalPrefix(globalPrefix); 17 | const port = process.env.PORT || 3333; 18 | const host = process.env.HOST || `http://localhost:${port}`; 19 | const shop = process.env.SHOP; 20 | await app.listen(port); 21 | Logger.log(`🚀 Application is running on: ${host}/${globalPrefix}`); 22 | 23 | Logger.log(`Login using: ${host}/?shop=${shop}`); 24 | Logger.log( 25 | `Install using: ${host}/${globalPrefix}/offline/auth?shop=${shop}` 26 | ); 27 | } 28 | 29 | bootstrap(); 30 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"], 7 | "emitDecoratorMetadata": true, 8 | "target": "es2015" 9 | }, 10 | "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true 5 | }, 6 | "files": [], 7 | "include": [], 8 | "references": [ 9 | { 10 | "path": "./tsconfig.app.json" 11 | }, 12 | { 13 | "path": "./tsconfig.spec.json" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/.env: -------------------------------------------------------------------------------- 1 | PORT=8081 2 | API_URL=http://localhost:8080 3 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@nrwl/nx/react-typescript", 4 | "next", 5 | "next/core-web-vitals", 6 | "../../.eslintrc.json" 7 | ], 8 | "ignorePatterns": ["!**/*"], 9 | "overrides": [ 10 | { 11 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 12 | "rules": { 13 | "@next/next/no-html-link-for-pages": ["error", "apps/web/pages"] 14 | } 15 | }, 16 | { 17 | "files": ["*.ts", "*.tsx"], 18 | "rules": {} 19 | }, 20 | { 21 | "files": ["*.js", "*.jsx"], 22 | "rules": {} 23 | } 24 | ], 25 | "rules": { 26 | "@next/next/no-html-link-for-pages": "off" 27 | }, 28 | "env": { 29 | "jest": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/components/ProductsCard.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from 'react'; 2 | import { VerticalStack, Button, LegacyCard, Text } from '@shopify/polaris'; 3 | import { useAppBridge } from '@shopify/app-bridge-react'; 4 | import { gql, useMutation } from '@apollo/client'; 5 | import { userLoggedInFetch } from '../utils/userLoggedInFetch'; 6 | 7 | const PRODUCTS_QUERY = gql` 8 | mutation populateProduct($input: ProductInput!) { 9 | productCreate(input: $input) { 10 | product { 11 | title 12 | } 13 | } 14 | } 15 | `; 16 | 17 | export function ProductsCard() { 18 | const [populateProduct, { loading }] = useMutation(PRODUCTS_QUERY); 19 | const [productCount, setProductCount] = useState(0); 20 | const [hasResults, setHasResults] = useState(false); 21 | 22 | const app = useAppBridge(); 23 | const fetch = userLoggedInFetch(app); 24 | const updateProductCount = useCallback(async () => { 25 | const { count } = await fetch('/api/products/count').then((res) => 26 | res.json() 27 | ); 28 | setProductCount(count); 29 | }, [fetch]); 30 | 31 | useEffect(() => { 32 | updateProductCount(); 33 | }, [updateProductCount]); 34 | 35 | const showToast = useCallback(() => { 36 | if (hasResults) { 37 | app.toast.show('1 product created!', { 38 | onDismiss() { 39 | setHasResults(false); 40 | }, 41 | }); 42 | } 43 | }, [app, hasResults, setHasResults]); 44 | 45 | return ( 46 | <> 47 | {showToast()} 48 | 49 | 50 |

51 | Sample products are created with a default title and price. You can 52 | remove them at any time. 53 |

54 | 55 | 56 | TOTAL PRODUCTS 57 | 58 | 59 | 60 | {productCount} 61 | 62 | 63 | 85 |
86 |
87 | 88 | ); 89 | } 90 | 91 | function randomTitle() { 92 | const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; 93 | const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; 94 | 95 | return `${adjective} ${noun}`; 96 | } 97 | 98 | const ADJECTIVES = [ 99 | 'autumn', 100 | 'hidden', 101 | 'bitter', 102 | 'misty', 103 | 'silent', 104 | 'empty', 105 | 'dry', 106 | 'dark', 107 | 'summer', 108 | 'icy', 109 | 'delicate', 110 | 'quiet', 111 | 'white', 112 | 'cool', 113 | 'spring', 114 | 'winter', 115 | 'patient', 116 | 'twilight', 117 | 'dawn', 118 | 'crimson', 119 | 'wispy', 120 | 'weathered', 121 | 'blue', 122 | 'billowing', 123 | 'broken', 124 | 'cold', 125 | 'damp', 126 | 'falling', 127 | 'frosty', 128 | 'green', 129 | 'long', 130 | 'late', 131 | 'lingering', 132 | 'bold', 133 | 'little', 134 | 'morning', 135 | 'muddy', 136 | 'old', 137 | 'red', 138 | 'rough', 139 | 'still', 140 | 'small', 141 | 'sparkling', 142 | 'throbbing', 143 | 'shy', 144 | 'wandering', 145 | 'withered', 146 | 'wild', 147 | 'black', 148 | 'young', 149 | 'holy', 150 | 'solitary', 151 | 'fragrant', 152 | 'aged', 153 | 'snowy', 154 | 'proud', 155 | 'floral', 156 | 'restless', 157 | 'divine', 158 | 'polished', 159 | 'ancient', 160 | 'purple', 161 | 'lively', 162 | 'nameless', 163 | ]; 164 | 165 | const NOUNS = [ 166 | 'waterfall', 167 | 'river', 168 | 'breeze', 169 | 'moon', 170 | 'rain', 171 | 'wind', 172 | 'sea', 173 | 'morning', 174 | 'snow', 175 | 'lake', 176 | 'sunset', 177 | 'pine', 178 | 'shadow', 179 | 'leaf', 180 | 'dawn', 181 | 'glitter', 182 | 'forest', 183 | 'hill', 184 | 'cloud', 185 | 'meadow', 186 | 'sun', 187 | 'glade', 188 | 'bird', 189 | 'brook', 190 | 'butterfly', 191 | 'bush', 192 | 'dew', 193 | 'dust', 194 | 'field', 195 | 'fire', 196 | 'flower', 197 | 'firefly', 198 | 'feather', 199 | 'grass', 200 | 'haze', 201 | 'mountain', 202 | 'night', 203 | 'pond', 204 | 'darkness', 205 | 'snowflake', 206 | 'silence', 207 | 'sound', 208 | 'sky', 209 | 'shape', 210 | 'surf', 211 | 'thunder', 212 | 'violet', 213 | 'water', 214 | 'wildflower', 215 | 'wave', 216 | 'water', 217 | 'resonance', 218 | 'sun', 219 | 'wood', 220 | 'dream', 221 | 'cherry', 222 | 'tree', 223 | 'fog', 224 | 'frost', 225 | 'voice', 226 | 'paper', 227 | 'frog', 228 | 'smoke', 229 | 'star', 230 | ]; 231 | -------------------------------------------------------------------------------- /apps/web/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | declare module '*.svg' { 3 | const content: any; 4 | export const ReactComponent: any; 5 | export default content; 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'web', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest', 7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }], 8 | }, 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 10 | coverageDirectory: '../../coverage/apps/web', 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { withNx } = require('@nrwl/next/plugins/with-nx'); 3 | 4 | /** 5 | * @type {import('@nrwl/next/plugins/with-nx').WithNxOptions} 6 | **/ 7 | const nextConfig = { 8 | env: { 9 | SHOPIFY_API_KEY: process.env.SHOPIFY_API_KEY, 10 | }, 11 | async rewrites() { 12 | return [ 13 | { 14 | source: '/api/:path*', 15 | destination: `${process.env.API_URL}/api/:path*`, 16 | }, 17 | ]; 18 | }, 19 | nx: { 20 | // Set this to true if you would like to to use SVGR 21 | // See: https://github.com/gregberge/svgr 22 | svgr: false, 23 | }, 24 | }; 25 | 26 | module.exports = withNx(nextConfig); 27 | -------------------------------------------------------------------------------- /apps/web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | ApolloProvider, 4 | HttpLink, 5 | InMemoryCache, 6 | } from '@apollo/client'; 7 | import { useAppBridge } from '@shopify/app-bridge-react'; 8 | import { AppProvider as PolarisProvider } from '@shopify/polaris'; 9 | import translations from '@shopify/polaris/locales/en.json'; 10 | import '@shopify/polaris/build/esm/styles.css'; 11 | import App from 'next/app'; 12 | 13 | import { userLoggedInFetch } from '../utils/userLoggedInFetch'; 14 | 15 | function MyProvider(props) { 16 | const app = useAppBridge(); 17 | 18 | const client = new ApolloClient({ 19 | cache: new InMemoryCache(), 20 | link: new HttpLink({ 21 | uri: '/api/graphql', 22 | credentials: 'include', 23 | fetch: userLoggedInFetch(app), 24 | }), 25 | }); 26 | 27 | const { Component } = props; 28 | 29 | return ( 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | class MyApp extends App { 37 | render() { 38 | const { Component, pageProps, host } = this.props as any; 39 | 40 | return ( 41 | 42 | 43 | 44 | ); 45 | } 46 | } 47 | 48 | MyApp.getInitialProps = async ({ ctx: { query, res } }) => { 49 | const { host, shop } = query as Record; 50 | 51 | if (!host) { 52 | const currentShop = shop ?? process.env.SHOP; 53 | 54 | if (res) { 55 | res.writeHead(307, { Location: `/api/online/auth?shop=${currentShop}` }); 56 | res.end(); 57 | 58 | return { pageProps: {} }; 59 | } 60 | 61 | return { 62 | pageProps: {}, 63 | }; 64 | } 65 | 66 | return { 67 | pageProps: { host, shop }, 68 | host, 69 | shop, 70 | }; 71 | }; 72 | 73 | export default MyApp; 74 | -------------------------------------------------------------------------------- /apps/web/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |