To print a 4×6 packing slip on a Rollo, or any thermal label printer, straight from the Shopify admin, you build a print-action admin extension. Select an order, click once, and the label prints. Here is how it works and the three bugs that will silently stop the print.
Shopify gives you two extension targets that put a custom button inside the admin's Print menu. Register the extension at one or both:
admin.order-details.print-action.render puts the button on a single order's detail page.admin.order-index.selection-print-action.render puts the button on the orders list, for printing a batch.In the extension, read the selected order id from shopify.data.selected, build a URL to a print route on your own server, and append a <s-admin-print-action> element with that URL as its src. Shopify fetches the URL, renders the HTML in its print preview, and hands it to the browser's print dialog.
export default async () => {
const { data } = shopify;
const orderId = data?.selected?.[0]?.id?.split("/")?.pop() || "";
// Admin UI extensions expose the ID token on the Auth object.
// shopify.idToken() (the App Bridge form) throws "is not a function" here.
let token = "";
if (orderId) {
try { token = await shopify.auth.idToken(); } catch (e) { token = ""; }
}
const src = orderId ? `/print/order/${orderId}?size=4x6&token=${token}` : "";
const el = document.createElement("s-admin-print-action");
if (src) el.setAttribute("src", src);
document.body.appendChild(el);
};
shopify.auth, not App BridgeThe print route serves customer data. The message, the To and From names, and the full shipping address all appear on the slip. It requires a Shopify session token, which the extension fetches and passes along as ?token=.
Fetch that token with shopify.auth.idToken(), off the Auth object, wrapped in a try/catch. Do not use shopify.idToken(), the App Bridge form: inside the admin UI extension runtime that method does not exist, so it throws "is not a function", the extension crashes before it renders anything, and no request reaches the server.
On the server, verify the token before you return a single byte. The token is a JWT that Shopify signs with your app secret, so recompute the signature and check it in constant time, then check the standard claims:
const [h, p, sig] = token.split(".");
const expected = crypto.createHmac("sha256", process.env.SHOPIFY_API_SECRET)
.update(`${h}.${p}`).digest();
if (!crypto.timingSafeEqual(expected, b64urlToBuf(sig))) return false;
const payload = JSON.parse(b64urlToBuf(p).toString("utf8"));
const now = Math.floor(Date.now() / 1000);
if (payload.exp && now >= payload.exp) return false; // expired
if (payload.aud && payload.aud !== process.env.SHOPIFY_API_KEY) return false;
if (payload.dest && !/\.myshopify\.com/.test(payload.dest)) return false;
Return 401 if any check fails.
The app's own in-app print buttons, outside the extension, on its React admin pages, hit a browser gesture rule. A page may only call window.print() while it is handling a genuine user gesture. If the click handler awaits the session token first and calls window.open afterward, the gesture is spent by the time the await resolves: the new tab renders but its window.print() is silently blocked, so the tab opens and no dialog appears.
Open the tab synchronously on the click, before you await the token, then point it at the real URL once the token comes back:
async function openPrint(path) {
// Open the tab synchronously on the click so the browser keeps the
// click's user-activation gesture. Await the token first and the gesture
// is gone: the tab renders but window.print() is silently blocked.
const w = window.open("about:blank", "_blank");
const token = await window.shopify.idToken();
const url = `${path}?token=${token}`;
if (w) w.location.href = url;
else window.open(url, "_blank", "noopener,noreferrer");
}
The extension path sidesteps this, because Shopify's <s-admin-print-action> element drives the preview and print from the src URL.
window.onloadThe slip carries a logo. A window.onload = () => window.print() trigger blocks on it: if the logo image is still loading, onload never fires, so the print never runs and the tab just spins.
Trigger the print once every image has settled, meaning each one has either loaded or errored, with a setTimeout fallback:
<script>
(function () {
var done = false;
function go() { if (done) return; done = true; try { window.focus(); } catch (e) {} window.print(); }
var imgs = [].slice.call(document.images);
function ready() { return imgs.every(function (i) { return i.complete; }); }
if (ready()) { go(); return; }
imgs.forEach(function (i) {
i.addEventListener('load', function () { if (ready()) go(); });
i.addEventListener('error', function () { if (ready()) go(); });
});
setTimeout(go, 2000);
})();
</script>
A done flag keeps it from firing twice.
A few specifics for the 4×6 slip:
@page { size: 4in 6in; margin: 0.12in; } inside a @media print block. That is what makes a browser lay the content out for a 4×6 label.-webkit-print-color-adjust: exact and print-color-adjust: exact so the colored message grid prints on the thermal printer.24px the logo printed faint on the thermal head, so set it to 48px and use a thermal-optimized PNG.page-break-after: always on each slip so ten selected orders come out as ten separate labels.Two server-side notes. The print route needs permissive CORS headers, because Shopify's print preview fetches it cross-origin. And when the route calls the Admin API for the shipping address, cap it with an abort timeout of a few seconds so a slow API response cannot stall the print. The slip renders without the address rather than spin.
Print an 11-line packing checklist down one side of the slip, right-justified, each line ending in a short underline the fulfillment team checks off in pen as the box is packed.
The label still has to route to the right printer. Two paths:
Want a print-action button like this on your store, or staring at a print tab that renders but will not print? That is the kind of build I do. Tell me the fulfillment step you are fighting and I will sketch the fix.
Tell me what you need and I'll tell you what it takes.
Or run a free teardown of your store first.
Tell me what you're working on. I'll reply within a business day.