├── .gitignore
├── LICENSE
├── README.md
├── app
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│ ├── app.tsx
│ ├── app
│ │ ├── index.tsx
│ │ └── products.tsx
│ ├── auth
│ │ ├── callback.tsx
│ │ └── index.tsx
│ ├── index.tsx
│ ├── login.tsx
│ └── login
│ │ └── initialize
│ │ └── $shop.tsx
└── utils
│ ├── db.server.ts
│ ├── security.ts
│ └── session.server.ts
├── package-lock.json
├── package.json
├── prisma
├── schema.prisma
└── seed.ts
├── public
└── favicon.ico
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | /public/build
6 | .env
7 |
8 | prisma/dev.db
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Willson Smith
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Remix Shopify App
2 |
3 | A bare-bones Shopify app build with [Remix](https://remix.run)
4 |
5 | Not supported by or affiliated with Shopify
6 |
7 | 1. Create `.env`
8 | 2. Add `API_KEY` to `.env`
9 | 3. Add `API_SECRET_KEY` to `.env`
10 | 4. Add `SESSION_SECRET` to `.env`
11 |
12 | ## Remix
13 |
14 | - [Remix Docs](https://remix.run/docs)
15 |
16 | ### Development
17 |
18 | From your terminal:
19 |
20 | ```sh
21 | npm run dev
22 | ```
23 |
24 | This starts your app in development mode, rebuilding assets on file changes.
25 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { hydrate } from "react-dom";
2 | import { RemixBrowser } from "remix";
3 |
4 | hydrate(, document);
5 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { renderToString } from "react-dom/server";
3 | import { RemixServer } from "remix";
4 | import type { EntryContext } from "remix";
5 |
6 | export default function handleRequest(
7 | request: Request,
8 | responseStatusCode: number,
9 | responseHeaders: Headers,
10 | remixContext: EntryContext
11 | ) {
12 | const markup = renderToString(
13 |
14 | );
15 |
16 | responseHeaders.set("Content-Type", "text/html");
17 |
18 | return new Response("" + markup, {
19 | status: responseStatusCode,
20 | headers: responseHeaders,
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Links,
3 | LiveReload,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | } from "remix";
9 | import type { MetaFunction } from "remix";
10 |
11 | export const meta: MetaFunction = () => {
12 | return { title: "New Remix App" };
13 | };
14 |
15 | export default function App() {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {process.env.NODE_ENV === "development" && }
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/routes/app.tsx:
--------------------------------------------------------------------------------
1 | import { AppProvider } from "@shopify/polaris";
2 | import shopifyStyles from "@shopify/polaris/build/esm/styles.css";
3 | import enTranslations from "@shopify/polaris/locales/en.json";
4 |
5 | import { LinksFunction, Outlet } from "remix";
6 | export const links: LinksFunction = () => {
7 | return [
8 | {
9 | rel: "stylesheet",
10 | href: shopifyStyles,
11 | },
12 | ];
13 | };
14 |
15 | export default function App() {
16 | return (
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/app/routes/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "remix";
2 |
3 | export default function Index() {
4 | return (
5 |
6 |
7 | -
8 | Products
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/routes/app/products.tsx:
--------------------------------------------------------------------------------
1 | import { Card, List, Page } from "@shopify/polaris";
2 | import { LoaderFunction, useLoaderData } from "remix";
3 | import { getShop, requireAccessToken } from "~/utils/session.server";
4 |
5 | const query = `
6 | {
7 | products(first: 5) {
8 | edges {
9 | node {
10 | id
11 | handle
12 | title
13 | description
14 | }
15 | }
16 | pageInfo {
17 | hasNextPage
18 | }
19 | }
20 | }
21 | `;
22 |
23 | export const loader: LoaderFunction = async ({ request }) => {
24 | const accessToken = await requireAccessToken(request);
25 | const shop = await getShop(request);
26 | try {
27 | const response = await fetch(
28 | `https://${shop}/admin/api/2022-01/graphql.json`,
29 | {
30 | method: "POST",
31 | headers: {
32 | "Content-Type": "application/graphql",
33 | "X-Shopify-Access-Token": accessToken,
34 | },
35 | body: query,
36 | }
37 | );
38 | const data = await response.json();
39 | const {
40 | data: {
41 | products: { edges },
42 | },
43 | } = data;
44 | return edges;
45 | } catch (e) {
46 | return {};
47 | }
48 | };
49 | export default function Products() {
50 | const products = useLoaderData();
51 | return (
52 |
53 |
54 |
55 | {products.map((edge: any) => {
56 | const { node: product } = edge;
57 | return (
58 |
59 | {product.title}
60 | {product.description}
61 |
62 | );
63 | })}
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/app/routes/auth/callback.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction } from "remix";
2 | import { handleCallback } from "~/utils/session.server";
3 |
4 | export const loader: LoaderFunction = async ({ request }) => {
5 | return await handleCallback(request);
6 | };
7 |
8 | export default function () {
9 | return (
10 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/routes/auth/index.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "remix";
2 |
3 | export default function () {
4 | return (
5 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link, LinksFunction, LoaderFunction } from "remix";
2 | import { AppProvider, Page, Card, Layout } from "@shopify/polaris";
3 | import shopifyStyles from "@shopify/polaris/build/esm/styles.css";
4 | import enTranslations from "@shopify/polaris/locales/en.json";
5 |
6 | export const links: LinksFunction = () => {
7 | return [
8 | {
9 | rel: "stylesheet",
10 | href: shopifyStyles,
11 | },
12 | ];
13 | };
14 |
15 | export const loader: LoaderFunction = async (request) => {
16 | return {};
17 | };
18 |
19 | export default function Index() {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 | -
28 | Login
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import {
3 | AppProvider,
4 | Page,
5 | Card,
6 | Layout,
7 | Button,
8 | TextField,
9 | } from "@shopify/polaris";
10 | import shopifyStyles from "@shopify/polaris/build/esm/styles.css";
11 | import enTranslations from "@shopify/polaris/locales/en.json";
12 |
13 | import { LinksFunction, LoaderFunction, useLoaderData } from "remix";
14 |
15 | export const links: LinksFunction = () => {
16 | return [
17 | {
18 | rel: "stylesheet",
19 | href: shopifyStyles,
20 | },
21 | ];
22 | };
23 |
24 | export const loader: LoaderFunction = async ({ request }) => {
25 | return {};
26 | };
27 |
28 | export default function Index() {
29 | const [url, setUrl] = useState("");
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
44 | You must log in to access this App.
45 | {
48 | setUrl(value);
49 | }}
50 | label="URL"
51 | value={url}
52 | />
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/app/routes/login/initialize/$shop.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction } from "remix";
2 | import { beginAuth } from "~/utils/session.server";
3 |
4 | export const loader: LoaderFunction = async ({ request, params }) => {
5 | const shop = params.shop;
6 | if (!shop) throw new Error("Shop is required");
7 | return await beginAuth(request, { shop });
8 | };
9 |
--------------------------------------------------------------------------------
/app/utils/db.server.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | import { redirect } from "remix";
3 |
4 | let db: PrismaClient;
5 |
6 | declare global {
7 | var __db: PrismaClient | undefined;
8 | }
9 |
10 | // this is needed because in development we don't want to restart
11 | // the server with every change, but we want to make sure we don't
12 | // create a new connection to the DB with every change either.
13 | if (process.env.NODE_ENV === "production") {
14 | db = new PrismaClient();
15 | db.$connect();
16 | } else {
17 | if (!global.__db) {
18 | global.__db = new PrismaClient();
19 | global.__db.$connect();
20 | }
21 | db = global.__db;
22 | }
23 |
24 | export async function requireActiveShop(shopName: string) {
25 | const shop = await db.shop.findUnique({
26 | where: { name: shopName },
27 | });
28 |
29 | if (!shop) throw redirect("/login");
30 | return shop;
31 | }
32 |
33 | export { db };
34 |
--------------------------------------------------------------------------------
/app/utils/security.ts:
--------------------------------------------------------------------------------
1 | import crypto from "crypto";
2 | import { getUserSession } from "./session.server";
3 |
4 | export function nonce(): string {
5 | const length = 15;
6 | const bytes = crypto.randomBytes(length);
7 |
8 | const nonce = bytes
9 | .map((byte) => {
10 | return byte % 10;
11 | })
12 | .join("");
13 |
14 | return nonce;
15 | }
16 |
17 | export async function passesSecurityCheck(request: Request) {
18 | const url = new URL(request.url);
19 | const state = url.searchParams.get("state");
20 | const session = await getUserSession(request);
21 | const storedState = session.get("state");
22 |
23 | // 1. Verify that the state matches the one we stored in the session
24 | if (state !== storedState) return false;
25 |
26 | // 2. Verify that the hmac is signed
27 | const searchParams = new URLSearchParams();
28 | for (const [key, value] of url.searchParams) {
29 | if (key !== "hmac") searchParams.append(key, value);
30 | }
31 | const localHmac = crypto
32 | .createHmac("sha256", process.env.API_SECRET_KEY!)
33 | .update(searchParams.toString())
34 | .digest("hex");
35 |
36 | const hmac = url.searchParams.get("hmac");
37 | if (localHmac !== hmac) return false;
38 | return true;
39 | }
40 |
--------------------------------------------------------------------------------
/app/utils/session.server.ts:
--------------------------------------------------------------------------------
1 | import { nonce, passesSecurityCheck } from "./security";
2 | import { createCookieSessionStorage, redirect } from "remix";
3 |
4 | const sessionSecret = process.env.SESSION_SECRET;
5 | if (!sessionSecret) throw new Error("SESSION_SECRET is not set");
6 | const storage = createCookieSessionStorage({
7 | cookie: {
8 | name: "shopify-remix-app",
9 | // normally you want this to be `secure: true`
10 | // but that doesn't work on localhost for Safari
11 | // https://web.dev/when-to-use-local-https/
12 | secure: process.env.NODE_ENV === "production",
13 | secrets: [sessionSecret],
14 | sameSite: "lax",
15 | path: "/",
16 | maxAge: 60 * 60 * 24 * 30,
17 | httpOnly: true,
18 | },
19 | });
20 |
21 | export async function getUserSession(request: Request) {
22 | return storage.getSession(request.headers.get("Cookie"));
23 | }
24 |
25 | export async function getShop(request: Request) {
26 | const session = await getUserSession(request);
27 | const shop = session.get("shop");
28 | if (!shop) throw new Error("Shop is not set");
29 | return shop;
30 | }
31 |
32 | export async function getAccessToken(request: Request) {
33 | const session = await getUserSession(request);
34 | const accessToken = session.get("accessToken");
35 | if (!accessToken || typeof accessToken !== "string") return null;
36 | return accessToken;
37 | }
38 |
39 | export async function requireAccessToken(request: Request) {
40 | const session = await getUserSession(request);
41 | const accessToken = session.get("accessToken");
42 | if (!accessToken || typeof accessToken !== "string") throw redirect("/login");
43 | return accessToken;
44 | }
45 |
46 | export async function beginAuth(request: Request, params: { shop: string }) {
47 | const { shop } = params;
48 | if (!shop) throw new Error('"shop" query param is required');
49 |
50 | const url = new URL(request.url);
51 | const session = await storage.getSession();
52 | const state = nonce();
53 | session.set("state", state);
54 |
55 | const queryParams = new URLSearchParams({
56 | client_id: process.env.API_KEY!,
57 | scope: "read_products,write_products",
58 | redirect_uri: `https://${url.host}/auth/callback`,
59 | state,
60 | "grant_options[]": "per-user",
61 | });
62 |
63 | const query = decodeURIComponent(queryParams.toString());
64 | const authRoute = `https://${shop}.myshopify.com/admin/oauth/authorize?${query}`;
65 | return redirect(authRoute, {
66 | headers: {
67 | "Set-Cookie": await storage.commitSession(session),
68 | },
69 | });
70 | }
71 |
72 | export async function handleCallback(request: Request) {
73 | const url = new URL(request.url);
74 | const shop = url.searchParams.get("shop");
75 | const code = url.searchParams.get("code");
76 | if (!code) throw redirect("/");
77 | if (!(await passesSecurityCheck(request))) {
78 | throw new Error("Security check failed");
79 | }
80 |
81 | const params = new URLSearchParams([
82 | ["client_id", process.env.API_KEY || ""],
83 | ["client_secret", process.env.API_SECRET_KEY || ""],
84 | ["code", code],
85 | ]);
86 |
87 | try {
88 | const response = await fetch(
89 | `https://${shop}/admin/oauth/access_token?${params}`,
90 | {
91 | method: "POST",
92 | }
93 | );
94 | const json = await response.json();
95 | const session = await getUserSession(request);
96 |
97 | session.set("shop", shop);
98 | session.set("accessToken", json.access_token);
99 |
100 | return redirect("/app/products", {
101 | headers: {
102 | "Set-Cookie": await storage.commitSession(session),
103 | },
104 | });
105 | } catch (error) {
106 | throw error;
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "remix-app-template",
4 | "description": "",
5 | "license": "",
6 | "prisma": {
7 | "seed": "node --require esbuild-register prisma/seed.ts"
8 | },
9 | "scripts": {
10 | "build": "remix build",
11 | "dev": "remix dev",
12 | "postinstall": "remix setup node",
13 | "start": "remix-serve build"
14 | },
15 | "dependencies": {
16 | "@prisma/client": "^3.9.1",
17 | "@remix-run/react": "^1.1.3",
18 | "@remix-run/serve": "^1.1.3",
19 | "@shopify/polaris": "^8.2.1",
20 | "dotenv": "^16.0.0",
21 | "react": "^17.0.2",
22 | "react-dom": "^17.0.2",
23 | "remix": "^1.1.3"
24 | },
25 | "devDependencies": {
26 | "@remix-run/dev": "^1.1.3",
27 | "@types/react": "^17.0.24",
28 | "@types/react-dom": "^17.0.9",
29 | "esbuild-register": "^3.3.2",
30 | "prisma": "^3.9.1",
31 | "typescript": "^4.1.2"
32 | },
33 | "engines": {
34 | "node": ">=14"
35 | },
36 | "sideEffects": false
37 | }
38 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "sqlite"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model Shop {
14 | id String @id @default(cuid())
15 | name String @unique
16 | active Boolean @default(true)
17 | }
18 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | const db = new PrismaClient();
3 |
4 | async function seed() {
5 | await db.shop.create({
6 | data: {
7 | name: "my-cool-development-store",
8 | },
9 | });
10 | }
11 |
12 | seed();
13 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WillsonSmith/shopify-remix-app/f7d7fdcc878bbdf3b377ba4b5bd6d3849ce6d93a/public/favicon.ico
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev/config').AppConfig}
3 | */
4 | module.exports = {
5 | appDirectory: "app",
6 | assetsBuildDirectory: "public/build",
7 | publicPath: "/build/",
8 | serverBuildDirectory: "build",
9 | devServerPort: 8002,
10 | ignoredRouteFiles: [".*"]
11 | };
12 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/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 | "baseUrl": ".",
13 | "paths": {
14 | "~/*": ["./app/*"]
15 | },
16 |
17 | // Remix takes care of building everything in `remix build`.
18 | "noEmit": true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------