The API
Other platforms
Hosting the gateway somewhere other than Netlify.
Netlify is what this documentation walks through, and what ships configured out of the box. It isn't a requirement — nothing in the gateway is Netlify-specific, and the whole thing is about 400 lines of standard JavaScript.
This page is for going somewhere else: what any platform has to provide, how to teach the build about it, and where to look for the platform-specific parts.
What the gateway actually needs
Less than most platforms offer. If a runtime can do these four things, it can host this:
| Requirement | Why |
|---|---|
Web Request → Response | The handler is (request, { env }) => Response. Nothing else |
fetch | Calling Shopify and third-party APIs |
crypto.subtle | HMAC verification of the App Proxy signature |
| Environment variables | The credentials, however the platform exposes them |
That's the complete list. The code uses URL, URLSearchParams and
TextEncoder too, but those are in every runtime that has the other four.
No Node built-ins, no filesystem, no dependencies. package.json ships with
an empty dependencies on purpose — operations and services are compiled into
the bundle at build time precisely so the deployed function never reads from
disk. That's what makes edge runtimes viable.
In practice that means Node 18+, Deno, Bun, Cloudflare Workers, Vercel's Node and Edge runtimes, Netlify Functions v2, and most container hosts.
Two things to get right
Deploying elsewhere means solving both:
1. The adapter — teaching npm run api:build how to lay out a folder for
that platform. One file.
2. The platform setup — creating the project, setting the three environment variables, and pointing the App Proxy at the resulting URL. The Netlify walkthrough is the template; the shape is the same everywhere.
Writing an adapter
Every file in scripts/deploy-targets/ is one platform, and the build discovers
them — so adding one is a file, not a change to the build script.
1import { banner, slug } from './_shared.js'23export const code = 'functions/_src' // where the handler tree goes4export const entry = (route) => `functions${route}.js` // entrypoint path5export const url = (route) => `POST ${route}` // how it's displayed6export const wrapper = (route, importPath) => // entrypoint contents7 banner(route) +8 `import handler from '${importPath}'\n\n` +9 `export default (request) => handler(request, { env: process.env })\n`npm run api:build -- --target=flypackage.json and the folder's README are generated for you.
The full contract
| Export | Required | What it is |
|---|---|---|
code | ✅ | Where the copied handler tree goes, relative to deploy/<name>/ |
entry(route) | ✅ | Path of the generated entrypoint for one route |
url(route) | ✅ | How the route is displayed |
wrapper(route, importPath) | ✅ | Contents of that entrypoint |
files(routes) | Extra files as { path: contents } — config, placeholders | |
preserved | Directory names to keep across rebuilds (CLI state) | |
label | Display name. Defaults to the filename | |
setup | Markdown lines: the deploy steps in the generated README | |
notes | Markdown lines appended to the generated README | |
proxyUrl | Example [app_proxy] url, if routes sit under a prefix | |
exampleHost | Example host for the curl snippet |
A half-written adapter fails the build naming the missing export, rather than throwing something opaque part-way through writing a folder.
Four traps, on every platform
Routes carry a dot. /admin.gql is the public path. If the platform derives
a function name from the filename, it probably can't contain one — Netlify
rejects it with a 422 at upload, after a successful bundle. Use slug(route)
from _shared.js for the filename and pin the real path some other way. Netlify
does it with Functions v2 config.path; Vercel would need a rewrite rule.
Declare preserved for CLI state. The link between the folder and the remote
project lives in a dotfile directory — .netlify, .vercel, .wrangler. The
build clears everything else each run, so an unlisted directory means re-linking
every time, and re-linking re-runs framework detection.
Turn framework detection off. These folders sit inside a Vite + Shopify repo,
and platform CLIs walk up looking for a framework. Left alone they will guess a
build command that doesn't exist here and fail the deploy. Say explicitly that
there's no build step, in whatever config the platform reads —
framework: null for Vercel, an explicit no-op command for Netlify.
Nothing may import outside the deploy folder. The tree is copied so it can stand alone. A relative import that escapes works locally and breaks in production.
Where to look, per platform
Rough notes rather than tested walkthroughs — the adapter shape is the same, the per-platform details differ.
Vercel
Serves functions from an api/ directory, so routes gain that prefix — set
proxyUrl to https://your-project.vercel.app/api and the App Proxy points at
the prefix, not the domain root. vercel.json needs framework: null and
buildCommand: null. Preserve .vercel. Environment variables bind at deploy
time, so set them and deploy again.
Docs: Vercel Functions
Cloudflare Pages
Routes come from a functions/ directory. The entrypoint exports
onRequest, and bindings arrive on context.env rather than process.env:
export const onRequest = (context) => handler(context.request, { env: context.env })Preserve .wrangler. Use wrangler pages secret put rather than plain
variables, so credentials are encrypted at rest. Files under functions/ that
start with _ are not routed, which is what keeps the copied handler tree
private.
Docs: Pages Functions
Deno Deploy / Bun
Both speak Request → Response natively, so the wrapper is almost nothing.
Read credentials from Deno.env or process.env respectively and pass them as
env.
A container host — Fly, Render, Railway, your own box
The least adapter work: one small server that routes two paths to the two handlers.
1import admin from './_src/admin.gql.js'2import service from './_src/service.js'34Bun.serve({ // or node:http, or Deno.serve5 fetch(request) {6 const { pathname } = new URL(request.url)7 if (pathname === '/admin.gql') return admin(request, { env: process.env })8 if (pathname === '/service') return service(request, { env: process.env })9 return new Response('Not found', { status: 404 })10 },11})Worth knowing: the Admin token cache lives in module scope, so a long-lived process exchanges credentials once a day rather than once a cold start. That's strictly better than serverless here.
AWS Lambda
Lambda's native event shape is not a Request, so the wrapper has to translate
both directions. A Function URL with the payload format v2 is the least
friction. If you're on AWS anyway, CloudFront Functions and Lambda@Edge are
awkward fits — neither gives you a comfortable crypto.subtle.
Vercel and Cloudflare used to ship
Both adapters existed and worked before the supported surface was narrowed to Netlify. They were deleted rather than deprecated, so they are recoverable:
git log --diff-filter=D --name-only -- scripts/deploy-targets/git show <commit>:scripts/deploy-targets/vercel.jsNeither is guaranteed current, but both are a better starting point than an empty file.
Checking a new target before you deploy
npm run api:build -- --target=yourtargetThen:
- Every entrypoint imports cleanly.
node -e "import('./deploy/<name>/<entry>')"— a bad relative path fails here rather than in production. - Nothing escapes the folder.
grep -rn "\.\./\.\./\.\./\.\." deploy/<name>/ - No secrets were baked in. The deploy folder should contain no credentials at all; they belong in the platform's environment.
Once deployed, the check is the same everywhere — an unsigned curl should
answer 401 Invalid signature. See
Deploy to Netlify.