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:

RequirementWhy
Web RequestResponseThe handler is (request, { env }) => Response. Nothing else
fetchCalling Shopify and third-party APIs
crypto.subtleHMAC verification of the App Proxy signature
Environment variablesThe 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'
2
3export const code = 'functions/_src' // where the handler tree goes
4export const entry = (route) => `functions${route}.js` // entrypoint path
5export const url = (route) => `POST ${route}` // how it's displayed
6export const wrapper = (route, importPath) => // entrypoint contents
7 banner(route) +
8 `import handler from '${importPath}'\n\n` +
9 `export default (request) => handler(request, { env: process.env })\n`
npm run api:build -- --target=fly

package.json and the folder's README are generated for you.

The full contract

ExportRequiredWhat it is
codeWhere 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
preservedDirectory names to keep across rebuilds (CLI state)
labelDisplay name. Defaults to the filename
setupMarkdown lines: the deploy steps in the generated README
notesMarkdown lines appended to the generated README
proxyUrlExample [app_proxy] url, if routes sit under a prefix
exampleHostExample 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 RequestResponse 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'
3
4Bun.serve({ // or node:http, or Deno.serve
5 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.js

Neither 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=yourtarget

Then:

  • 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.