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-appPro — 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)
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.