Build on Upload Native
A lightweight storefront SDK and a set of custom DOM events let you hook into every upload. Drive uploads from your own UI, mirror progress, and react to files as they are added. No build step required.
Overview
Upload Native ships as a Shopify theme app block (product page and cart) and a checkout UI
extension. With the app embed enabled, the block exposes a global window.UploadNative
SDK and emits namespaced upload-native:* events on document. Everything is
framework-agnostic, so you can call it from a theme <script>, a section, or your own bundle.
- SDK. Upload files, validate, and persist the reference from custom UI.
- Events.
upload-native:*CustomEvents fire through the lifecycle (select, upload, success/error, complete). - Cart attributes. Uploads persist as opaque references (
upload_referenceplus_ids) that survive to the order.
Installation
Enable the app embed under Theme settings › App embeds › Upload Native. That loads
upload-sdk.js and defines window.UploadNative on every storefront page. Bytes
upload directly to your Shopify Files through the app proxy. They never pass through our servers,
and no customer personal data is stored.
Example
First, get a File from a file input (or a drag-and-drop event):
// Get a File from an <input type="file"> (or a drop event's dataTransfer.files).
const input = document.querySelector<HTMLInputElement>('#proof');
input?.addEventListener('change', () => {
const file: File | undefined = input.files?.[0];
if (file) void handleUpload(file);
});Then upload it and attach the reference so checkout and the order can read it:
import type { UploadRef } from './types';
// Upload the bytes straight to Shopify Files, then attach the reference to the cart.
async function handleUpload(file: File): Promise<void> {
const cart: { token: string } = await fetch('/cart.js').then((r) => r.json());
const ref: UploadRef = await UploadNative.upload(file, {
base: '/apps/upload-app', // the app proxy (HMAC-verified by the server)
allowed: ['application/pdf', 'image/*'],
reference: cart.token, // opaque order reference, no PII
onProgress: (loaded: number, total: number): void => {
setProgress(Math.round((loaded / total) * 100));
},
});
await UploadNative.attach(ref, { target: 'cart_attribute' });
} attach(refs, options) takes the upload() result(s). target is
'cart_attribute' (default) or 'line_item_property' (pass form, a node
inside the form[action*="/cart/add"]). Without the helper, write the cart attribute yourself:
// Without the attach() helper: write the cart attribute yourself.
await fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attributes: {
upload_reference: ref.fileUrl,
upload_reference_ids: ref.fileId,
},
}),
});
Prefer a per-product line-item property over a cart attribute when each item carries its own file
and you want it on the order’s line item. The SDK writes a visible properties[Uploaded file]
(the CDN URL) plus a hidden properties[_upload_reference_ids] (the file GID; the leading underscore hides
it from the customer at checkout, not from the merchant). Shopify shows the URL as plain selectable text
in the admin order page, so your theme must wrap it in an <a href> to make it clickable on
the storefront cart:
// Attach the uploaded file to a cart LINE-ITEM property (per product), so it
// shows on the order's line item. Shopify does NOT auto-hotlink the URL — your
// theme renders the link (see the cart-template Liquid below).
//
// Option A — let the SDK write it onto the add-to-cart form:
const form = document.querySelector('form[action*="/cart/add"]');
const ref = await UploadNative.upload(file, { base: '/apps/upload-app' });
await UploadNative.attach(ref, { target: 'line_item_property', form });
// → properties[Uploaded file] = <file URL> (shown on the order)
// properties[_upload_reference_ids] = <file GID> (hidden: leading "_")
// Hide the file line from the cart & checkout (still kept on the order):
await UploadNative.attach(ref, { target: 'line_item_property', form, hidden: true });
// → properties[_Uploaded file] = <file URL> (leading "_" hides it from shoppers)
// Option B — write the hidden input yourself before the native add-to-cart submit.
// (The URL alone is enough to hotlink it; the SDK additionally writes the hidden
// properties[_upload_reference_ids] GID companion, which this variant omits.)
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'properties[Uploaded file]';
input.value = ref.fileUrl; // the Shopify Files CDN URL
form.appendChild(input);{%- comment -%} In your theme's cart template / cart line items {%- endcomment -%}
{% for property in item.properties %}
{%- if property.last == blank -%}{%- continue -%}{%- endif -%}
{{ property.first }}:
{% if property.last contains '/cdn.shopify.com/' or property.last contains '/uploads/' %}
<a href="{{ property.last }}" target="_blank" rel="noopener">{{ property.last | split: '/' | last }}</a>
{% else %}
{{ property.last }}
{% endif %}
{% endfor %}target: 'cart_attribute' when an upload must block checkout.window.UploadNative
The global SDK, available once the app embed (or a block) mounts.
| Method | Returns | Description |
|---|---|---|
upload(file, options) | Promise<UploadRef> | Upload one file end to end. |
uploadMany(files, options) | Promise<SettledResult[]> | Upload many with controlled concurrency (default 3). |
validate(file, options) | string | null | UX-only pre-check. null means the file is acceptable. |
attach(refs, options) | Promise<void> | Persist the upload result(s) to the cart attribute or line-item properties. |
init(root?) | void | No-op kept for API compatibility. Blocks self-render; no manual wiring needed. |
version | string | Build marker. |
attach also exposes the lower-level writers writeCartAttribute and
writeLineItemProperties for full control.
Upload options
The second argument to upload() and uploadMany():
| Option | Type | Default | Description |
|---|---|---|---|
base | string | '/apps/upload-app' | The app proxy path (HMAC-verified by the server). |
allowed | string[] | (none) | Accepted MIME types or extensions, e.g. ['application/pdf','image/*']. |
reference | string | (none) | Cart token. An opaque, no-PII order reference. |
onProgress | function | (none) | (loaded, total) byte progress for your bar. |
signal | AbortSignal | (none) | Cancel an in-flight upload. |
Types
TypeScript shapes for the SDK surface (illustrative; the SDK ships as plain JS on the storefront):
// window.UploadNative: available once the app embed (or a block) mounts.
interface UploadNative {
upload(file: File, options: UploadOptions): Promise<UploadRef>;
uploadMany(files: File[], options: UploadManyOptions): Promise<SettledResult<UploadRef>[]>;
validate(file: File, options: ValidateOptions): string | null; // null = ok
attach(refs: UploadRef | UploadRef[], options?: AttachOptions): Promise<void>;
init(root?: ParentNode): void; // no-op; blocks self-render, kept for compatibility
readonly version: string;
}
interface UploadRef {
fileId: string; // Shopify File GID, e.g. "gid://shopify/GenericFile/123"
fileUrl: string; // CDN URL of the stored file
}
interface UploadOptions {
base: string; // app proxy path, "/apps/upload-app"
allowed?: string[]; // MIME types / extensions
reference?: string; // cart token (opaque, no PII)
onProgress?: (loaded: number, total: number) => void;
signal?: AbortSignal; // cancel an in-flight upload
}
interface UploadManyOptions extends Omit<UploadOptions, 'onProgress'> {
concurrency?: number; // default 3
onProgress?: (loaded: number, total: number) => void; // aggregate across files
onFileDone?: (index: number, result: SettledResult<UploadRef>) => void;
}
interface AttachOptions {
target?: 'cart_attribute' | 'line_item_property'; // default "cart_attribute"
name?: string; // attribute/property key, default "upload_reference"
multiple?: boolean; // default refs.length > 1
form?: Element; // required for line_item_property
hidden?: boolean; // line_item_property only: _-prefix the property
// so it stays on the order but is hidden from
// the cart & checkout. default false
}
interface ValidateOptions {
accept: string; // e.g. ".pdf,image/*"
maxBytes: number;
maxFiles: number;
}
type SettledResult<T> =
| { status: 'fulfilled'; value: T }
| { status: 'rejected'; reason: unknown };Custom events
The built-in app block emits bubbling CustomEvents through the upload lifecycle, so you can
wire analytics or custom behavior without replacing the UI. They fire on the <upload-native-block>
element and bubble to document. Every detail includes the blockId.
| Event | When | detail |
|---|---|---|
| upload-native:select | files chosen and passed client validation | { files: [{ name, size, type }] } |
| upload-native:upload | per file, when its upload begins | { name, size, type } |
| upload-native:success | per file, on success | { fileId, fileUrl, name, size } |
| upload-native:error | per file, on failure | { message, name } |
| upload-native:complete | after the batch is persisted | { urls, gids, attribute, target } |
// Events bubble from <upload-native-block> to document; detail carries blockId.
document.addEventListener('upload-native:success', (e: Event) => {
const { fileId, fileUrl, name } = (e as CustomEvent).detail;
console.log('uploaded', name, fileUrl, fileId);
});
document.addEventListener('upload-native:error', (e: Event) => {
const { name, message } = (e as CustomEvent).detail;
console.warn('failed', name, message);
});Cart attributes
attach writes the file reference where checkout and the Shopify order read it. A single file
stores a scalar value; multiple files store a JSON array. A companion upload_reference_ids holds
the Shopify File GID(s). For per-product capture, the same values are written as line-item properties on the
product form.
Errors
upload() rejects on 402 (store upload limit), 413 (file too large),
and 415 (type not allowed). Map these to buyer-friendly copy. The server is always the source
of truth; validate() is a UX convenience.
const err: string | null = UploadNative.validate(file, {
accept: '.pdf,image/*',
maxBytes: 20 * 1024 * 1024, // client cap; Shopify Files caps generic files at 20 MB
maxFiles: 1,
});
if (err) showError(err); // null means the file is acceptablePrefer the no-code option? Add the “Upload Native” block from the theme editor. See Support.