Skip to main content

Fixing 'No route matches /admin/oauth/authorize' in Shopify Remix Apps

 There is a specific, visceral panic that sets in when a Shopify app works perfectly in local development but crashes immediately upon deployment.

You run npm run dev, Cloudflare tunnels spin up, and the OAuth flow is seamless. You deploy to Vercel, Fly.io, or Heroku, click "Install" on a test store, and are greeted with a cryptic 404 or a Rails-style error message: No route matches /admin/oauth/authorize.

This error typically implies a disconnect between Shopify’s OAuth requirements and your Remix application’s routing configuration. It is rarely a bug in Remix itself, but rather a misconfiguration of the production environment variables or the wildcard authentication route.

This guide provides a root cause analysis and a definitive technical fix for this issue using the latest @shopify/shopify-app-remix package.

The Root Cause: Local Tunnels vs. Production Edge

To fix this, we must understand why it breaks only in production.

When you use the Shopify CLI (npm run dev), the tool automatically manages your environment. It spins up a Cloudflare tunnel, detects the generated URL (e.g., https://random-name.trycloudflare.com), and silently updates your app's configuration in the Shopify Partner Dashboard.

In production, this automation stops.

The No route matches error occurs when one of three architectural failures happens:

  1. Missing Host Configuration: The Remix app does not know its own public URL, so it constructs an invalid redirect_uri during the OAuth handshake.
  2. Splat Route Failure: The file responsible for handling authentication (routes/auth$.tsx) is not capturing the /auth/callback or /auth/login requests correctly.
  3. Dashboard Mismatch: The "App URL" and "Allowed Redirection URL(s)" in the Shopify Partner Dashboard do not match the environment variables in your deployment.

Shopify rejects the handshake before it even reaches your logic, or your app fails to handle the callback, resulting in the route error.

The Fix: Step-by-Step Implementation

We will enforce strict URL validation and configure the catch-all route correctly.

1. Hardening the Shopify Server Configuration

First, ensure your initialization logic explicitly defines the appUrl. Do not rely on implicit defaults in production.

Modify app/shopify.server.ts:

import "@shopify/shopify-app-remix/adapters/node";
import {
  AppDistribution,
  DeliveryMethod,
  shopifyApp,
  LATEST_API_VERSION,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import { restResources } from "@shopify/shopify-api/rest/admin/2024-01";
import prisma from "./db.server";

// CRITICAL: Fail fast if the URL is missing in production
const appUrl = process.env.SHOPIFY_APP_URL;

if (process.env.NODE_ENV === "production" && !appUrl) {
  throw new Error("SHOPIFY_APP_URL must be set in production environment.");
}

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
  apiVersion: LATEST_API_VERSION,
  scopes: process.env.SCOPES?.split(","),
  appUrl: appUrl || "http://localhost:3000", // Fallback only for local dev
  authPathPrefix: "/auth",
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  restResources,
  webhooks: {
    APP_UNINSTALLED: {
      deliveryMethod: DeliveryMethod.Http,
      callbackUrl: "/webhooks",
    },
  },
  hooks: {
    afterAuth: async ({ session }) => {
      shopify.registerWebhooks({ session });
    },
  },
  future: {
    v3_webhookAdminContext: true,
    v3_authenticatePublic: true,
  },
});

export default shopify;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;

2. The Auth Splat Route (auth$.tsx)

Remix uses the $ suffix for splat (catch-all) routes. The Shopify template relies on app/routes/auth$.tsx to handle /auth/login/auth/callback, and /auth/toplevel.

If this file is missing, moved, or has incorrect exports, OAuth fails. Ensure your app/routes/auth$.tsx looks exactly like this:

import type { LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  // This single line handles the entire OAuth handshake:
  // 1. Starts the flow (login)
  // 2. Handles the callback
  // 3. Exchanges the access token
  // 4. Redirects to the app root
  await authenticate.admin(request);

  return null;
};

3. Production Environment Variables

This is the most common point of failure. In your hosting provider (Vercel, Fly, Heroku, etc.), set the following environment variables exactly:

  • SHOPIFY_API_KEY: (From Partner Dashboard)
  • SHOPIFY_API_SECRET: (From Partner Dashboard)
  • SCOPESwrite_products,read_orders (etc.)
  • SHOPIFY_APP_URLhttps://your-production-app.com (Must include https:// and no trailing slash)

4. Partner Dashboard Configuration

Go to your Shopify Partner Dashboard -> Apps -> Configuration.

  • App URL: Must match SHOPIFY_APP_URL.
    • Example: https://your-production-app.com
  • Allowed Redirection URL(s):
    • Example: https://your-production-app.com/auth/callback
    • Example: https://your-production-app.com/auth/shopify/callback (If using older templates)
    • Crucial: Included https://your-production-app.com/api/auth/callback if you are doing deep embedding, but strictly speaking, the Remix template uses /auth/callback by default.

Deep Dive: Why The Handshake Fails

The @shopify/shopify-app-remix package abstracts a complex security dance. Here is what happens under the hood when you visit your app:

  1. Trigger: A user visits https://your-production-app.com.
  2. Detection: The loader in _index.tsx checks for a session.
  3. Redirect: If no session exists, authenticate.admin throws a Response object (a redirect) pointing to /auth/login.
  4. Handshake: The browser goes to /auth/login, which redirects to shopify.com/admin/oauth/authorize.
  5. Validation: Shopify checks the redirect_uri parameter passed in that URL.

If you omit SHOPIFY_APP_URL in production, the library might attempt to infer the host from request headers. In serverless environments (like Vercel or AWS Lambda), proxies and load balancers can strip or obfuscate the Host header. This results in the library sending http://localhost:3000/auth/callback as the redirect URI to Shopify.

Shopify sees a mismatch between the registered Allowed Redirection URLs (production) and the requested redirect URI (localhost), resulting in the "No route matches" or "Oauth error invalid_request" screen.

Edge Cases and Pitfalls

1. The "Mixed Content" Loop

If your load balancer terminates SSL (HTTPS) but forwards traffic to your Node server over HTTP, Remix might believe the request is insecure.

Solution: ensure your entry.server.tsx or server configuration trusts the proxy. If you are using a custom Express server with Remix:

// server.js
app.set('trust proxy', true); // Trust the load balancer's X-Forwarded-Proto

2. Database Session Persistence

If OAuth seems to succeed but then immediately redirects back to login (an infinite loop), your session storage is failing.

In the shopify.server.ts code provided above, we used PrismaSessionStorage. Ensure your database is provisioned and your schema includes the Session table:

// prisma/schema.prisma
model Session {
  id          String    @id
  shop        String
  state       String
  isOnline    Boolean   @default(false)
  scope       String?
  expires     DateTime?
  accessToken String
  userId      BigInt?
}

If the database is unreachable or the schema is not applied (npx prisma migrate deploy), the session cannot be saved, and the user will be forced to re-authenticate endlessly.

Conclusion

The "No route matches" error in Shopify Remix apps is almost always a discrepancy between where the code thinks it is running and where Shopify expects it to be running.

By explicitly defining appUrl in your server configuration, strictly matching your Partner Dashboard URLs, and ensuring your wildcard auth route is correctly placed, you eliminate the ambiguity that causes production OAuth failures.