Shopify App Proxy Runbook
How to serve data to a Shopify storefront from your own server, so the storefront depends on your app being installed and running. Written to be followed start to finish by someone who has never built one.
What an App Proxy is
Shopify lets you claim a URL path on the merchant's own storefront domain and have Shopify forward those requests to your server. The shopper's browser thinks it is talking to the shop. It is actually talking to you.
Shopify stores nothing. Every request reaches your server, every time.
Why you would want this
- Live data on the storefront. Prices, availability, configuration — anything that would be stale if baked into the theme or a metafield.
- Same-origin. The browser calls the shop's own domain, so there is no CORS setup, and ad blockers and tracking prevention leave it alone.
- Shopify proves who is asking. Each forwarded request is signed, so the shop name cannot be faked.
- It dies when the app is uninstalled. The proxy route belongs to the app. Uninstall it and the URL returns 404 with no action from you.
Once the theme fetches from the proxy, your server becomes part of their storefront. If it is down, the feature is broken for real shoppers. Set up uptime monitoring before you go live, and keep a fallback in the theme.
Before you start
- A Shopify app you can deploy, using the React Router or Remix template (this runbook uses
authenticate.public.appProxy, which those templates provide). - A development store with the app installed.
- Shopify CLI installed and logged in.
- Somewhere to read your data from — a database table, an API, anything your server can reach.
What is the source of truth for the data you are about to serve? If it currently lives in a Shopify metafield and you copy it to your database, decide which one wins when they disagree — and write that down before you build.
The build
Six steps. Nothing here changes the storefront — the theme is only touched in step 6, and the proxy is inert until then.
Add the write_app_proxy scope
A scope is a permission your app asks the store for. Shopify requires this specific one before it will route any proxy traffic.
[access_scopes]
scopes = "read_products,write_products,write_app_proxy"
Many templates read scopes from an environment variable at runtime (scopes: process.env.SCOPES?.split(",") in shopify.server.js). If yours does, add write_app_proxy to SCOPES in every .env too — local and the production server. Miss the server one and it fails only in production.
Adding a scope forces re-authorization: the merchant is prompted to approve the new permission. Schedule that with them rather than surprising them.
Declare the proxy path
Add this block to your app config. Do it in every config file you have — dev, staging and production each need it, each pointing at its own URL.
[app_proxy]
url = "https://yourapp.com/proxy" # where Shopify forwards to
prefix = "apps" # apps | a | community | tools
subpath = "widget" # your choice
Those three values produce the storefront URL, and anything after the subpath is appended to your url:
| Storefront | Forwarded to |
|---|---|
shop.com/apps/widget/config | yourapp.com/proxy/config |
shop.com/apps/widget/anything/else | yourapp.com/proxy/anything/else |
Rules: prefix must be one of those four words exactly. subpath allows letters, numbers, _ and -, max 30 characters, and cannot be admin, services, password or login. One app gets one proxy root — plan everything else as paths beneath it.
Editing subpath or prefix after a store has installed the app will not change that store's URL. The config only applies to new installations. To change it on an existing store, do it in that store's admin: Settings → Apps and sales channels → your app → App Proxy URL → Customize URL. Merchants can change it there themselves, so if a working URL suddenly 404s, look there first.
Write the endpoint
Create a route whose path matches the forwarded URL. With url = ".../proxy", a file named proxy.config.jsx answers /proxy/config.
Export only a loader — no default export. That makes it a data endpoint that returns JSON instead of an HTML page.
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }) => {
// Verifies Shopify's signature. Throws a 400 Response if it
// fails, so unsigned requests never reach the code below.
const { session } = await authenticate.public.appProxy(request);
// No stored session for this shop = nothing to serve.
if (!session) return json({}, 404);
const url = new URL(request.url);
const productId = url.searchParams.get("product");
// ALWAYS scope the query by session.shop — never by a
// shop name read straight from the query string.
const row = await db.product.findFirst({
where: { gid: toGid(productId), session: { shop: session.shop } },
});
return json(row ? row.data : {}, row ? 200 : 404);
};
// no-store keeps changes instant — no cache to wait out.
function json(body, status = 200) {
return Response.json(body, {
status,
headers: { "Cache-Control": "no-store" },
});
}
Take the shop from session.shop (the verified result), never from searchParams.get("shop"). Read it from the query string and anyone can type a different shop name and read another merchant's data. This is the single most important line in the file.
Shopify strips Cookie and Set-Cookie from proxy requests, along with several other headers. The signed shop parameter is your only identity. Any cookie-based admin auth you have elsewhere cannot be reused in this route.
Understand what arrives
Shopify appends these to every forwarded request. You rarely read them yourself — the helper does — but knowing them makes debugging far easier.
| Parameter | What it is |
|---|---|
shop | The *.myshopify.com domain making the request. |
signature | SHA-256 HMAC of all other parameters, keyed with your app secret. |
timestamp | Unix seconds. |
path_prefix | The prefix + subpath actually used — may differ from your config if the merchant customized it. |
logged_in_customer_id | The signed-in customer, or empty. |
The signature only proves the request was not tampered with. It does not prove the viewer owns the data you are about to return. Check logged_in_customer_id against the record before serving anything personal.
Not using the React Router / Remix template?
Verify the signature yourself: remove signature from the query, sort the remaining parameters, concatenate them as key=value with no separator, compute an SHA-256 HMAC with your app's shared secret, and compare in constant time.
Note this differs from webhook verification, which uses a header and base64. Proxy signatures are a query parameter and hex. The official page linked at the bottom has worked examples.
Test it properly
Run shopify app dev. It pushes the config to your dev store and prompts you to approve the new permission. Then work through all four:
- It answers. Open
https://your-dev-store.myshopify.com/apps/widget/config?product=123in a browser. You should see your JSON. - It matches. Compare the response against the real record. Same data?
- It refuses unsigned requests. Open your tunnel URL directly —
https://your-tunnel.trycloudflare.com/proxy/config?product=123— with no Shopify in front. You must get 400 Bad Request. If you see JSON here, stop: anyone on the internet can read any store's data. - It cannot cross stores. If you have two dev stores, confirm one cannot return the other's records.
Everything looks fine without it. A missing signature check is invisible until someone finds it.
Fetch it from the theme
Use a relative URL. The browser is already on the shop's domain, so this stays same-origin.
The important part is the fallback: keep rendering the old data source into the page while you migrate, so a slow or failed request cannot break the storefront.
<div id="widget" data-product-id="{{ product.id }}">
<div class="loading">Loading options…</div>
</div>
<!-- Safety net: the old source, still embedded -->
<script type="application/json" id="fallback">
{{ product.metafields.custom.your_key.value | json }}
</script>
(async function () {
const el = document.getElementById("widget");
// Never let a slow server freeze the product page.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
let data = null;
try {
const res = await fetch(
`/apps/widget/config?product=${el.dataset.productId}`,
{ signal: ctrl.signal }
);
if (res.ok) data = await res.json();
} catch (e) {
/* timeout or network error — fall through */
} finally {
clearTimeout(timer);
}
// Proxy failed? Use the embedded copy instead.
if (!data) {
try {
data = JSON.parse(document.getElementById("fallback").textContent);
} catch (e) { data = {}; }
}
el.querySelector(".loading")?.remove();
// Empty object = switched off, or nothing configured.
if (!data || Object.keys(data).length === 0) {
el.hidden = true;
return;
}
render(data); // your existing render function, unchanged
})();
A metafield is available instantly with the page. A fetch arrives a moment later. That is why the loading state exists — without it the widget visibly pops in, or the page jumps.
Always edit a duplicate of the live theme. App proxies work in theme previews, so you can test the whole thing without publishing.
Shipping to production
- Add the scope to the production server's environment variables, if your app reads scopes from there.
- Run
shopify app deploy(with--config <name>if you keep several config files). Config changes do not reach production without this. - The merchant approves the new permission.
- Test the live URL on the shop's real domain before touching the theme.
- Publish the theme change last, once everything above is confirmed.
Steps 1–5 of the build change nothing for shoppers — the proxy sits unused. Deploy that first and let it settle. Only the theme step is visible to customers, and the fallback makes even that reversible.
If the point is to make the app required
A proxy is often built so a storefront feature stops working when the app is removed. Two things make that real, and one thing will destroy your data if you miss it.
Make the app the only source
If you keep writing the same data into metafields, the storefront still has a complete copy and the proxy is decoration. Cutting over means the data lives only on your side — do it only after the fetch has run in production without incident.
Add an off switch
A per-shop enabled flag in your database, checked by the loader, returning an empty result when off. Non-destructive, instant, reversible — far better than deleting the merchant's data. Default a shop with no row to on:
// ?? true, not a falsy check — otherwise every shop
// without a row goes dark on the storefront.
const enabled = setting?.enabled ?? true;
Most Shopify templates ship an app/uninstalled handler that deletes the shop's session row:
await db.session.deleteMany({ where: { shop } });
If your other tables reference that session with onDelete: Cascade, this silently deletes all of that merchant's data the moment anyone uninstalls — accidentally or not. Once the proxy is your only source, that data is unrecoverable.
Fix: stop deleting on uninstall. Keep the row (the token is revoked anyway, so it is useless to anyone). With offline sessions the id is stable — offline_<shop> — so a reinstall writes a fresh token into the same row and every related record reconnects automatically.
Check this before you build the proxy, not after.
Know what you are now responsible for
- Uptime. Monitor the proxy URL itself, not just your homepage.
- Caching tension. A CDN cache helps latency but delays your off switch. Short TTL, chosen deliberately, or
no-store. - The theme code is still theirs. The proxy controls the data, not the widget. If you want the UI to disappear on uninstall too, ship it as a theme app extension instead of theme files.
Things worth knowing before someone asks
Is the proxy URL public?
Yes. Anyone who knows the shop domain and a record id can open it. The signature controls which shop can be asked about, not who may ask.
That is usually fine, because the data has to reach the shopper's browser anyway. But it does make bulk collection easy — clean JSON instead of scraping HTML. Never put anything internal (costs, supplier names, notes) in a proxy response.
Does it work on a custom domain?
Yes. It works on whatever domain the storefront uses, and on theme preview URLs.
Can I return HTML instead of JSON?
Yes. Respond with Content-Type: application/liquid and Shopify renders the Liquid in the shop's theme context, so it can use theme styles and shop data. The templates expose a liquid() helper for this. JSON plus client-side rendering is simpler to reason about, so start there.
Can one app have several proxy paths?
No — one proxy root per app. Everything else lives as paths underneath it, which is why url should point at a folder-like path such as /proxy rather than a single endpoint.
It returns 400 and I do not know why
Almost always the signature check. In order of likelihood:
- You opened your server URL directly instead of going through the shop domain. (This one is correct behaviour.)
- The app secret on the server does not match the app you configured the proxy on — easy to hit when juggling several config files.
- Something rewrote the query string between Shopify and your app — a proxy, a redirect, a trailing-slash rule.
It returns 404 from Shopify, before reaching my server
Shopify does not know about the proxy for that store. Check, in order: did you run shopify app deploy; did the merchant approve the updated permissions; and has the merchant customized the proxy URL in their admin so the real path differs from your config?
Official documentation
- About app proxies and dynamic data — concepts and the template walkthrough.
- Authenticate app proxies — parameters, the signature algorithm, stripped headers.
- App configuration — the
app_proxyreference. - authenticate.public.appProxy — check the version number in the URL matches your installed package.