├── .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 |
--------------------------------------------------------------------------------