Shopify app notes · 08

Shopify File Uploads

Getting images and files into Shopify: the three-step staged upload, why the obvious approach breaks on large files, and how to handle many uploads at once.

staged uploads Shopify Files

Where files actually go

You do not store uploaded images. Shopify does, on its CDN, and gives you back a URL.

But you cannot simply post a file to the Admin API. Shopify uses a three-step dance called a staged upload:

1. ask for a spot → 2. upload the bytes there → 3. tell Shopify it's done

Step 2 does not go to the Admin API at all — it goes to a storage service.

  1. Ask for a spot. stagedUploadsCreate returns a temporary upload URL and some signed parameters.
  2. Upload the bytes to that URL as a normal form post.
  3. Register it with fileCreate, which turns the uploaded blob into a real file in the shop's Files.
Why it works this way

Large files never touch the Admin API, so Shopify keeps that API fast. Once you know the shape, it is the same three steps for images, fonts, videos and PDFs.


The approach everyone tries first — and why it fails

browser → your server (holds the file) → Shopify

Works perfectly with a 200 KB test image. Fails with a real 6 MB photo.

It seems natural: the form posts the file to your action, your action uploads it to Shopify. It breaks for three separate reasons.

Trap — the 401 that makes no sense

Remember from Note 04 that the browser's session token lives about one minute. A multi-megabyte upload on a slow connection can outlive it.

By the time your server finishes, the token that authorised the request has expired — so you get 401 Unauthorized even though you are clearly logged in and the file was fine.

It is intermittent, it depends on file size and connection speed, and it never happens on your fast laptop with small test images. Which is exactly why it reaches production.

The other two reasons: hosting platforms often cap request body size (413 errors on bigger files), and your server buffers the whole file in memory, so several uploads at once can exhaust it.


The fix: send the bytes from the browser

Keep the file away from your server entirely. Your server only handles two tiny JSON messages.

browser → your server → upload URL
browser → bytes go straight to storage
browser → your server → fileCreate

Both requests to your server are milliseconds long, so the session token never expires mid-flight.

This removes all three problems at once: no expiry risk, no body-size limit, no memory pressure.


Step 1 — the endpoint that asks for a spot

app/routes/app.uploads.stage.jsx
export const action = async ({ request }) => {
  const { admin } = await authenticate.admin(request);
  const { filename, mimeType, fileSize } = await request.json();

  const response = await admin.graphql(
    `#graphql
      mutation StageUpload($input: [StagedUploadInput!]!) {
        stagedUploadsCreate(input: $input) {
          stagedTargets {
            url
            resourceUrl
            parameters { name value }
          }
          userErrors { field message }
        }
      }`,
    {
      variables: {
        input: [{
          filename,
          mimeType,
          httpMethod: "POST",
          resource: "IMAGE",        // or FILE for fonts/PDFs
        }],
      },
    },
  );

  const json = await response.json();
  const errors = json.data?.stagedUploadsCreate?.userErrors ?? [];
  if (errors.length) return Response.json({ success: false, message: errors[0].message }, { status: 422 });

  return Response.json({
    success: true,
    target: json.data.stagedUploadsCreate.stagedTargets[0],
  });
};

You get back three things: url (where to send the bytes), parameters (signed values that must be included), and resourceUrl (the handle you give Shopify in step 3).


Step 2 — the browser sends the bytes

This happens in the browser, not on your server. The signed parameters must be appended before the file.

const form = new FormData();
target.parameters.forEach((p) => form.append(p.name, p.value));
form.append("file", file);          // must be appended LAST

await fetch(target.url, { method: "POST", body: form });
Two details that cause silent failures

The file must be the last field — the storage service ignores anything after it.

Do not send your own auth headers or cookies here. This request is not going to Shopify's API; the signed parameters are the authorisation.


Step 3 — register the file

app/routes/app.uploads.finalize.jsx
export const action = async ({ request }) => {
  const { admin } = await authenticate.admin(request);
  const { resourceUrl, altText } = await request.json();

  const response = await admin.graphql(
    `#graphql
      mutation CreateFile($files: [FileCreateInput!]!) {
        fileCreate(files: $files) {
          files {
            id
            fileStatus
            ... on MediaImage { image { url } }
          }
          userErrors { field message }
        }
      }`,
    {
      variables: {
        files: [{
          originalSource: resourceUrl,
          contentType: "IMAGE",
          alt: altText ?? "Image",
        }],
      },
    },
  );

  const json = await response.json();
  // check userErrors, then return the file id + url
};

The file is not ready yet

fileCreate returns immediately, but Shopify still has to process the image. Its fileStatus starts as UPLOADED and only later becomes READY.

Trap — saving a URL that does not work yet

Use the URL straight away and you may store an empty value, or show a broken image to the merchant. It usually works on a fast connection with a small file — and fails for someone else.

So you poll until it is ready:

