diff --git a/.graphqlrc.ts b/.graphqlrc.ts
new file mode 100644
index 00000000..23c8d1bc
--- /dev/null
+++ b/.graphqlrc.ts
@@ -0,0 +1,40 @@
+import fs from "fs";
+import { LATEST_API_VERSION } from "@shopify/shopify-api";
+import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
+import type { IGraphQLConfig } from "graphql-config";
+
+function getConfig() {
+ const config: IGraphQLConfig = {
+ projects: {
+ default: shopifyApiProject({
+ apiType: ApiType.Admin,
+ apiVersion: LATEST_API_VERSION,
+ documents: ["./app/**/*.{js,ts,jsx,tsx}"],
+ outputDir: "./app/types",
+ }),
+ },
+ };
+
+ let extensions: string[] = [];
+ try {
+ extensions = fs.readdirSync("./extensions");
+ } catch {
+ // ignore if no extensions
+ }
+
+ for (const entry of extensions) {
+ const extensionPath = `./extensions/${entry}`;
+ const schema = `${extensionPath}/schema.graphql`;
+ if (!fs.existsSync(schema)) {
+ continue;
+ }
+ config.projects[entry] = {
+ schema,
+ documents: [`${extensionPath}/**/*.graphql`],
+ };
+ }
+
+ return config;
+}
+
+module.exports = getConfig();
diff --git a/app/db.server.ts b/app/db.server.ts
new file mode 100644
index 00000000..e92f23e6
--- /dev/null
+++ b/app/db.server.ts
@@ -0,0 +1,15 @@
+import { PrismaClient } from "@prisma/client";
+
+declare global {
+ var prisma: PrismaClient;
+}
+
+const prisma: PrismaClient = global.prisma || new PrismaClient();
+
+if (process.env.NODE_ENV !== "production") {
+ if (!global.prisma) {
+ global.prisma = new PrismaClient();
+ }
+}
+
+export default prisma;
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
new file mode 100644
index 00000000..07860846
--- /dev/null
+++ b/app/entry.server.tsx
@@ -0,0 +1,58 @@
+import { PassThrough } from "stream";
+import { renderToPipeableStream } from "react-dom/server";
+import { RemixServer } from "@remix-run/react";
+import {
+ createReadableStreamFromReadable,
+ type EntryContext,
+} from "@remix-run/node";
+import { isbot } from "isbot";
+import { addDocumentResponseHeaders } from "./shopify.server";
+
+const ABORT_DELAY = 5000;
+
+export default async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ addDocumentResponseHeaders(request, responseHeaders);
+ const userAgent = request.headers.get("user-agent");
+ const callbackName = isbot(userAgent ?? '')
+ ? "onAllReady"
+ : "onShellReady";
+
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ [callbackName]: () => {
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set("Content-Type", "text/html");
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+ pipe(body);
+ },
+ onShellError(error) {
+ reject(error);
+ },
+ onError(error) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/app/globals.d.ts b/app/globals.d.ts
new file mode 100644
index 00000000..cbe652db
--- /dev/null
+++ b/app/globals.d.ts
@@ -0,0 +1 @@
+declare module "*.css";
diff --git a/app/root.tsx b/app/root.tsx
new file mode 100644
index 00000000..805f121f
--- /dev/null
+++ b/app/root.tsx
@@ -0,0 +1,30 @@
+import {
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from "@remix-run/react";
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx
new file mode 100644
index 00000000..cf668d31
--- /dev/null
+++ b/app/routes/_index/route.tsx
@@ -0,0 +1,58 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { json, redirect } from "@remix-run/node";
+import { Form, useLoaderData } from "@remix-run/react";
+
+import { login } from "../../shopify.server";
+
+import styles from "./styles.module.css";
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const url = new URL(request.url);
+
+ if (url.searchParams.get("shop")) {
+ throw redirect(`/app?${url.searchParams.toString()}`);
+ }
+
+ return json({ showForm: Boolean(login) });
+};
+
+export default function App() {
+ const { showForm } = useLoaderData();
+
+ return (
+
+
+
A short heading about [your app]
+
+ A tagline about [your app] that describes your value proposition.
+
+ {showForm && (
+
+ )}
+
+ -
+ Product feature. Some detail about your feature and
+ its benefit to your customer.
+
+ -
+ Product feature. Some detail about your feature and
+ its benefit to your customer.
+
+ -
+ Product feature. Some detail about your feature and
+ its benefit to your customer.
+
+
+
+
+ );
+}
diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx
new file mode 100644
index 00000000..d595805b
--- /dev/null
+++ b/app/routes/app._index.tsx
@@ -0,0 +1,333 @@
+import { useEffect } from "react";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
+import { json } from "@remix-run/node";
+import { useActionData, useNavigation, useSubmit } from "@remix-run/react";
+import {
+ Page,
+ Layout,
+ Text,
+ Card,
+ Button,
+ BlockStack,
+ Box,
+ List,
+ Link,
+ InlineStack,
+} from "@shopify/polaris";
+import { authenticate } from "../shopify.server";
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ await authenticate.admin(request);
+
+ return null;
+};
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const { admin } = await authenticate.admin(request);
+ const color = ["Red", "Orange", "Yellow", "Green"][
+ Math.floor(Math.random() * 4)
+ ];
+ const response = await admin.graphql(
+ `#graphql
+ mutation populateProduct($input: ProductInput!) {
+ productCreate(input: $input) {
+ product {
+ id
+ title
+ handle
+ status
+ variants(first: 10) {
+ edges {
+ node {
+ id
+ price
+ barcode
+ createdAt
+ }
+ }
+ }
+ }
+ }
+ }`,
+ {
+ variables: {
+ input: {
+ title: `${color} Snowboard`,
+ },
+ },
+ },
+ );
+ const responseJson = await response.json();
+
+ const variantId =
+ responseJson.data!.productCreate!.product!.variants.edges[0]!.node!.id!;
+ const variantResponse = await admin.graphql(
+ `#graphql
+ mutation shopifyRemixTemplateUpdateVariant($input: ProductVariantInput!) {
+ productVariantUpdate(input: $input) {
+ productVariant {
+ id
+ price
+ barcode
+ createdAt
+ }
+ }
+ }`,
+ {
+ variables: {
+ input: {
+ id: variantId,
+ price: Math.random() * 100,
+ },
+ },
+ },
+ );
+
+ const variantResponseJson = await variantResponse.json();
+
+ return json({
+ product: responseJson!.data!.productCreate!.product,
+ variant: variantResponseJson!.data!.productVariantUpdate!.productVariant,
+ });
+};
+
+export default function Index() {
+ const nav = useNavigation();
+ const actionData = useActionData();
+ const submit = useSubmit();
+ const isLoading =
+ ["loading", "submitting"].includes(nav.state) && nav.formMethod === "POST";
+ const productId = actionData?.product?.id.replace(
+ "gid://shopify/Product/",
+ "",
+ );
+
+ useEffect(() => {
+ if (productId) {
+ shopify.toast.show("Product created");
+ }
+ }, [productId]);
+ const generateProduct = () => submit({}, { replace: true, method: "POST" });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Congrats on creating a new Shopify app 🎉
+
+
+ This embedded app template uses{" "}
+
+ App Bridge
+ {" "}
+ interface examples like an{" "}
+
+ additional page in the app nav
+
+ , as well as an{" "}
+
+ Admin GraphQL
+ {" "}
+ mutation demo, to provide a starting point for app
+ development.
+
+
+
+
+ Get started with products
+
+
+ Generate a product with GraphQL and get the JSON output for
+ that product. Learn more about the{" "}
+
+ productCreate
+ {" "}
+ mutation in our API references.
+
+
+
+
+ {actionData?.product && (
+
+ )}
+
+ {actionData?.product && (
+ <>
+
+ {" "}
+ productCreate mutation
+
+
+
+
+ {JSON.stringify(actionData.product, null, 2)}
+
+
+
+
+ {" "}
+ productVariantUpdate mutation
+
+
+
+
+ {JSON.stringify(actionData.variant, null, 2)}
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ App template specs
+
+
+
+
+ Framework
+
+
+ Remix
+
+
+
+
+ Database
+
+
+ Prisma
+
+
+
+
+ Interface
+
+
+
+ Polaris
+
+ {", "}
+
+ App Bridge
+
+
+
+
+
+ API
+
+
+ GraphQL API
+
+
+
+
+
+
+
+
+ Next steps
+
+
+
+ Build an{" "}
+
+ {" "}
+ example app
+ {" "}
+ to get started
+
+
+ Explore Shopify’s API with{" "}
+
+ GraphiQL
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/routes/app.additional.tsx b/app/routes/app.additional.tsx
new file mode 100644
index 00000000..a018c3e2
--- /dev/null
+++ b/app/routes/app.additional.tsx
@@ -0,0 +1,82 @@
+import {
+ Box,
+ Card,
+ Layout,
+ Link,
+ List,
+ Page,
+ Text,
+ BlockStack,
+} from "@shopify/polaris";
+
+export default function AdditionalPage() {
+ return (
+
+
+
+
+
+
+
+ The app template comes with an additional page which
+ demonstrates how to create multiple pages within app navigation
+ using{" "}
+
+ App Bridge
+
+ .
+
+
+ To create your own page and have it show up in the app
+ navigation, add a page inside app/routes
, and a
+ link to it in the <ui-nav-menu>
component
+ found in app/routes/app.jsx
.
+
+
+
+
+
+
+
+
+ Resources
+
+
+
+
+ App nav best practices
+
+
+
+
+
+
+
+
+ );
+}
+
+function Code({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/routes/app.tsx b/app/routes/app.tsx
new file mode 100644
index 00000000..702d238e
--- /dev/null
+++ b/app/routes/app.tsx
@@ -0,0 +1,41 @@
+import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
+import { json } from "@remix-run/node";
+import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
+import { boundary } from "@shopify/shopify-app-remix/server";
+import { AppProvider } from "@shopify/shopify-app-remix/react";
+import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
+
+import { authenticate } from "../shopify.server";
+
+export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ await authenticate.admin(request);
+
+ return json({ apiKey: process.env.SHOPIFY_API_KEY || "" });
+};
+
+export default function App() {
+ const { apiKey } = useLoaderData();
+
+ return (
+
+
+
+ Home
+
+ Additional page
+
+
+
+ );
+}
+
+// Shopify needs Remix to catch some thrown responses, so that their headers are included in the response.
+export function ErrorBoundary() {
+ return boundary.error(useRouteError());
+}
+
+export const headers: HeadersFunction = (headersArgs) => {
+ return boundary.headers(headersArgs);
+};
diff --git a/app/routes/auth.$.tsx b/app/routes/auth.$.tsx
new file mode 100644
index 00000000..8919320d
--- /dev/null
+++ b/app/routes/auth.$.tsx
@@ -0,0 +1,8 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { authenticate } from "../shopify.server";
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ await authenticate.admin(request);
+
+ return null;
+};
diff --git a/app/routes/auth.login/error.server.tsx b/app/routes/auth.login/error.server.tsx
new file mode 100644
index 00000000..2c794974
--- /dev/null
+++ b/app/routes/auth.login/error.server.tsx
@@ -0,0 +1,16 @@
+import type { LoginError } from "@shopify/shopify-app-remix/server";
+import { LoginErrorType } from "@shopify/shopify-app-remix/server";
+
+interface LoginErrorMessage {
+ shop?: string;
+}
+
+export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage {
+ if (loginErrors?.shop === LoginErrorType.MissingShop) {
+ return { shop: "Please enter your shop domain to log in" };
+ } else if (loginErrors?.shop === LoginErrorType.InvalidShop) {
+ return { shop: "Please enter a valid shop domain to log in" };
+ }
+
+ return {};
+}
diff --git a/app/routes/auth.login/route.tsx b/app/routes/auth.login/route.tsx
new file mode 100644
index 00000000..db0e9025
--- /dev/null
+++ b/app/routes/auth.login/route.tsx
@@ -0,0 +1,69 @@
+import { useState } from "react";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
+import { json } from "@remix-run/node";
+import { Form, useActionData, useLoaderData } from "@remix-run/react";
+import {
+ AppProvider as PolarisAppProvider,
+ Button,
+ Card,
+ FormLayout,
+ Page,
+ Text,
+ TextField,
+} from "@shopify/polaris";
+import polarisTranslations from "@shopify/polaris/locales/en.json";
+import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
+
+import { login } from "../../shopify.server";
+
+import { loginErrorMessage } from "./error.server";
+
+export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const errors = loginErrorMessage(await login(request));
+
+ return json({ errors, polarisTranslations });
+};
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const errors = loginErrorMessage(await login(request));
+
+ return json({
+ errors,
+ });
+};
+
+export default function Auth() {
+ const loaderData = useLoaderData();
+ const actionData = useActionData();
+ const [shop, setShop] = useState("");
+ const { errors } = actionData || loaderData;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/routes/webhooks.tsx b/app/routes/webhooks.tsx
new file mode 100644
index 00000000..4099f1a0
--- /dev/null
+++ b/app/routes/webhooks.tsx
@@ -0,0 +1,28 @@
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { authenticate } from "../shopify.server";
+import db from "../db.server";
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const { topic, shop, session, admin } = await authenticate.webhook(request);
+
+ if (!admin) {
+ // The admin context isn't returned if the webhook fired after a shop was uninstalled.
+ throw new Response();
+ }
+
+ switch (topic) {
+ case "APP_UNINSTALLED":
+ if (session) {
+ await db.session.deleteMany({ where: { shop } });
+ }
+
+ break;
+ case "CUSTOMERS_DATA_REQUEST":
+ case "CUSTOMERS_REDACT":
+ case "SHOP_REDACT":
+ default:
+ throw new Response("Unhandled webhook topic", { status: 404 });
+ }
+
+ throw new Response();
+};
diff --git a/app/shopify.server.ts b/app/shopify.server.ts
new file mode 100644
index 00000000..4736a4bc
--- /dev/null
+++ b/app/shopify.server.ts
@@ -0,0 +1,51 @@
+import "@shopify/shopify-app-remix/adapters/node";
+import {
+ ApiVersion,
+ AppDistribution,
+ DeliveryMethod,
+ shopifyApp,
+} from "@shopify/shopify-app-remix/server";
+import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
+import { restResources } from "@shopify/shopify-api/rest/admin/2024-04";
+import prisma from "./db.server";
+
+const shopify = shopifyApp({
+ apiKey: process.env.SHOPIFY_API_KEY,
+ apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
+ apiVersion: ApiVersion.April24,
+ scopes: process.env.SCOPES?.split(","),
+ appUrl: process.env.SHOPIFY_APP_URL || "",
+ authPathPrefix: "/auth",
+ sessionStorage: new PrismaSessionStorage(prisma),
+ distribution: AppDistribution.AppStore,
+ restResources,
+ webhooks: {
+ APP_UNINSTALLED: {
+ deliveryMethod: DeliveryMethod.Http,
+ callbackUrl: "/webhooks",
+ },
+ },
+ hooks: {
+ afterAuth: async ({ session }) => {
+ shopify.registerWebhooks({ session });
+ },
+ },
+ future: {
+ v3_webhookAdminContext: true,
+ v3_authenticatePublic: true,
+ v3_lineItemBilling: true,
+ unstable_newEmbeddedAuthStrategy: true,
+ },
+ ...(process.env.SHOP_CUSTOM_DOMAIN
+ ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
+ : {}),
+});
+
+export default shopify;
+export const apiVersion = ApiVersion.April24;
+export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
+export const authenticate = shopify.authenticate;
+export const unauthenticated = shopify.unauthenticated;
+export const login = shopify.login;
+export const registerWebhooks = shopify.registerWebhooks;
+export const sessionStorage = shopify.sessionStorage;
diff --git a/package.json b/package.json
index 916913db..863fd1bf 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,9 @@
"vite": "vite"
},
"type": "module",
+ "engines": {
+ "node": ">=16.0.0 <22.0.0"
+ },
"dependencies": {
"@prisma/client": "^5.11.0",
"@remix-run/dev": "^2.7.1",
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000..81402fa8
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,55 @@
+import { vitePlugin as remix } from "@remix-run/dev";
+import { defineConfig, type UserConfig } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+// 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,
+ };
+}
+
+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: [
+ remix({
+ ignoredRouteFiles: ["**/.*"],
+ }),
+ tsconfigPaths(),
+ ],
+ build: {
+ assetsInlineLimit: 0,
+ },
+}) satisfies UserConfig;