├── .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 |
11 |
Callback
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/routes/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "remix"; 2 | 3 | export default function () { 4 | return ( 5 |
6 |
Auth
7 | 8 |
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 | --------------------------------------------------------------------------------