shopcheck · preflight linter for Shopify app submissions

The 7 bugs that get Shopify apps rejected — caught before you submit.

Every App Store review round trip costs about a week. Most rejections aren't about your app's idea — they're the same scaffold-level patterns reviewers hit within minutes. shopcheck scans your embedded Remix app for all of them in two seconds. Every rule maps to a real rejection email we received, with the fix that passed review.

npx shopcheck ./my-appGet the full ruleset — $49 one-time
$ npx shopcheck .

shopcheck v0.2.0 — Shopify App Store submission preflight
Scanned 29 files in ./my-app

✖ BLOCKER [app-wide-billing-gate] app/routes/app.tsx:12
  App-wide billing gate in the parent /app route loader
  Ref: App Store 2.1.1 — main functionality fails to appear
  Why: An unsubscribed reviewer is bounced to the Plans page on
       every route and can never reach core features.
  Fix: Remove the gate from the parent loader; gate premium
       features individually instead.

✖ BLOCKER [billing-return-url-not-session-derived] app/lib/billing.server.ts:41
  Billing returnUrl built from the request URL instead of the session
  ...

Summary: 2 blocker(s), 1 warning(s), 0 info
Do not submit until blockers are resolved — each maps to a
documented rejection or suspension cause.

Seven ways to fail review without writing a single bad feature

Collected from dozens of real rejection emails — and one suspension — across a 19-app portfolio. If you built from a template, you probably have at least one of these right now.

app-wide-billing-gate

The billing gate

What the reviewer wrote: “Generating quotes redirects to the subscription page.”

A parent app.tsx loader that redirects unsubscribed shops to the Plans page sounds sensible — until you remember the reviewer is an unsubscribed shop. They never reach your features. Instant 2.1.1.

billing-return-url-not-session-derived

The returnUrl dead-end

What the reviewer wrote: “After approving the charge we were redirected to an error page.”

Build returnUrl from the request URL and it works — until a client-side nav strips the query params and the post-approval redirect lands on /auth/login. The fix is a session-derived admin deep link, and it's not in any template.

env-gated-mock-data

The mock-data leak

What the reviewer wrote: “The app isn't showing any of our products, so we can't test it.”

Gate your data source on USE_MOCK_DATA and one stale deployed env serves fixture data to an authenticated reviewer. This exact pattern has caused an outright suspension with a resubmission lockout — not just a rejection.

unawaited-login

The auth-route crash

What the reviewer wrote: “The app redirects to an error page.” (plus 502s for everyone else)

login() redirects by throwing a Response. Call it without await and the rejection escapes the request cycle and kills the Node process. One GET to /auth/login and your app flaps.

window-open-server-route

The download loop

What the reviewer wrote: “We can't download the file — it asks us to re-enter our shop domain.”

window.open() to a server route opens a tab with no App Bridge session token, so authentication bounces the reviewer into the OAuth flow. Exports, PDFs, CSVs — any file button can carry it.

billing-istest-nodeenv

The real-charge test

What the reviewer wrote: “Choosing a plan ends on the Application Error page.”

isTest: NODE_ENV !== 'production' means your production deploy attempts a REAL charge on the reviewer's dev store — which errors in front of them, mid-review.

swallowed-query-errors

The silent empty state

What the reviewer wrote: “The app only shows sample data, not our real data.”

catch { return [] } around an Admin API query turns a genuine failure into an innocent-looking empty store. To a reviewer, silence reads as dummy data.

Pricing

One rejection costs you a week. A suspension costs you the listing. shopcheck costs less than an hour of anyone's time, once.

Free

$0

5 rules — the most common scaffold killers. No sign-up, no key.

  • ✓App-wide billing gate (2.1.1)
  • ✓@anthropic-ai/sdk runtime crash → silent mock fallback
  • ✓AbortSignal.timeout process crash (502s)
  • ✓Blank SSR body / white-iframe hydration guard
  • ✓Deprecated offline-token config
npx shopcheck ./my-app

Pro — lifetime license

$49 one-time, per team

All 17 rules, including everything on the free tier plus:

  • ✓Both billing returnUrl traps (255-char cap + the /auth/login dead-end)
  • ✓Env-gated mock data — the documented suspension cause
  • ✓Unawaited login() auth-route crash
  • ✓window.open session-loss downloads
  • ✓Managed Pricing vs Billing API conflict
  • ✓isTest/NODE_ENV real-charge trap
  • ✓Missing auth.login route error page
  • ✓Fixture content reachable in real sessions
  • ✓Swallowed Admin API errors
  • ✓Removed GraphQL enum values (only fails on the real-data path)
  • ✓Unbounded LLM calls in loaders (white iframe)
Buy a license

License key by email · works offline, in CI, forever

Built for the submit button, and for CI

Zero dependencies

One file, Node ≥18, nothing to configure. Runs in about two seconds on a typical app.

CI-ready

--json output and exit code 1 on blockers. Put it in the pipeline and a rejection-bait pattern can never reach a submission again.

Fix included

Every finding ships the ref (which review guideline), the why (what the reviewer sees) and the fix that actually passed review.

What shopcheck is — and isn't

shopcheck is a static lint pass over known rejection-bait patterns in embedded Remix / shopify-app-remix apps. It is not a guarantee of approval, and it doesn't replace testing on a real store with demo mode off, or a decent listing. It's the two-second check that saves the five-day round trip — the one we wish we'd had before the first dozen rejections.