async function waitUntilReady(admin, id, { interval = 400, timeout = 15000 } = {}) {
  const start = Date.now();

  while (Date.now() - start < timeout) {
    const res = await admin.graphql(
      `#graphql
        query FileStatus($id: ID!) {
          node(id: $id) {
            ... on MediaImage { fileStatus image { url } }
          }
        }`,
      { variables: { id } },
    );
    const node = (await res.json()).data?.node;

    if (node?.fileStatus === "READY" && node.image?.url) return node.image.url;
    if (node?.fileStatus === "FAILED") throw new Error("Shopify could not process the image");

    await new Promise((r) => setTimeout(r, interval));
  }

  throw new Error("Timed out waiting for the image");
}

Poll on the server, inside the finalize action, so the browser gets one clean answer.


Files library vs product images

Two different destinations, and people mix them up.

Shopify Files

fileCreate

A shared library for the whole shop. Not attached to anything.

Use for: logos, fonts, general assets, anything reused across products.

Product media

productCreateMedia

Images shown in a product's gallery, and assignable to variants.

Use for: the product's own photos.

Both accept a resourceUrl from a staged upload, so steps 1 and 2 are identical. Only step 3 changes.

Shopify de-duplicates identical URLs

Send the same source URL twice in one call and Shopify may create one media item, not two. If you rely on the returned list lining up with what you sent — matching by position — that shifts everything and images end up on the wrong variants.

Send each unique URL once, and map your items onto the results yourself.


Prepare files in the browser first

Cheap to add, and it prevents most upload failures:

  • Check the type — reject anything that is not an image before starting.
  • Check the size — tell the user immediately rather than after a long upload.
  • Downscale large photos — a phone photo can be 8 MB; resized to 2000px it is a few hundred KB and looks identical on a product page.
if (!file.type.startsWith("image/")) return showError("Images only");
if (file.size > 20 * 1024 * 1024)      return showError("That file is too large");

Downscaling uses a <canvas>: draw the image at a smaller size, then export it with canvas.toBlob(). Validate on the server too — a determined user can skip the browser checks.


Uploading several files

Do not fire them all at once with Promise.all. Twenty parallel uploads will saturate the connection and trip rate limits on the staging calls.

async function uploadAll(files, onProgress) {
  const done = [];

  for (let i = 0; i < files.length; i++) {
    try {
      done.push({ ok: true, file: await uploadOne(files[i]) });
    } catch (err) {
      done.push({ ok: false, name: files[i].name, message: err.message });
    }
    onProgress(i + 1, files.length);   // "3 of 12"
  }

  return done;
}

Three things make bulk uploads feel reliable: go one at a time, show progress, and let one failure not kill the rest — report which files failed and offer a retry.


Storing the result

Save the CDN URL and the file id:

imageUrl String? @db.Text    // CDN URLs are long — see Note 05 (leave @db.Text off on SQLite)
imageGid String? @unique     // needed later to delete it
Trap — deleting files that are still in use

fileDelete removes a file from the shop permanently. If two products point at the same uploaded image, deleting one product's record breaks the other.

Before deleting, check nothing else references that file. When in doubt, leave it — an unused file costs nothing; a missing one is a visible bug on the storefront.


Common errors

401 on upload, but only sometimes

The file is going through your server and the session token is expiring. Move to the browser-direct flow. "Only sometimes" is the signature of this bug — it tracks file size and connection speed.

413 Request Entity Too Large

Your hosting platform's body limit. Same fix — the bytes should never reach your server.

The upload returns 200 but the file never appears

Usually the signed parameters were not appended, or the file was not appended last. Order matters.

The image URL is empty or the image is broken

You used it before fileStatus reached READY. Poll first.

Data too long for column 'imageUrl'

On MySQL, Shopify CDN URLs exceed the default 191 characters. Add @db.Text. (On SQLite, the template's default, there is no limit and @db.Text is not allowed — see Note 05.)

Images ended up on the wrong variants

Duplicate source URLs were de-duplicated, shifting the positions you were matching against. Send each unique URL once and map results explicitly.


Checklist

  • Bytes go browser → storage directly. Your server only exchanges small JSON messages.
  • Type and size are checked in the browser and on the server.
  • Large images are downscaled before upload.
  • You poll until READY before saving or showing the URL.
  • userErrors is checked on every upload mutation.
  • URL columns are @db.Text; the file id is stored too.
  • Multiple uploads run one at a time, with progress and per-file errors.

Cheat sheet

# the three steps
1. stagedUploadsCreate   → url + parameters + resourceUrl   (your server)
2. POST the bytes        → to that url                      (the BROWSER)
3. fileCreate            → register resourceUrl             (your server)
   then poll until fileStatus === "READY"

# step 2 rules
append all parameters first, file LAST
no auth headers, no cookies

# destination
fileCreate          shop-wide Files library
productCreateMedia  a product's own gallery

# why not upload through your server
401   session token expires mid-upload
413   hosting body size limit
OOM   whole file buffered in memory

# storing
imageUrl String? @db.Text     CDN URLs are long
imageGid String? @unique      needed for fileDelete