Run Shopify App without the App Bridge

This post wants to explain how to run a Shopify'app outside the cli and the app bridge.

Please pay attention, you won't be able to use anything from the App Bridge API. The installation process is also affected, since you won't be able to install the app without it.

Let's start with the Shopify Remix Template setup, one of the main file is the shopify.server.ts:

app/shopify.server.ts

_36
import "@shopify/shopify-app-remix/adapters/node";
_36
import {
_36
ApiVersion,
_36
AppDistribution,
_36
shopifyApp,
_36
} from "@shopify/shopify-app-remix/server";
_36
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
_36
import { restResources } from "@shopify/shopify-api/rest/admin/2024-07";
_36
import prisma from "./db.server";
_36
_36
const shopify = shopifyApp({
_36
apiKey: process.env.SHOPIFY_API_KEY,
_36
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
_36
apiVersion: ApiVersion.October24,
_36
scopes: process.env.SCOPES?.split(","),
_36
appUrl: process.env.SHOPIFY_APP_URL || "",
_36
authPathPrefix: "/auth",
_36
sessionStorage: new PrismaSessionStorage(prisma),
_36
distribution: AppDistribution.AppStore,
_36
restResources,
_36
future: {
_36
unstable_newEmbeddedAuthStrategy: true,
_36
},
_36
...(process.env.SHOP_CUSTOM_DOMAIN
_36
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
_36
: {}),
_36
});
_36
_36
export default shopify;
_36
export const apiVersion = ApiVersion.October24;
_36
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
_36
export const authenticate = shopify.authenticate;
_36
export const unauthenticated = shopify.unauthenticated;
_36
export const login = shopify.login;
_36
export const registerWebhooks = shopify.registerWebhooks;
_36
export const sessionStorage = shopify.sessionStorage;

It exports a bunch of utilities, but the one we are interested in is the authenticate one. It has a bunch of methods to check if a request is from Shopify Admin, Shopify Flow, a Shopify Webhook etc.

You should use it as the first call of each loader and action in all your routes:

app/routes/app.tsx

_10
export const loader = async ({ request }: LoaderFunctionArgs) => {
_10
await authenticate.admin(request);
_10
_10
return "This request is from Shopify Admin!";
_10
};

A great convention you can use is to run your embedded app within the /app layout, in this way you can configure common react contexts to be shared across the app. Also, you may like to have the /webhook route on the first level, not something like /app/webhook

The problem with await authenticate.admin(request) is that it throws a redirect response to the authPathPrefix you defined above, so we simply need the call to not throw.

The solution is quite easy, we need to encapsulate the logic for it, returning one thing from the session when we want to run the app outside the app bridge.

I said "one thing from the session" because it is unlikely you are using the prisma Session model to save your business logic, I bet you have a Shop model where you save your data, maybe from the afterAuth hook.

If that's the case excellent, you may have something like the following:

prisma/schema.prisma

_23
model Session {
_23
id String @id
_23
shop String
_23
state String
_23
isOnline Boolean @default(false)
_23
scope String?
_23
expires DateTime?
_23
accessToken String
_23
userId BigInt?
_23
firstName String?
_23
lastName String?
_23
email String?
_23
accountOwner Boolean @default(false)
_23
locale String?
_23
collaborator Boolean? @default(false)
_23
emailVerified Boolean? @default(false)
_23
}
_23
_23
model Shop {
_23
id String @id @default(cuid())
_23
shopifyDomain String @unique
_23
accessToken String
_23
}

Do you see the point here? We actually do not need the session if we already have the shopifyDomain (It is the one in the form <name>.myshopify.com, you can find it in the Session.shop column).

Let's write now some logic to handle this, we want to run our app with a shopifyDomain we define, something like:


_10
export async function requireShopifyDomain(request: Request) {
_10
if(process.env.NODE_ENV === "development" && process.env.RUN_AS_SHOPIFY_DOMAIN) {
_10
const shopifyDomain = process.env.RUN_AS_SHOPIFY_DOMAIN;
_10
return shopifyDomain;
_10
}
_10
_10
const { session } = await authenticate.admin(request);
_10
return session.shop;
_10
}

Excellent, we can retrieve now the shopifyDomain we define as an env, just in development to avoid any potential issue while running in production.

Let's write now a simple utility to handle all loaders requests in the same way:


_10
export function handleLoaderRequest<T>(
_10
request: Request,
_10
callback: (shopifyDomain: string) => Promise<T>
_10
) {
_10
const shopifyDomain = await requireShopifyDomain(request);
_10
return await callback(shopifyDomain);
_10
}

With this our loader gets updated like the following:

app/routes/app.tsx

_10
export const loader = async ({ request }: LoaderFunctionArgs) => {
_10
return handleLoaderRequest(request, async (shopifyDomain) => {
_10
return "This request is from Shopify Admin... probably.";
_10
});
_10
};

You may now ask yourself: "Great, but I have no way to access the GraphQL admin". You are right, it is not possible to access it in this way, but the Shopify GraphQL client is not the only client out here.

A great replacement for it is Genql, to build the client for the shop we only need two things: the shopify domain and the access token.

So let's start with a little model to get the info about the shop:

app/models/shop.ts

_10
export async function findShop(shopifyDomain: string) {
_10
return prisma.shop.findUniqueOrThrown({
_10
where: {
_10
shopifyDomain,
_10
},
_10
});
_10
}

After this let's set up the GraphQL schema for Genql, simply run the following command:


_10
npx genql --endpoint "https://<name>.myshopify.com/admin/api/2024-07/graphql.json" -S --output "app/lib/genql/generated.server" -H "X-Shopify-Access-Token: <access token>" --esm

Replace the <name>.myshopify.com and <access token> with some real data (you can also use the data from a test store, the command is just needed to generate the graphql schema).

Let's now create the little utility we will use to generate the graphql client:

app/lib/genql/index.ts

_19
import { createClient } from "./generated.server";
_19
export * from "./generated.server";
_19
_19
type CreateGenqlClientArgs = {
_19
shopifyDomain: string;
_19
accessToken: string;
_19
};
_19
_19
export function createGenqlClient({
_19
shopifyDomain,
_19
accessToken,
_19
}: CreateGenqlClientArgs) {
_19
return createClient({
_19
url: `https://${shopifyDomain}/admin/api/2024-07/graphql.json`,
_19
headers: {
_19
"X-Shopify-Access-Token": accessToken,
_19
},
_19
});
_19
}

That's it, let's glue all together:

app/routes/app.tsx

_12
export const loader = async ({ request }: LoaderFunctionArgs) => {
_12
return handleLoaderRequest(request, async (shopifyDomain) => {
_12
const shop = await findShop(shopifyDomain);
_12
const genqlClient = createGenqlClient(shop);
_12
const queryResult = await genqlClient.query({
_12
shop: {
_12
id: true,
_12
},
_12
});
_12
return `This request is from shop ${queryResult.shop.id}.`;
_12
});
_12
};

Nice, now time for some questions.

What about the billing api?

The authenticate.admin() call also returns the billing object, that allows you to handle all aspects of your app's billing, but it is just a wrapper for some graphql mutations, like appPurchaseOneTimeCreate and appSubscriptionCreate, you can handle them with Genql as well.

I also use some actions in my app!

The process to handle actions request is the same, but you just need to check for the method of the request:


_43
type PostHandler<T> = {
_43
POST: (shopifyDomain: string) => Promise<T>;
_43
};
_43
_43
type PutHandler<T> = {
_43
PUT: (shopifyDomain: string) => Promise<T>;
_43
};
_43
_43
type PatchHandler<T> = {
_43
PATCH: (shopifyDomain: string) => Promise<T>;
_43
};
_43
_43
type DeleteHandler<T> = {
_43
DELETE: (shopifyDomain: string) => Promise<T>;
_43
};
_43
_43
type RequestHandlerMap<PostResult, PutResult, PatchResult, DeleteResult> =
_43
Partial<
_43
PostHandler<PostResult> &
_43
PutHandler<PutResult> &
_43
PatchHandler<PatchResult> &
_43
DeleteHandler<DeleteResult>
_43
>;
_43
_43
export async function handleActionRequest<
_43
PostResult = never,
_43
PutResult = never,
_43
PatchResult = never,
_43
DeleteResult = never,
_43
>(
_43
request: Request,
_43
map: RequestHandlerMap<PostResult, PutResult, PatchResult, DeleteResult>,
_43
) {
_43
const requestMethod = request.method as keyof typeof map;
_43
const requestHandler = map[requestMethod];
_43
_43
if (requestHandler) {
_43
const shopifyDomain = await requireShopifyDomain(request);
_43
return await requestHandler(shopifyDomain);
_43
}
_43
_43
throw methodNotAllowed();
_43
}

You can update all your actions like this:


_10
export const action = async ({ request }: ActionFunctionArgs) => {
_10
return handleActionRequest(request, {
_10
POST: async (shopifyDomain) => {
_10
// logic for the POST method
_10
},
_10
DELETE: async (shopifyDomain) => {
_10
// logic for the DELETE method
_10
},
_10
});
_10
}

Do I need the record of the shop to be in my db?

Yes, since it is not possible to use the app bridge to get the session the only possible point is your db.

nobyai logo
Typo?