├── .eslintrc.cjs
├── .example.vars
├── .gitignore
├── .node-version
├── README.md
├── app
├── components
│ └── LiveReload.tsx
├── db.server.ts
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│ ├── _index.tsx
│ ├── auth.$.tsx
│ └── webhooks
│ │ ├── carts
│ │ └── create.tsx
│ │ ├── config.tsx
│ │ └── route.tsx
└── shopify.server.js
├── drizzle.config.ts
├── example.wrangler.toml
├── migrations
├── 0000_bumpy_stick.sql
└── meta
│ ├── 0000_snapshot.json
│ └── _journal.json
├── package.json
├── pnpm-lock.yaml
├── public
├── _headers
├── _routes.json
└── favicon.ico
├── remix.config.js
├── remix.env.d.ts
├── schema.ts
├── server.ts
├── shopify.app.toml
├── shopify.web.toml
├── tsconfig.json
└── worker-configuration.d.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4 | };
5 |
--------------------------------------------------------------------------------
/.example.vars:
--------------------------------------------------------------------------------
1 | SHOPIFY_APP_KEY="CLIENT KEY"
2 | SHOPIFY_APP_SECRET="SECRET KEY"
3 | APP_URL="YOUR APPS URL"
4 | SHOPIFY_APP_SCOPES="read_products,write_products,read_script_tags,write_script_tags"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /functions/\[\[path\]\].js
5 | /functions/\[\[path\]\].js.map
6 | /functions/metafile.*
7 | /functions/version.txt
8 | /public/build
9 | .dev.vars
10 |
11 | wrangler.toml
12 | priv.shopify.app.toml
13 | shopify.app.ignore.toml
14 | .wrangler
15 | .vscode/database-client-config.json
16 | worker-configuration.d.ts
17 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 18.0.0
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A template for developing Shopify Apps using RemixJS and Cloudflare Workers
2 |
3 | ## Why?
4 | I'm a glutton for performance and I love the idea of using Cloudflare Workers to serve my Shopify App from their global CDN. I also really enjoy working with RemixJS and especially with Shopify apps.
5 |
6 | This template is a starting point for building a Shopify App using RemixJS and Cloudflare Workers. It's not a complete app, but it does provide a good starting point for building a Shopify App and I will be continuing to implement Cloudflare technologies into it. (Like KV, D1, etc.)
7 |
8 | ## Getting Started
9 |
10 | ### 1. Clone this repo
11 | ```bash
12 | git clone git@github.com:refactor-this/Shopify-RemixJS-Cloudflare-Workers-Template.git
13 | ```
14 | ### 2. Install dependencies
15 | I'm using pnpm to manage dependencies. You can install it with npm or yarn if you prefer.
16 | ```bash
17 | pnpm install
18 | ```
19 |
20 | ### 3. Create your environment variables
21 | Copy the `example.wrangler.toml` to your own `wrangler.toml` file and fill in the environment variables. (You should have a Shopify app created already on their partner dashboard so you can get the client id and secret.)
22 |
23 | For the app url, I set up a free tunnel service using Cloudflare. You can follow how I set that up here: https://innovonics.com/creating-a-free-tunnel-service-for-developing-shopify-apps/
24 |
25 | As an alternative, you can use http://localhost:8002
26 |
27 | You will also need to copy the `.example.vars` to `.dev.vars` and fill out the required values.
28 |
29 | The reason we use the local `.{environment}.vars` file is because we want to keep the sensitive information out of the `wrangler.toml` file and out of git history.
30 |
31 | When adding the productions values, you would add them using the Cloudflare CLI with the encrypted flag set to `true`. This would keep the values secret but have no effect on the worker.
32 |
33 | ### 4. Start the dev server
34 | ```bash
35 | pnpm dev
36 | ```
37 | ### 5. Happy coding!
38 |
39 | ## Creating the D1 Database
40 |
41 | Creating the D1 database is fairly straightforward, run the next command to create one.
42 |
43 | ```bash
44 | wrangler d1 create d1-example
45 | ```
46 |
47 | You will receive an output in the terminal that looks like this:
48 | ```bash
49 | [[d1_databases]]
50 | binding = "DB" # i.e. available in your Worker on env.DB
51 | database_name = "your-database-name"
52 | database_id = "your-generated-database-id"
53 | ```
54 |
55 | copy and paste it to your wrangler.toml file.
56 |
57 |
58 | Now that we have our DB created, let's generate and apply migrations:
59 |
60 | Generate migrations
61 | ```bash
62 | pnpm db:generate
63 | ```
64 | Apply migrations
65 | ```bash
66 | pnpm dev:db:apply
67 | ```
68 |
69 | You can also list pending migrations with
70 | ```bash
71 | pnpm dev:db:list
72 | ```
73 |
74 | ### Viewing data in your current database
75 |
76 | You can view the data in your current database by running the following command:
77 | ```bash
78 | pnpm db:studio:preview
79 | ```
80 | This will open a Drizzle preview connection which you can view on your browser.
81 |
82 | Or if you are like me an use a 3rd party tool you can access the D1 SQLite database directly. It is located at the top of your project folder.
83 | ```bash
84 | .wrangler/state/v3/d1/miniflare-D1DatabaseObject/[some-random-string].sqlite
85 | ```
86 |
87 | ## Webhooks
88 | The webhooks file is set up with the standard `app/uninstall` solution (delete the session in your database).
89 |
90 | ~~It continues with the stadard switch/case solution. This is not necessarily how I would handle it when the application grows and will be subject to change.~~
91 |
92 | ~~It would make more sense to me to use the routing to define webhook handling and set up a config file when registering webhooks.~~
93 |
94 | I ended up just implementing both for you to choose which you like.
95 |
96 | #### Route-based webhooks
97 |
98 | In `~/routes/webhooks/carts/create.tsx` You can see how I would handle the route-based approach. The webhook logic is defined by the route and used as the endpoint shown in `~/routes/webhooks/config.tsx` which is used as a global webhook endpoint definition file.
99 |
100 | #### Standard Switch/Case webhooks
101 |
102 | You can view this method in `~/routes/webhooks/route.tsx`. This is the standard switch/case solution. It uses the `topic` to determine the logic to handle the webhook. It works for small solutions but personally the route-based approach is a long term optimiation that makes more sense.
103 |
104 |
--------------------------------------------------------------------------------
/app/components/LiveReload.tsx:
--------------------------------------------------------------------------------
1 | export const LiveReload =
2 | process.env.NODE_ENV !== "development"
3 | ? () => null
4 | : function LiveReload({
5 | port = Number(process.env.REMIX_DEV_SERVER_WS_PORT || 8002),
6 | }: {
7 | port?: number;
8 | }) {
9 | let setupLiveReload = ((port: number) => {
10 | let protocol = location.protocol === "https:" ? "wss:" : "ws:";
11 | let host = location.hostname;
12 | let socketPath = `${protocol}//${host}:${port}/socket`;
13 |
14 | let ws = new WebSocket(socketPath);
15 | ws.onmessage = (message) => {
16 | let event = JSON.parse(message.data);
17 | if (event.type === "LOG") {
18 | console.log(event.message);
19 | }
20 | if (event.type === "RELOAD") {
21 | console.log("💿 Reloading window ...");
22 | window.location.reload();
23 | }
24 | };
25 | ws.onerror = (error) => {
26 | console.log("Remix dev asset server web socket error:");
27 | console.error(error);
28 | };
29 | }).toString();
30 |
31 | return (
32 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/app/db.server.ts:
--------------------------------------------------------------------------------
1 | import type { AppLoadContext } from "@remix-run/cloudflare";
2 | import { drizzle } from "drizzle-orm/d1";
3 |
4 | type D1Database = import("@cloudflare/workers-types/experimental").D1Database;
5 |
6 | interface MyLoadContext extends AppLoadContext {
7 | env: {
8 | DB: D1Database;
9 | };
10 | }
11 |
12 |
13 | export function initDB(context: MyLoadContext) {
14 |
15 | return drizzle(context.env.DB);
16 | }
17 |
18 | export * as schema from "../schema";
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from "@remix-run/react";
8 | import { startTransition, StrictMode } from "react";
9 | import { hydrateRoot } from "react-dom/client";
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 |
17 | );
18 | });
19 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare";
2 | import { RemixServer } from "@remix-run/react";
3 | import {isbot} from "isbot";
4 | import { renderToReadableStream } from "react-dom/server";
5 | import { shopify } from "./shopify.server";
6 |
7 | export default async function handleRequest(
8 | request: Request,
9 | responseStatusCode: number,
10 | responseHeaders: Headers,
11 | remixContext: EntryContext,
12 | context: AppLoadContext,
13 | ) {
14 | const shopifyInstance = await shopify(context);
15 | shopifyInstance.addDocumentResponseHeaders(request, responseHeaders);
16 |
17 | const body = await renderToReadableStream(
18 | ,
19 | {
20 | signal: request.signal,
21 | onError(error: unknown) {
22 | // Log streaming rendering errors from inside the shell
23 | console.error(error);
24 | responseStatusCode = 500;
25 | },
26 | },
27 | );
28 | if (isbot(request.headers.get("user-agent"))) {
29 | await body.allReady;
30 | }
31 |
32 | responseHeaders.set("Content-Type", "text/html");
33 | return new Response(body, {
34 | headers: responseHeaders,
35 | status: responseStatusCode,
36 | });
37 | }
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { HeadersArgs, json, type LinksFunction } from "@remix-run/cloudflare";
2 | import { cssBundleHref } from "@remix-run/css-bundle";
3 | import {
4 | Links,
5 | Meta,
6 | Outlet,
7 | Scripts,
8 | ScrollRestoration,
9 | useRouteError,
10 | } from "@remix-run/react";
11 | import { LoaderFunctionArgs } from '@remix-run/cloudflare';
12 | import { boundary } from "@shopify/shopify-app-remix";
13 |
14 | import { useLoaderData } from "@remix-run/react";
15 | import { AppProvider } from '@shopify/shopify-app-remix/react';
16 | import {shopify} from '~/shopify.server';
17 | import { LiveReload } from '../app/components/LiveReload';
18 | import polarisStyles from "@shopify/polaris/build/esm/styles.css";
19 |
20 | interface LoaderContext {
21 | env: {
22 | SHOPIFY_APP_KEY: string;
23 | };
24 | }
25 |
26 | export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
27 |
28 |
29 | export async function loader({ request, context }: LoaderFunctionArgs & { context: LoaderContext }) {
30 | await shopify(context).authenticate.admin(request);
31 |
32 |
33 | return json({
34 | apiKey: context.env.SHOPIFY_APP_KEY,
35 | });
36 | }
37 |
38 | export default function App() {
39 | const { apiKey } = useLoaderData();
40 |
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | export function ErrorBoundary() {
62 | return boundary.error(useRouteError());
63 | }
64 |
65 | export const headers = (headersArgs: HeadersArgs) => {
66 | return boundary.headers(headersArgs);
67 | };
--------------------------------------------------------------------------------
/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import { Page, Card, EmptyState, Layout } from '@shopify/polaris';
2 | import { schema, shopify } from '~/shopify.server';
3 | import {initDB} from '~/db.server'
4 | import {json} from '@remix-run/cloudflare'
5 |
6 | export async function loader({ context, request }) {
7 | console.log('hit index loader')
8 |
9 | const test_data = await initDB(context)
10 | .select()
11 | .from(schema.sessionTable)
12 | .all();
13 | console.log('test_data', test_data)
14 |
15 | return json({message: 'hit index loader', status: 200},{status: 200})
16 | }
17 |
18 |
19 | export default function Index() {
20 | return (
21 |
22 |
23 |
24 |
28 |
29 | Congratulations on following the Readme and getting this far! Now it's time to build something awesome.
30 |