diff --git a/.graphqlrc.ts b/.graphqlrc.ts
new file mode 100644
index 00000000..a28be7a2
--- /dev/null
+++ b/.graphqlrc.ts
@@ -0,0 +1,42 @@
+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}", "./app/.server/**/*.{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;
+}
+
+const config = getConfig();
+
+export default config;
diff --git a/app/db.server.ts b/app/db.server.ts
new file mode 100644
index 00000000..bafa6cc2
--- /dev/null
+++ b/app/db.server.ts
@@ -0,0 +1,15 @@
+import { PrismaClient } from "@prisma/client";
+
+declare global {
+ var prisma: PrismaClient;
+}
+
+if (process.env.NODE_ENV !== "production") {
+ if (!global.prisma) {
+ global.prisma = new PrismaClient();
+ }
+}
+
+const prisma: PrismaClient = 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..86274311
--- /dev/null
+++ b/app/entry.server.tsx
@@ -0,0 +1,59 @@
+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";
+
+export const streamTimeout = 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);
+ },
+ }
+ );
+
+ // Automatically timeout the React renderer after 6 seconds, which ensures
+ // React has enough time to flush down the rejected boundary contents
+ setTimeout(abort, streamTimeout + 1000);
+ });
+}
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.ts b/app/routes.ts
new file mode 100644
index 00000000..83892841
--- /dev/null
+++ b/app/routes.ts
@@ -0,0 +1,3 @@
+import { flatRoutes } from "@remix-run/fs-routes";
+
+export default flatRoutes();
diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx
new file mode 100644
index 00000000..2de9dd47
--- /dev/null
+++ b/app/routes/_index/route.tsx
@@ -0,0 +1,58 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { 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 { 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..18b215b0
--- /dev/null
+++ b/app/routes/app._index.tsx
@@ -0,0 +1,334 @@
+import { useEffect } from "react";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
+import { useFetcher } from "@remix-run/react";
+import {
+ Page,
+ Layout,
+ Text,
+ Card,
+ Button,
+ BlockStack,
+ Box,
+ List,
+ Link,
+ InlineStack,
+} from "@shopify/polaris";
+import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
+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($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 fetcher = useFetcher();
+
+ const shopify = useAppBridge();
+ const isLoading =
+ ["loading", "submitting"].includes(fetcher.state) &&
+ fetcher.formMethod === "POST";
+ const productId = fetcher.data?.product?.id.replace(
+ "gid://shopify/Product/",
+ "",
+ );
+
+ useEffect(() => {
+ if (productId) {
+ shopify.toast.show("Product created");
+ }
+ }, [productId, shopify]);
+ const generateProduct = () => fetcher.submit({}, { 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.
+
+
+
+
+ {fetcher.data?.product && (
+
+ )}
+
+ {fetcher.data?.product && (
+ <>
+
+ {" "}
+ productCreate mutation
+
+
+
+
+ {JSON.stringify(fetcher.data.product, null, 2)}
+
+
+
+
+ {" "}
+ productVariantsBulkUpdate mutation
+
+
+
+
+ {JSON.stringify(fetcher.data.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..eb9b0cfd
--- /dev/null
+++ b/app/routes/app.additional.tsx
@@ -0,0 +1,83 @@
+import {
+ Box,
+ Card,
+ Layout,
+ Link,
+ List,
+ Page,
+ Text,
+ BlockStack,
+} from "@shopify/polaris";
+import { TitleBar } from "@shopify/app-bridge-react";
+
+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 <NavMenu>
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..bdcf1162
--- /dev/null
+++ b/app/routes/app.tsx
@@ -0,0 +1,41 @@
+import type { HeadersFunction, LoaderFunctionArgs } 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 { NavMenu } from "@shopify/app-bridge-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 { 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..0e9aece7
--- /dev/null
+++ b/app/routes/auth.login/route.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import type { ActionFunctionArgs, LoaderFunctionArgs } 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 { errors, polarisTranslations };
+};
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const errors = loginErrorMessage(await login(request));
+
+ return {
+ 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.app.scopes_update.tsx b/app/routes/webhooks.app.scopes_update.tsx
new file mode 100644
index 00000000..c36bb64c
--- /dev/null
+++ b/app/routes/webhooks.app.scopes_update.tsx
@@ -0,0 +1,21 @@
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { authenticate } from "../shopify.server";
+import db from "../db.server";
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const { payload, session, topic, shop } = await authenticate.webhook(request);
+ console.log(`Received ${topic} webhook for ${shop}`);
+
+ const current = payload.current as string[];
+ if (session) {
+ await db.session.update({
+ where: {
+ id: session.id
+ },
+ data: {
+ scope: current.toString(),
+ },
+ });
+ }
+ return new Response();
+};
diff --git a/app/routes/webhooks.app.uninstalled.tsx b/app/routes/webhooks.app.uninstalled.tsx
new file mode 100644
index 00000000..54d3161c
--- /dev/null
+++ b/app/routes/webhooks.app.uninstalled.tsx
@@ -0,0 +1,17 @@
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { authenticate } from "../shopify.server";
+import db from "../db.server";
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const { shop, session, topic } = await 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.session.deleteMany({ where: { shop } });
+ }
+
+ return new Response();
+};
diff --git a/app/shopify.server.ts b/app/shopify.server.ts
new file mode 100644
index 00000000..ec980711
--- /dev/null
+++ b/app/shopify.server.ts
@@ -0,0 +1,35 @@
+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";
+
+const shopify = shopifyApp({
+ apiKey: process.env.SHOPIFY_API_KEY,
+ apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
+ apiVersion: ApiVersion.October24,
+ scopes: process.env.SCOPES?.split(","),
+ appUrl: process.env.SHOPIFY_APP_URL || "",
+ authPathPrefix: "/auth",
+ sessionStorage: new PrismaSessionStorage(prisma),
+ distribution: AppDistribution.AppStore,
+ future: {
+ unstable_newEmbeddedAuthStrategy: true,
+ removeRest: true,
+ },
+ ...(process.env.SHOP_CUSTOM_DOMAIN
+ ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
+ : {}),
+});
+
+export default shopify;
+export const apiVersion = ApiVersion.October24;
+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 54ef95f4..4bf6e817 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"eslint": "^8.42.0",
- "eslint-config-prettier": "^9.1.0",
+ "eslint-config-prettier": "^10.0.1",
"prettier": "^3.2.4",
"typescript": "^5.2.2",
"vite": "^5.1.3"
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000..82142f42
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,66 @@
+import { vitePlugin as remix } from "@remix-run/dev";
+import { installGlobals } from "@remix-run/node";
+import { defineConfig, type UserConfig } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+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,
+ };
+}
+
+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: ["**/.*"],
+ future: {
+ v3_fetcherPersist: true,
+ v3_relativeSplatPath: true,
+ v3_throwAbortReason: true,
+ v3_lazyRouteDiscovery: true,
+ v3_singleFetch: false,
+ v3_routeConfig: true,
+ },
+ }),
+ tsconfigPaths(),
+ ],
+ build: {
+ assetsInlineLimit: 0,
+ },
+}) satisfies UserConfig;