├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── README.md ├── app ├── entry.client.tsx ├── entry.server.tsx ├── remixShopify.server.ts ├── root.tsx ├── routes │ ├── _p._index.tsx │ ├── _p.tsx │ ├── api.auth.callback.ts │ └── api.auth_.ts └── sessions │ ├── shopDomainSession.server.ts │ └── stateSession.server.ts ├── docker-compose.yaml ├── package.json ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── gitignore ├── index.js └── package.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NGROK_AUTH_TOKEN= 2 | NGROK_SUBDOMAIN= 3 | 4 | SHOPIFY_API_KEY= 5 | SHOPIFY_API_SECRET= 6 | 7 | SHOPIFY_APP_PERMISSIONS="read_customers,write_customers" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | build 3 | node_modules 4 | public/build -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | 8 | # We don't want lockfiles in stacks, as people could use a different package manager 9 | # This part will be removed by `remix.init` 10 | package-lock.json 11 | yarn.lock 12 | pnpm-lock.yaml 13 | pnpm-lock.yml 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Depreacted 2 | 3 | ⚠️ This project is deprecated. 4 | 5 | Shopify released the official remix stack for apps, you can find it [here](https://github.com/Shopify/shopify-app-template-remix). 6 | 7 | This project is now deprecated. 8 | 9 | # Remix Shopify App 10 | 11 | All in one Remix.run template to get started with Shopify App's. 12 | 13 | Learn more about [Remix Stacks](https://remix.run/stacks). 14 | 15 | ```sh 16 | npx create-remix@latest --template nullndr/remix-shopify 17 | ``` 18 | 19 | ## What's in the stack 20 | 21 | - Shopify [Polaris](https://polaris.shopify.com) react library 22 | - OAuth 2.0 [authorization flow](https://shopify.dev/apps/auth/oauth#the-oauth-flow) to issue access tokens on users 23 | - Code formatting with [Prettier](https://prettier.io) 24 | - Linting with [ESLint](https://eslint.org) 25 | - Static Types with [TypeScript](https://typescriptlang.org) 26 | 27 | ## Docker 28 | 29 | The [`docker-compose.yaml`](./docker-compose.yaml) file starts an ngrok container. 30 | 31 | For this you need to get an ngrok auth token and set it in your `.env` file (you can use the `.env.example` file as example). 32 | 33 | The `NGROK_SUBDOMAIN` is your subdomain for the `ngrok.io` domain, for example if you set `NGROK_SUBDOMAIN=myfoobar` your app will be accessible at `myfoobar.ngrok.io`. 34 | 35 | This service starts also a web server at `localhost:4040` to monitor your ngrok service. 36 | 37 | ## Development 38 | 39 | 1. Write your `SHOPIFY_API_KEY` and your `SHOPIFY_API_SECRET` in the `.env` file (you can use the `.env.example` file as example). 40 | 41 | 2. Replace the `SHOPIFY_APP_PERMISSIONS` value in the `.env` file with the permissions your app needs. 42 | 43 | > Remember to write them with the following format: 44 | > ```sh 45 | > SHOPIFY_APP_PERMISSIONS="read_customers,write_customers" 46 | > ``` 47 | 48 | 3. Run the first build: 49 | ```sh 50 | npm run build 51 | ``` 52 | 53 | 4. Start dev server: 54 | ```sh 55 | npm run dev 56 | ``` 57 | 58 | ### Type Checking 59 | 60 | This project uses TypeScript. 61 | 62 | It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. 63 | 64 | To run type checking across the whole project, run the following: 65 | 66 | ```sh 67 | npm run typecheck 68 | ``` 69 | 70 | ### Linting 71 | 72 | This project uses ESLint for linting. 73 | 74 | You can find it's configurations in `.eslintrc.js`. 75 | 76 | ### Formatting 77 | 78 | We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project. -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | function hydrate() { 6 | startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | } 15 | 16 | if (typeof requestIdleCallback === "function") { 17 | requestIdleCallback(hydrate); 18 | } else { 19 | // Safari doesn't support requestIdleCallback 20 | // https://caniuse.com/requestidlecallback 21 | setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { Response } from "@remix-run/node"; 3 | import { RemixServer } from "@remix-run/react"; 4 | import isbot from "isbot"; 5 | import { renderToPipeableStream } from "react-dom/server"; 6 | import { PassThrough } from "stream"; 7 | 8 | const ABORT_DELAY = 5000; 9 | 10 | export default function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | remixContext: EntryContext 15 | ) { 16 | return isbot(request.headers.get("user-agent")) 17 | ? handleBotRequest( 18 | request, 19 | responseStatusCode, 20 | responseHeaders, 21 | remixContext 22 | ) 23 | : handleBrowserRequest( 24 | request, 25 | responseStatusCode, 26 | responseHeaders, 27 | remixContext 28 | ); 29 | } 30 | 31 | function handleBotRequest( 32 | request: Request, 33 | responseStatusCode: number, 34 | responseHeaders: Headers, 35 | remixContext: EntryContext 36 | ) { 37 | return new Promise((resolve, reject) => { 38 | let didError = false; 39 | 40 | const { pipe, abort } = renderToPipeableStream( 41 | , 42 | { 43 | onAllReady() { 44 | const body = new PassThrough(); 45 | 46 | responseHeaders.set("Content-Type", "text/html"); 47 | 48 | resolve( 49 | new Response(body, { 50 | headers: responseHeaders, 51 | status: didError ? 500 : responseStatusCode, 52 | }) 53 | ); 54 | 55 | pipe(body); 56 | }, 57 | onShellError(error: unknown) { 58 | reject(error); 59 | }, 60 | onError(error: unknown) { 61 | didError = true; 62 | 63 | console.error(error); 64 | }, 65 | } 66 | ); 67 | 68 | setTimeout(abort, ABORT_DELAY); 69 | }); 70 | } 71 | 72 | function handleBrowserRequest( 73 | request: Request, 74 | responseStatusCode: number, 75 | responseHeaders: Headers, 76 | remixContext: EntryContext 77 | ) { 78 | return new Promise((resolve, reject) => { 79 | let didError = false; 80 | 81 | const { pipe, abort } = renderToPipeableStream( 82 | , 83 | { 84 | onShellReady() { 85 | const body = new PassThrough(); 86 | 87 | responseHeaders.set("Content-Type", "text/html"); 88 | 89 | resolve( 90 | new Response(body, { 91 | headers: responseHeaders, 92 | status: didError ? 500 : responseStatusCode, 93 | }) 94 | ); 95 | 96 | pipe(body); 97 | }, 98 | onShellError(err: unknown) { 99 | reject(err); 100 | }, 101 | onError(error: unknown) { 102 | didError = true; 103 | 104 | console.error(error); 105 | }, 106 | } 107 | ); 108 | 109 | setTimeout(abort, ABORT_DELAY); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /app/remixShopify.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@remix-run/node"; 2 | import * as crypto from "crypto"; 3 | import { stateSession } from "./sessions/stateSession.server"; 4 | 5 | type BeginArgs = { 6 | clientId: string; 7 | scopes: string[]; 8 | callbackPath: string; 9 | isOnline?: boolean; 10 | }; 11 | 12 | /** 13 | * Initializate an OAuth request from Shopify. 14 | * 15 | * This function will throw a redirect to the authorization url so it never returns. 16 | */ 17 | export async function beginShopifyAuth( 18 | shop: string, 19 | { scopes, clientId, callbackPath, isOnline = false }: BeginArgs, 20 | ): Promise { 21 | const session = await stateSession.getSession(); 22 | const state = nonce(); 23 | session.set("state", state); 24 | 25 | const redirectUrl = new URL(`https://${shop}/admin/oauth/authorize`); 26 | 27 | redirectUrl.searchParams.append("client_id", clientId); 28 | redirectUrl.searchParams.append("scope", scopes.join(",")); 29 | redirectUrl.searchParams.append("redirect_uri", callbackPath); 30 | redirectUrl.searchParams.append("state", state); 31 | redirectUrl.searchParams.append( 32 | "grant_options[]", 33 | isOnline ? "per-user" : "", 34 | ); 35 | 36 | throw redirect(redirectUrl.toString(), { 37 | headers: { 38 | "Set-Cookie": await stateSession.commitSession(session), 39 | }, 40 | }); 41 | } 42 | 43 | type CallbackArgs = { 44 | request: Request; 45 | clientId: string; 46 | appSecret: string; 47 | }; 48 | 49 | type CallbackResult = { 50 | shopifyDomain: string; 51 | accessToken: string; 52 | host: string; 53 | }; 54 | 55 | /** 56 | * Validate the request from Shopify sent to `redirectPath` in `initializeShopifyAuth`. 57 | * 58 | * Returns the access token on success. 59 | */ 60 | export async function callbackShopifyAuth({ 61 | request, 62 | clientId, 63 | appSecret, 64 | }: CallbackArgs): Promise { 65 | const url = new URL(request.url); 66 | 67 | const code = url.searchParams.get("code"); 68 | const shop = url.searchParams.get("shop"); 69 | const host = url.searchParams.get("host"); 70 | 71 | if (!code || !shop || !host) { 72 | throw new Response(null, { 73 | status: 400, 74 | }); 75 | } 76 | 77 | const stateFromUrl = url.searchParams.get("state"); 78 | 79 | const session = await stateSession.getSession(request.headers.get("Cookie")); 80 | const stateFromSession = session.get("state"); 81 | stateSession.destroySession(session); 82 | 83 | const isVerifiedRequest = isValidHMAC(request, appSecret); 84 | const isValidState = stateFromSession === stateFromUrl; 85 | 86 | if (!isVerifiedRequest || !isValidState) { 87 | throw new Response(null, { 88 | status: 401, 89 | }); 90 | } 91 | 92 | const response = await fetch(`https://${shop}/admin/oauth/access_token`, { 93 | method: "POST", 94 | headers: { 95 | "Content-Type": "application/json", 96 | }, 97 | body: JSON.stringify({ 98 | client_id: clientId, 99 | client_secret: appSecret, 100 | code, 101 | }), 102 | }); 103 | 104 | const payload = await response.json(); 105 | 106 | if ("access_token" in payload && typeof payload.access_token === "string") { 107 | return { shopifyDomain: shop, accessToken: payload.access_token, host }; 108 | } 109 | 110 | throw new Response(null, { 111 | status: 401, 112 | }); 113 | } 114 | 115 | const nonce = () => { 116 | const length = 15; 117 | const bytes = crypto.getRandomValues(new Uint8Array(length)); 118 | return bytes.map((byte) => byte % 10).join(""); 119 | }; 120 | 121 | const isValidHMAC = (request: Request, secret: string): boolean => { 122 | const url = new URL(request.url); 123 | const hmac = url.searchParams.get("hmac"); 124 | 125 | if (hmac == null) { 126 | return false; 127 | } 128 | 129 | /* 130 | * In order to check if the 'hmac' parameter is correct, 131 | * we need to separate the 'hmac' parameter from the others 132 | */ 133 | url.searchParams.delete("hmac"); 134 | 135 | /* 136 | * The remaining parameters must also be sorted: 137 | * https://shopify.dev/apps/auth/oauth/getting-started#remove-the-hmac-parameter-from-the-query-string 138 | */ 139 | url.searchParams.sort(); 140 | const urlParamsWithoutHMAC = url.searchParams.toString(); 141 | 142 | const hmacCalculated = crypto 143 | .createHmac("sha256", secret) 144 | .update(Buffer.from(urlParamsWithoutHMAC)) 145 | .digest("hex") 146 | .toLowerCase(); 147 | 148 | return hmac === hmacCalculated; 149 | }; 150 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "@remix-run/node"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react"; 10 | import "@shopify/polaris/build/esm/styles.css"; 11 | 12 | export const meta: MetaFunction = () => ({ 13 | charset: "utf-8", 14 | title: "New Remix App", 15 | viewport: "width=device-width,initial-scale=1", 16 | }); 17 | 18 | export const links: LinksFunction = () => [ 19 | { 20 | rel: "stylesheet", 21 | href: "https://unpkg.com/@shopify/polaris@10.16.1/build/esm/styles.css", 22 | }, 23 | ]; 24 | 25 | export default function App() { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/routes/_p._index.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Page } from "@shopify/polaris"; 2 | 3 | export default function Index() { 4 | return ( 5 | 6 | 7 | 8 | 13 | 15m Quickstart Blog Tutorial 14 | 15 | 16 | 17 | 22 | Deep Dive Jokes App Tutorial 23 | 24 | 25 | 26 | 31 | Polaris Components 32 | 33 | 34 | 35 | 36 | Remix Docs 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/_p.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { 3 | Outlet, 4 | useLoaderData, 5 | useLocation, 6 | useNavigate, 7 | } from "@remix-run/react"; 8 | import { Provider as ShopifyProvider } from "@shopify/app-bridge-react"; 9 | import { AppProvider } from "@shopify/polaris"; 10 | import en from "@shopify/polaris/locales/en.json"; 11 | import { redirect } from "react-router"; 12 | import { shopDomainSession } from "~/sessions/shopDomainSession.server"; 13 | 14 | export const loader = async ({ request }: LoaderArgs) => { 15 | const url = new URL(request.url); 16 | const shop = url.searchParams.get("shop"); 17 | const host = url.searchParams.get("host"); 18 | const apiKey = process.env.SHOPIFY_API_KEY; 19 | 20 | if (!apiKey) { 21 | throw new Error("Missing shopify api key"); 22 | } 23 | 24 | if (shop && host) { 25 | const session = await shopDomainSession.getSession( 26 | request.headers.get("Cookie"), 27 | ); 28 | const shopifyDomain = session.get("shopifyDomain"); 29 | 30 | if (shopifyDomain === shop) { 31 | return { 32 | apiKey, 33 | host, 34 | }; 35 | } 36 | 37 | throw redirect(`/api/auth?shop=${shop}&host=${host}`); 38 | } 39 | 40 | throw new Error("Missing shop or host parameters"); 41 | }; 42 | 43 | export default function Provider() { 44 | const { apiKey, host } = useLoaderData(); 45 | const location = useLocation(); 46 | const navigate = useNavigate(); 47 | 48 | return ( 49 | 50 | navigate(path), 60 | }, 61 | }} 62 | > 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/routes/api.auth.callback.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { redirect } from "react-router"; 3 | import { callbackShopifyAuth } from "~/remixShopify.server"; 4 | import { shopDomainSession } from "~/sessions/shopDomainSession.server"; 5 | 6 | export const loader = async ({ request }: LoaderArgs) => { 7 | const clientId = process.env.SHOPIFY_API_KEY; 8 | const appSecret = process.env.SHOPIFY_API_SECRET; 9 | 10 | if (clientId == null || appSecret == null) { 11 | throw new Response(null, { status: 500 }); 12 | } 13 | 14 | const { accessToken, host, shopifyDomain } = await callbackShopifyAuth({ 15 | request, 16 | clientId, 17 | appSecret, 18 | }); 19 | 20 | const session = await shopDomainSession.getSession(); 21 | session.set("shopifyDomain", shopifyDomain); 22 | throw redirect(`/?shop=${shopifyDomain}&host=${host}`, { 23 | headers: { 24 | "Set-Cookie": await shopDomainSession.commitSession(session), 25 | }, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /app/routes/api.auth_.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { beginShopifyAuth } from "~/remixShopify.server"; 3 | 4 | export const loader = async ({ request }: LoaderArgs) => { 5 | const hostName = request.headers.get("host"); 6 | 7 | const url = new URL(request.url); 8 | const shop = url.searchParams.get("shop"); 9 | const host = url.searchParams.get("host"); 10 | 11 | if (shop == null || host == null) { 12 | throw new Response(null, { 13 | status: 400, 14 | }); 15 | } 16 | 17 | const clientId = process.env.SHOPIFY_API_KEY; 18 | 19 | if (clientId == null) { 20 | throw new Error("Missing app client id"); 21 | } 22 | 23 | await beginShopifyAuth(shop, { 24 | clientId, 25 | scopes: [ 26 | "read_customers", 27 | "write_customers", 28 | "read_orders", 29 | "read_fulfillments", 30 | ], 31 | callbackPath: `https://${hostName}/auth/callback`, 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /app/sessions/shopDomainSession.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "@remix-run/node"; 2 | 3 | type SessionData = { 4 | shopifyDomain: string; 5 | }; 6 | 7 | export const shopDomainSession = createCookieSessionStorage({ 8 | cookie: { 9 | name: "shopify_app_domain", 10 | secure: true, 11 | sameSite: "none", 12 | secrets: ["sup3r_s3cr3t"], 13 | path: "/", 14 | httpOnly: true, 15 | maxAge: 86400, // 1 day 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /app/sessions/stateSession.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "@remix-run/node"; 2 | 3 | type SessionData = { 4 | state: string; 5 | }; 6 | 7 | export const stateSession = createCookieSessionStorage({ 8 | cookie: { 9 | name: "shopify_app_state", 10 | secure: true, 11 | secrets: ["sup3r_se3cr3t"], 12 | path: "/", 13 | httpOnly: true, 14 | sameSite: "none", 15 | maxAge: 120, // 2 minutes 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | ngrok: 4 | container_name: ngrok 5 | image: ngrok/ngrok:alpine 6 | env_file: 7 | - .env 8 | command: http 3000 --authtoken ${NGROK_AUTH_TOKEN} --subdomain ${NGROK_SUBDOMAIN} 9 | network_mode: host -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build": "remix build", 6 | "dev": "remix dev", 7 | "start": "remix-serve build", 8 | "typecheck": "tsc -b --noEmit", 9 | "format": "prettier --write ./app", 10 | "lint": "npx eslint ." 11 | }, 12 | "dependencies": { 13 | "@remix-run/node": "*", 14 | "@remix-run/react": "*", 15 | "@remix-run/serve": "*", 16 | "@shopify/app-bridge-react": "^3.7.2", 17 | "@shopify/polaris": "^10.16.1", 18 | "isbot": "^3.6.5", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0" 21 | }, 22 | "devDependencies": { 23 | "@remix-run/dev": "*", 24 | "@remix-run/eslint-config": "*", 25 | "@types/react": "^18.0.25", 26 | "@types/react-dom": "^18.0.8", 27 | "eslint": "^8.27.0", 28 | "prettier": "^2.8.1", 29 | "typescript": "^4.8.4" 30 | }, 31 | "engines": { 32 | "node": ">=14" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullndr/remix-shopify/54baadd85fbfaf2c8875a9f8ed203442cc8dc3fc/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // serverBuildPath: "build/index.js", 7 | // publicPath: "/build/", 8 | future: { 9 | v2_routeConvention: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /remix.init/gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env -------------------------------------------------------------------------------- /remix.init/index.js: -------------------------------------------------------------------------------- 1 | const { copyFile, readFile, writeFile } = require("fs/promises"); 2 | const { join } = require("path"); 3 | 4 | async function main({ rootDirectory }) { 5 | const EXAMPLE_ENV_PATH = join(rootDirectory, ".env.example"); 6 | const ENV_PATH = join(rootDirectory, ".env"); 7 | 8 | const env = await readFile(EXAMPLE_ENV_PATH, "utf-8"); 9 | 10 | await Promise.all([ 11 | writeFile(ENV_PATH, env), 12 | copyFile( 13 | join(rootDirectory, "remix.init", "gitignore"), 14 | join(rootDirectory, ".gitignore") 15 | ), 16 | ]); 17 | } 18 | 19 | module.exports = main; 20 | -------------------------------------------------------------------------------- /remix.init/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix.init", 3 | "private": true, 4 | "license": "MIT", 5 | "main": "index.js", 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------