CORS error when fetching from Admin Block Extension to app backend (works in dev, fails in production)

Hi,

I’m trying to fetch data from my app’s backend in an Admin Block Extension (admin.customer-details.block.render), following the documentation:

My Setup

  • React Router v7

Extension (BlockExtension.jsx):

const response = await fetch(`/api/point?customerID=${customerID}`);
const data = await response.json();

Backend route (app/routes/api.point.tsx)

export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cors } = await authenticate.admin(request);
const url = new URL(request.url);
const customerID = url.searchParams.get(“customerID”);


return cors(Response.json({ points: 100, customerID }));                                                                                 

};

The Problem

This works fine in my local development environment, but after deploying to production, I get the following CORS error:

Access to fetch at ‘https://my-app.com/api/point?customerID=…’
from origin ‘https://extensions.shopifycdn.com’ has been blocked by CORS policy:
Response to preflight request doesn’t pass access control check:
No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

I wrapped the response with cors() from authenticate.admin as documented, but still getting CORS errors on the preflight (OPTIONS) request in
production.

Questions

  1. Does the cors() method handle OPTIONS preflight requests automatically?
  2. Is there additional configuration needed for Admin Block Extensions in production?
  3. Are there any working examples?

Thank you!

Hi @tokujirou,

Thanks for reaching out about this issue!

I’m currently investigating if there is an issue with the cors() method.
In the meantime, can you confirm if bypassing that method and handling CORS manually works for you?

This is happening because the OPTIONS preflight request never hits your loader in production.

In an Admin UI extension, fetch() sends auth headers, so the browser does an OPTIONS preflight from https://extensions.shopifycdn.com.
authenticate.admin().cors() only adds headers to a response you return — it doesn’t magically handle OPTIONS unless your route actually responds to it.

In Remix / React Router v7:

  • loader handles GET

  • OPTIONS is ignored unless you handle it explicitly

That’s why it works locally but fails in prod. Check out this thread as well:

Fix

Add an action to handle OPTIONS and wrap it with cors():

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { cors } = await authenticate.admin(request);

  const url = new URL(request.url);
  const customerID = url.searchParams.get("customerID");

  return cors(Response.json({ points: 100, customerID }));
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const { cors } = await authenticate.admin(request);

  if (request.method === "OPTIONS") {
    return cors(new Response(null, { status: 204 }));
  }

  return cors(new Response("Method Not Allowed", { status: 405 }));
};

Also check

  • Your hosting/CDN must not redirect OPTIONS (http→https, trailing slash, www/apex).
    Browsers reject redirected preflight requests.

Once OPTIONS is explicitly handled, the CORS error should disappear.

Hi, thank you for your response and investigation!

I’m sorry for the confusion — it turned out I was deploying to the wrong environment. After deploying to the correct
environment, the original code from the documentation worked perfectly:

  export const loader = async ({ request }: LoaderFunctionArgs) => {                                                             
    const { cors } = await authenticate.admin(request);                                                                          
                                                                                                                                 
    const url = new URL(request.url);                                                                                            
    const customerID = url.searchParams.get("customerID");                                                                       
                                                                                                                                 
    return cors(Response.json({ points: 100, customerID }));                                                             
  };        

No need for a separate action to handle OPTIONS — the cors() method works as expected.

Thank you for your help, and apologies for the false alarm!

Thanks for letting us know that you were able to get it to work :slight_smile: