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-remix
cd shopify-remix-cloudflare-workers
npm install

2. Install Required Packages

# Install Remix Cloudflare and Prisma Accelerate extensions
npm i @remix-run/cloudflare @prisma/extension-accelerate
# Install Cloudflare Workers types
npm 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=true
auto-install-peers=true
shamefully-hoist=true
enable-pre-post-scripts=true
legacy-peer-deps=true

6. Create Context Loader

Create a load-context.ts file to handle Cloudflare Workers context:

typescript
Copy
import { type PlatformProxy } from "wrangler";
// Context and environment type definitions
type Env = {
SHOPIFY_API_KEY?: string;
SHOPIFY_API_SECRET?: string;
SHOPIFY_APP_URL?: string;
DATABASE_URL?: string;
SCOPES?: string;
}
// Context loading function
export 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 information
allow: ["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 built
import * as build from "./build/server"; // eslint-disable-line import/no-unresolved
import { getLoadContext } from "./load-context";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const 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/#getplatformproxy
cf: 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 @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId 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-vars
loadContext: AppLoadContext,
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);
const body = await renderToReadableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
signal: controller.signal,
onError(error: unknown) {
if (!controller.signal.aborted) {
// Log streaming rendering errors from inside the shell
console.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: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build Remix application
run: npm run build
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
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.tsxfile, 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(
`#graphql
mutation populateProduct($product: ProductCreateInput!) {
productCreate(product: $product) {
product {
id
title
handle
status
variants(first: 10) {
edges {
node {
id
price
barcode
createdAt
}
}
}
}
}
}`,
{
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(
`#graphql
mutation shopifyRemixTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
price
barcode
createdAt
}
}
}`,
{
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{" "}
<Link
url="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{" "}
<Link
url="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
removeUnderline
>
Admin GraphQL
</Link>{" "}
mutation demo, to provide a starting point for app
development.
</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 output
for that product. Learn more about the{" "}
<Link
url="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 && (
<Button
url={`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>
<Box
padding="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>
<Box
padding="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>
<Link
url="https://remix.run"
target="_blank"
removeUnderline
>
Remix
</Link>
</InlineStack>
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
Database
</Text>
<Link
url="https://www.prisma.io/"
target="_blank"
removeUnderline
>
Prisma
</Link>
</InlineStack>
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
Interface
</Text>
<span>
<Link
url="https://polaris.shopify.com"
target="_blank"
removeUnderline
>
Polaris
</Link>
{", "}
<Link
url="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>
<Link
url="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{" "}
<Link
url="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{" "}
<Link
url="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

Tell me about your project