Deploy the Shopify remix official template to Cloudflare Workers
by Ilias Haddad
Overview
In this guide, we'll walk through deploying the Shopify Remix template to Cloudflare Workers, using Prisma and PostgreSQL for session and database storage.
Important Note: Deploying the Remix app to Cloudflare Workers will involve changes to the core project and may limit some functionality. You'll need to modify the project to work with Cloudflare Workers' specific environment, such as using Context to pass environment variables on each request.
Let’s get started!
You can checkout the final Github repo instead of reading the full article: (https://github.com/IliasHad/shopify-app-template-remix-cloudflare-workers)
1. Clone the Shopify Remix Package
git clone https://github.com/Shopify/shopify-app-template-remixcd shopify-remix-cloudflare-workersnpm install
2. Install Required Packages
# Install Remix Cloudflare and Prisma Accelerate extensionsnpm i @remix-run/cloudflare @prisma/extension-accelerate# Install Cloudflare Workers typesnpm i @cloudflare/workers-types --save-dev
3. Update TypeScript Configuration
Update tsconfig.json
to include Cloudflare Workers types:
"types": ["node","@remix-run/cloudflare","@cloudflare/workers-types/2023-07-01"]
4. Install Wrangler
npm i wrangler@next --save-dev --legacy-peer-deps
We’ll be using wrangler@next
package with next version which isn’t compatible with @remix/run package. Because of this issue with wrangler and Shopify app remix package import with json
5. Configure NPM Settings
Update .npmrc
file:
engine-strict=trueauto-install-peers=trueshamefully-hoist=trueenable-pre-post-scripts=truelegacy-peer-deps=true
6. Create Context Loader
Create a load-context.ts
file to handle Cloudflare Workers context:
typescriptCopyimport { type PlatformProxy } from "wrangler";// Context and environment type definitionstype Env = {SHOPIFY_API_KEY?: string;SHOPIFY_API_SECRET?: string;SHOPIFY_APP_URL?: string;DATABASE_URL?: string;SCOPES?: string;}// Context loading functionexport function getLoadContext({ context }: GetLoadContextArgs) {return context;}
7. Update Vite Configuration
Update vite.config.ts
file with cloudflare workers dev proxy plugin and loading the cloudflare context
import {vitePlugin as remix,cloudflareDevProxyVitePlugin,} from "@remix-run/dev";import { defineConfig } from "vite";import tsconfigPaths from "vite-tsconfig-paths";import { installGlobals } from "@remix-run/node";import { getLoadContext } from "./load-context";installGlobals({ nativeFetch: true });// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the remix server. The CLI will eventually// stop passing in HOST, so we can remove this workaround after the next major release.if (process.env.HOST &&(!process.env.SHOPIFY_APP_URL ||process.env.SHOPIFY_APP_URL === process.env.HOST)) {process.env.SHOPIFY_APP_URL = process.env.HOST;delete process.env.HOST;}const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost").hostname;let hmrConfig;if (host === "localhost") {hmrConfig = {protocol: "ws",host: "localhost",port: 64999,clientPort: 64999,};} else {hmrConfig = {protocol: "wss",host: host,port: parseInt(process.env.FRONTEND_PORT!) || 8002,clientPort: 443,};}declare module "@remix-run/cloudflare" {interface Future {v3_singleFetch: true;}}export default defineConfig({server: {port: Number(process.env.PORT || 3000),hmr: hmrConfig,fs: {// See https://vitejs.dev/config/server-options.html#server-fs-allow for more informationallow: ["app", "node_modules"],},},plugins: [cloudflareDevProxyVitePlugin({getLoadContext,}),remix({future: {v3_fetcherPersist: true,v3_relativeSplatPath: true,v3_throwAbortReason: true,v3_singleFetch: true,v3_lazyRouteDiscovery: true,},}),tsconfigPaths(),],ssr: {resolve: {conditions: ["workerd", "worker", "browser"],},},resolve: {mainFields: ["browser", "module", "main"],},build: {minify: true,},json: {stringify: true}});
8. Create Server FIle
Add server.ts
file, this will be the main entry file for your Couldflare Workers
import { createRequestHandler, type ServerBuild } from "@remix-run/cloudflare";// eslint-disable-next-line @typescript-eslint/ban-ts-comment// @ts-ignore This file won’t exist if it hasn’t yet been builtimport * as build from "./build/server"; // eslint-disable-line import/no-unresolvedimport { getLoadContext } from "./load-context";// eslint-disable-next-line @typescript-eslint/no-explicit-anyconst handleRemixRequest = createRequestHandler(build as any as ServerBuild);export default {async fetch(request, env, ctx) {try {const loadContext = getLoadContext({request,context: {cloudflare: {// This object matches the return value from Wrangler's// `getPlatformProxy` used during development via Remix's// `cloudflareDevProxyVitePlugin`:// https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxycf: request.cf,ctx: {waitUntil: ctx.waitUntil.bind(ctx),passThroughOnException: ctx.passThroughOnException.bind(ctx),},caches,env,},},});return await handleRemixRequest(request, loadContext);} catch (error) {console.error("Request handling error:", error);return new Response("An unexpected error occurred", { status: 500 });}},} satisfies ExportedHandler<Env>;
9. Update Database Configuration
Update prisma/schema.prisma
file to use postgresql
and will be DATABASE_URL Prisma accelerate) and DIRECT_URL (direct connection to your Postgresql database)
generator client {provider = "prisma-client-js"}datasource db {provider = "postgresql"url = env("DATABASE_URL")directUrl = env("DIRECT_URL")}model Session {id String @idshop Stringstate StringisOnline Boolean @default(false)scope String?expires DateTime?accessToken StringuserId BigInt?firstName String?lastName String?email String?accountOwner Boolean @default(false)locale String?collaborator Boolean? @default(false)emailVerified Boolean? @default(false)}
10. Update Database Client
Update app/db.server.ts
to use Prisma client with Prisma accelerate
import { PrismaClient } from "@prisma/client/edge";import { withAccelerate } from "@prisma/extension-accelerate";const prisma = (DATABASE_URL: string) =>new PrismaClient({datasourceUrl: DATABASE_URL,}).$extends(withAccelerate());export default prisma;
11. Update Entry Server File
Update app/entry.server.tsx
with cloudflare workers support
/*** By default, Remix will handle generating the HTTP Response for you.* 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` ✨* For more information, see https://remix.run/file-conventions/entry.server*/import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare";import { RemixServer } from "@remix-run/react";import { isbot } from "isbot";import { renderToReadableStream } from "react-dom/server";const ABORT_DELAY = 5000;export default async function handleRequest(request: Request,responseStatusCode: number,responseHeaders: Headers,remixContext: EntryContext,// This is ignored so we can keep it in the template for visibility. Feel// free to delete this parameter in your app if you're not using it!// eslint-disable-next-line @typescript-eslint/no-unused-varsloadContext: AppLoadContext,) {const controller = new AbortController();const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);const body = await renderToReadableStream(<RemixServercontext={remixContext}url={request.url}abortDelay={ABORT_DELAY}/>,{signal: controller.signal,onError(error: unknown) {if (!controller.signal.aborted) {// Log streaming rendering errors from inside the shellconsole.error(error);}responseStatusCode = 500;},},);body.allReady.then(() => clearTimeout(timeoutId));if (isbot(request.headers.get("user-agent") || "")) {await body.allReady;}responseHeaders.set("Content-Type", "text/html");return new Response(body, {headers: responseHeaders,status: responseStatusCode,});}
12. Update Shopify Instance
Update app/shopify.server.ts
file to initialise Shopify instance from the request Cloudflare workers context to get the env variables
import "@shopify/shopify-app-remix/adapters/node";import {ApiVersion,AppDistribution,shopifyApp,} from "@shopify/shopify-app-remix/server";import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";import prisma from "./db.server";import { AppLoadContext } from "@remix-run/node";export const shopify = (context: AppLoadContext) =>shopifyApp({apiKey: context.cloudflare.env.SHOPIFY_API_KEY,apiSecretKey: context.cloudflare.env.SHOPIFY_API_SECRET,apiVersion: ApiVersion.October24,scopes: context.cloudflare.env?.SCOPES?.split(",") || ["write_products"],appUrl: context.cloudflare.env?.SHOPIFY_APP_URL,authPathPrefix: "/auth",sessionStorage: new PrismaSessionStorage(prisma(context.cloudflare.env.DATABASE_URL),{connectionRetries: 10,connectionRetryIntervalMs: 5000,},),distribution: AppDistribution.AppStore,future: {unstable_newEmbeddedAuthStrategy: true,removeRest: true,},...(process.env.SHOP_CUSTOM_DOMAIN? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }: {}),logger: {log: console.log,},});export default shopify;export const apiVersion = ApiVersion.October24;
Now, we update Prisma and Shopify instance from variable to a function to pull the environment variables from the request context.
13. Update Pages with new Prisma and Shopify config
Update app/routes/app.tsx
file
import { shopify } from "../shopify.server";export const links = () => [{ rel: "stylesheet", href: polarisStyles }];export const loader = async ({ request, context }: LoaderFunctionArgs) => {await shopify(context).authenticate.admin(request);return { apiKey: process.env.SHOPIFY_API_KEY || "" };};
Update app/routes/auth.$.tsx
file
import type { LoaderFunctionArgs } from "@remix-run/node";import { shopify } from "../shopify.server";export const loader = async ({ request, context }: LoaderFunctionArgs) => {await shopify(context).authenticate.admin(request);return null;};
Update app/routes/auth.login/route.tsx
file
import { shopify } from "../../shopify.server";import { loginErrorMessage } from "./error.server";export const links = () => [{ rel: "stylesheet", href: polarisStyles }];export const loader = async ({ request, context }: LoaderFunctionArgs) => {const errors = loginErrorMessage(await shopify(context).login(request));return { errors, polarisTranslations };};export const action = async ({ request, context }: ActionFunctionArgs) => {const errors = loginErrorMessage(await shopify(context).login(request));return {errors,};};
Update app/routes/_index/route.tsx
file
import { shopify } from "../../shopify.server";import styles from "./styles.module.css";import prisma from "../../db.server";export const loader = async ({ request, context }: LoaderFunctionArgs) => {const url = new URL(request.url);const sessions = await prisma(context.cloudflare.env.DATABASE_URL).session.findMany();if (url.searchParams.get("shop")) {throw redirect(`/app?${url.searchParams.toString()}`);}return { showForm: Boolean(shopify(context).login), sessions };};
Update app/routes/webhooks.app.scopes_update.tsx
file
import { shopify } from "../shopify.server";import db from "../db.server";export const action = async ({ request, context }: ActionFunctionArgs) => {const { payload, session, topic, shop } = await shopify(context).authenticate.webhook(request);console.log(`Received ${topic} webhook for ${shop}`);const current = payload.current as string[];if (session) {await db(context.cloudflare.env.DATABASE_URL).session.update({where: {id: session.id},data: {scope: current.toString(),},});}return new Response();};
Update app/routes/webhooks.app.uninstalled.tsx
file
import { shopify } from "../shopify.server";import db from "../db.server";export const action = async ({ request, context }: ActionFunctionArgs) => {const { shop, session, topic } = await shopify(context).authenticate.webhook(request);console.log(`Received ${topic} webhook for ${shop}`);// Webhook requests can trigger multiple times and after an app has already been uninstalled.// If this webhook already ran, the session may have been deleted previously.if (session) {await db(context.cloudflare.env.DATABASE_URL).session.deleteMany({ where: { shop } });}return new Response();};
14. Setup deployment pipeline
Create wrangler.json
file to have wrangler configuration
{"name": "cloudflare-workers-app","compatibility_date": "2024-11-11","main": "./server.ts","assets": {"directory": "./build/client"},"observability": {"enabled": true},"upload_source_maps": false,"compatibility_flags": ["nodejs_compat_v2"],"vars": {"SHOPIFY_APP_URL": "https://cloudflare-workers-app.ilias-had.workers.dev","SCOPES": "writes_products"}}
15. Setup Local deployment
Add script to package.json file to publish the Cloudflare workers from the local build
"publish": "npm run build && wrangler deploy --keep-vars"
16. Setup Github Action workflow
Add Github action .github/deploy.yml
file
name: Deployon:push:branches:- mainjobs:deploy:runs-on: ubuntu-latestname: Deploysteps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: 20- name: Install dependenciesrun: npm ci- name: Build Remix applicationrun: npm run build- name: Deployuses: cloudflare/wrangler-action@v3with:apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
You need to add CLOUDFLARE_API_TOKEN
to your Github repo and you can get it from your Cloudflare workers
17. Setup environment variable
Add those variables in the Cloudflare workers as a secret in your cloudflare dashboard
SHOPIFY_API_KEY=""SHOPIFY_API_SECRET=""DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=__API_KEY__"DIRECT_URL="postgresql://user:password@host:port/db_name?schema=public"
18. Update main app page
Update app/routes/app._index.tsx
file, there’s an issue with remix useFetcher and Cloudflare worker
import { useEffect } from "react";import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";import { Form, useNavigation, useActionData } from "@remix-run/react";import {Page,Layout,Text,Card,Button,BlockStack,Box,List,Link,InlineStack,} from "@shopify/polaris";import { useAppBridge } from "@shopify/app-bridge-react";import { shopify } from "../shopify.server";export const loader = async ({ request, context }: LoaderFunctionArgs) => {await shopify(context).authenticate.admin(request);return null;};export const action = async ({ request, context }: ActionFunctionArgs) => {const { admin } = await shopify(context).authenticate.admin(request);const color = ["Red", "Orange", "Yellow", "Green"][Math.floor(Math.random() * 4)];const response = await admin.graphql(`#graphqlmutation populateProduct($product: ProductCreateInput!) {productCreate(product: $product) {product {idtitlehandlestatusvariants(first: 10) {edges {node {idpricebarcodecreatedAt}}}}}}`,{variables: {product: {title: `${color} Snowboard`,},},},);const responseJson = await response.json();const product = responseJson.data!.productCreate!.product!;const variantId = product.variants.edges[0]!.node!.id!;const variantResponse = await admin.graphql(`#graphqlmutation shopifyRemixTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {productVariantsBulkUpdate(productId: $productId, variants: $variants) {productVariants {idpricebarcodecreatedAt}}}`,{variables: {productId: product.id,variants: [{ id: variantId, price: "100.00" }],},},);const variantResponseJson = await variantResponse.json();return {product: responseJson!.data!.productCreate!.product,variant:variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants,};};export default function Index() {const navigation = useNavigation();const shopify = useAppBridge();const actionData = useActionData<typeof action>();const isLoading =navigation.state === "submitting" || navigation.state === "loading";useEffect(() => {if (actionData?.product) {shopify.toast.show("Product created");}}, [actionData, shopify]);return (<Page><Form method="POST" reloadDocument><BlockStack gap="500"><Layout><Layout.Section><Card><BlockStack gap="500"><BlockStack gap="200"><Text as="h2" variant="headingMd">Congrats on creating a new Shopify app 🎉</Text><Text variant="bodyMd" as="p">This embedded app template uses{" "}<Linkurl="https://shopify.dev/docs/apps/tools/app-bridge"target="_blank"removeUnderline>App Bridge</Link>{" "}interface examples like an{" "}<Link url="/app/additional" removeUnderline>additional page in the app nav</Link>, as well as an{" "}<Linkurl="https://shopify.dev/docs/api/admin-graphql"target="_blank"removeUnderline>Admin GraphQL</Link>{" "}mutation demo, to provide a starting point for appdevelopment.</Text></BlockStack><BlockStack gap="200"><Text as="h3" variant="headingMd">Get started with products</Text><Text as="p" variant="bodyMd">Generate a product with GraphQL and get the JSON outputfor that product. Learn more about the{" "}<Linkurl="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"target="_blank"removeUnderline>productCreate</Link>{" "}mutation in our API references.</Text></BlockStack><InlineStack gap="300"><Button loading={isLoading} submit>Generate a product</Button>{actionData?.product && (<Buttonurl={`shopify:admin/products/${actionData.product.id.replace("gid://shopify/Product/","",)}`}target="_blank"variant="plain">View product</Button>)}</InlineStack>{actionData?.product && (<><Text as="h3" variant="headingMd">{" "}productCreate mutation</Text><Boxpadding="400"background="bg-surface-active"borderWidth="025"borderRadius="200"borderColor="border"overflowX="scroll"><pre style={{ margin: 0 }}><code>{JSON.stringify(actionData.product, null, 2)}</code></pre></Box><Text as="h3" variant="headingMd">{" "}productVariantsBulkUpdate mutation</Text><Boxpadding="400"background="bg-surface-active"borderWidth="025"borderRadius="200"borderColor="border"overflowX="scroll"><pre style={{ margin: 0 }}><code>{JSON.stringify(actionData.variant, null, 2)}</code></pre></Box></>)}</BlockStack></Card></Layout.Section><Layout.Section variant="oneThird"><BlockStack gap="500"><Card><BlockStack gap="200"><Text as="h2" variant="headingMd">App template specs</Text><BlockStack gap="200"><InlineStack align="space-between"><Text as="span" variant="bodyMd">Framework</Text><Linkurl="https://remix.run"target="_blank"removeUnderline>Remix</Link></InlineStack><InlineStack align="space-between"><Text as="span" variant="bodyMd">Database</Text><Linkurl="https://www.prisma.io/"target="_blank"removeUnderline>Prisma</Link></InlineStack><InlineStack align="space-between"><Text as="span" variant="bodyMd">Interface</Text><span><Linkurl="https://polaris.shopify.com"target="_blank"removeUnderline>Polaris</Link>{", "}<Linkurl="https://shopify.dev/docs/apps/tools/app-bridge"target="_blank"removeUnderline>App Bridge</Link></span></InlineStack><InlineStack align="space-between"><Text as="span" variant="bodyMd">API</Text><Linkurl="https://shopify.dev/docs/api/admin-graphql"target="_blank"removeUnderline>GraphQL API</Link></InlineStack></BlockStack></BlockStack></Card><Card><BlockStack gap="200"><Text as="h2" variant="headingMd">Next steps</Text><List><List.Item>Build an{" "}<Linkurl="https://shopify.dev/docs/apps/getting-started/build-app-example"target="_blank"removeUnderline>{" "}example app</Link>{" "}to get started</List.Item><List.Item>Explore Shopify’s API with{" "}<Linkurl="https://shopify.dev/docs/apps/tools/graphiql-admin-api"target="_blank"removeUnderline>GraphiQL</Link></List.Item></List></BlockStack></Card></BlockStack></Layout.Section></Layout></BlockStack></Form></Page>);}
Conclusion
Deploying a Shopify Remix app to Cloudflare Workers requires careful configuration and understanding of the platform-specific requirements. Follow the steps meticulously and test each stage of the deployment process.
Resources:
https://github.com/vincaslt/remix-cf-pages-d1-drizzle-shopify-app
https://hono.dev/examples/prisma
https://github.com/remix-run/remix/issues/5911
https://github.com/cloudy9101/shopify-remix-cfpages
https://codefrontend.com/deploy-shopify-apps-on-cloudflare/#step-5-deploying-your-shopify-app
https://github.com/dan-gamble/cloudflare-workers-remix-shopify
- Shopify App Dev
- Remix