Most print-on-demand stores have a gap between "pick a shirt" and "place the order". The customer wants to put their own design on it and see how it looks before paying. Printful can print it. Someone still has to build the part where the design gets made.
I build Polotno, a design editor SDK for JavaScript. Print shops ask us for this flow all the time, so I built a demo of it. This post walks through it: a React t-shirt customizer that exports print files at Printful's exact size, sends them to Printful, and shows the mockups Printful renders back.
- Live demo: polotno.com/sdk/product/integrations/printful
- Code: github.com/polotno-project/polotno-print-on-demand
The demo runs on Polotno SDK, which needs an API key. A free trial key from polotno.com/get-started is enough to follow along. Trial keys show a "Powered by Polotno" badge in the editor.
How it fits together
There are two parts. web/ is a Vite and React app with the editor. server/ is a small Node server with no dependencies.
The split exists for one reason: the Printful token can't live in the browser. The server holds it and exposes two routes to the editor:
GET /printful/print-area real print file size for the demo tee POST /printful/mockups render the customer's design with Printful's mockup generator The server reads the print area from Printful's catalog, and the editor sizes its print box to match. The customer designs inside it. The editor exports one PNG per side at Printful's size, and the server passes those to Printful's mockup generator. Mockup URLs come back to the browser.
Nothing gets ordered. Printful's mockups are free, so you can run this against your own account without spending anything.
Step 1: ask Printful for the print size
Every Printful product has a print file with a fixed pixel size per placement. The demo sells a Bella+Canvas 3001 tee, which is product 71 in Printful's catalog. The server loads the product and its print files once and caches them for an hour:
const printful = (path, opts = {}) => { if (!env.PRINTFUL_TOKEN) throw new HttpError(503, 'PRINTFUL_TOKEN is not set'); const headers = env.PRINTFUL_STORE_ID ? { 'X-PF-Store-Id': env.PRINTFUL_STORE_ID } : {}; return api('https://api.printful.com', env.PRINTFUL_TOKEN, path, { ...opts, headers: { ...headers, ...opts.headers }, }); }; const printfulCatalog = cached(async () => { const [{ result: product }, { result: printfiles }] = await Promise.all([ printful(`/products/${PRINTFUL_PRODUCT_ID}`), printful(`/mockup-generator/printfiles/${PRINTFUL_PRODUCT_ID}`), ]); return { product, printfiles }; }); Then it finds the variant the customer picked and looks up the print file for each side:
async function printfulVariant(colorId) { const { product, printfiles } = await printfulCatalog('catalog'); const color = colorName(colorId); const variant = product.variants.find((v) => v.color === color && v.size === SIZE); if (!variant) throw new HttpError(502, `Printful has no ${color} / ${SIZE} variant`); const mapping = printfiles.variant_printfiles.find((v) => v.variant_id === variant.id); const areas = {}; for (const side of SIDES) { const id = mapping?.placements?.[side]; const file = printfiles.printfiles.find((p) => p.printfile_id === id); if (file) areas[side] = { width: file.width, height: file.height, dpi: file.dpi }; } return { variant, areas }; } GET /printful/print-area returns those areas. Each side comes back as a pixel size and a DPI. The whole export depends on that number.
Step 2: the editor
Polotno is a set of React components on top of a store. Here is the layout, trimmed from web/src/index.jsx:
import { PolotnoContainer, SidePanelWrap, WorkspaceWrap } from 'polotno'; import { Toolbar } from 'polotno/toolbar/toolbar'; import { ZoomButtons } from 'polotno/toolbar/zoom-buttons'; import { SidePanel, ElementsSection, UploadSection, TextSection } from 'polotno/side-panel'; import { Workspace } from 'polotno/canvas/workspace'; import { createStore } from 'polotno/model/store'; import 'polotno/ui.css'; const POLOTNO_KEY = import.meta.env.VITE_POLOTNO_KEY; const store = createStore({ key: POLOTNO_KEY, showCredit: false }); const SECTIONS = [ElementsSection, UploadSection, TextSection]; function Editor() { return ( <PolotnoContainer> <SidePanelWrap> <SidePanel store={store} sections={SECTIONS} /> </SidePanelWrap> <WorkspaceWrap> <Toolbar store={store} /> <Workspace store={store} pageControlsEnabled={false} /> <ZoomButtons store={store} /> </WorkspaceWrap> </PolotnoContainer> ); } The shirt is a photo on a single page. It is locked, so the customer can't select or move it. Their artwork sits above it, clipped to a dashed print box on the chest.
Before building that scene, the app asks the server for Printful's print area and matches the box to its aspect ratio:
React.useEffect(() => { const start = async () => { if (PROVIDER) { try { const area = await fetchPrintArea(COLORS[0].id); const front = area.areas.front; if (front) setPrintAspect(front.width, front.height); setPrintArea(area); } catch (e) { console.error(`${PROVIDER.id} print area unavailable`, e); } } setupScene(store, COLORS[0]); }; start(); }, []); setPrintAspect keeps the box width and recomputes its height:
export function setPrintAspect(width, height) { PRINT.height = Math.round((PRINT.width * height) / width); } So the box on the shirt has the same proportions as Printful's file. If the customer fills the box, they fill the print file. Nothing gets cropped or stretched later.
Step 3: export at Printful's print size
The customer's page also holds the shirt photo and the dashed guide. Neither should be printed. So the demo copies only the artwork into a second store, shifts it so the print box starts at 0,0, and exports from there.
Polotno exports from a store that is mounted in a <Workspace>, so the second store renders off-screen:
const printStore = createStore({ key: POLOTNO_KEY, showCredit: false }); // ...inside the app <div aria-hidden="true" style={{ position: 'fixed', left: -10000, top: 0, width: 800, height: 800, pointerEvents: 'none' }}> <Workspace store={printStore} onKeyDown={() => {}} pageControlsEnabled={false} /> </div> Building the print pages is one loadJSON call. Each side becomes a page the size of the print box, with a transparent background:
const shift = (side, children) => children.map((c, i) => ({ ...c, id: `print-${side}-${i}`, x: (c.x || 0) - PRINT.x, y: (c.y || 0) - PRINT.y, })); printStore.loadJSON({ width: PRINT.width, height: PRINT.height, dpi: PRINT_DPI, unit: 'mm', pages: [ { id: 'print-front', background: 'rgba(0,0,0,0)', children: shift('front', state.designs.front) }, { id: 'print-back', background: 'rgba(0,0,0,0)', children: shift('back', state.designs.back) }, ], }); await wait(120); await printStore.waitLoading(); Then each side that has artwork is exported at the size Printful gave us:
export async function exportPrintSides(store, printStore, sizes) { await buildPrintPages(store, printStore); const files = {}; for (const page of printStore.pages.filter((p) => p.children.length)) { const side = page.id.replace('print-', ''); const size = sizes[side]; if (!size) continue; files[side] = await printStore.toDataURL({ pageId: page.id, pixelRatio: size.width / PRINT.width, mimeType: 'image/png', }); } if (!Object.keys(files).length) throw new Error('Add some artwork first'); return files; } The line that matters is pixelRatio: size.width / PRINT.width. The print page is a few hundred pixels wide on screen. pixelRatio renders it at Printful's exact width instead. Text and shapes are redrawn at that size, so they stay sharp. Photos can only be as sharp as the file the customer uploaded.
The transparent background matters too. Printful gets a PNG where only the artwork has pixels, which is what it wants for a garment.
Step 4: send it to Printful
The browser posts the PNGs to the server as data URLs, together with the shirt color:
export const sendDesign = (files, colorId) => call(`/${PROVIDER.id}/${PROVIDER.send}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color: colorId, files }), }); Printful's mockup generator takes files by URL, not as an upload. The server keeps each PNG in memory for ten minutes and serves it from its own public URL:
function hostFile(base64, ext = 'png') { const id = randomUUID(); files.set(id, { data: Buffer.from(base64, 'base64'), ext, expires: Date.now() + FILE_TTL_MS }); return `${PUBLIC_URL}/files/${id}.${ext}`; } Now it can create the mockup task. Every file covers the whole print area, because the editor already placed the artwork inside it:
const taskBody = { variant_ids: [variant.id], format: 'jpg', options: Object.keys(sides).map((side) => (side === 'back' ? 'Back' : 'Front')), option_groups: ['Flat', "Men's", "Women's Lifestyle"], files: Object.entries(sides).map(([side, base64]) => ({ placement: side, image_url: hostFile(base64), position: { area_width: areas[side].width, area_height: areas[side].height, width: areas[side].width, height: areas[side].height, top: 0, left: 0, }, })), }; const { result: task } = await printful( `/mockup-generator/create-task/${PRINTFUL_PRODUCT_ID}`, { method: 'POST', body: taskBody } ); The generator is a queue. A task usually takes 10 to 20 seconds, so the server polls every two seconds for up to a minute:
const started = Date.now(); let status = task; while (status.status === 'pending' && Date.now() - started < 60 * 1000) { await sleep(2000); ({ result: status } = await printful( `/mockup-generator/task?task_key=${encodeURIComponent(task.task_key)}` )); } if (status.status !== 'completed') { throw new HttpError(502, `Printful mockup task ${status.status}: ${status.error || ''}`); } One catch when you run this locally: Printful can't download files from localhost. Expose the server with a tunnel and set PUBLIC_URL to the tunnel address.
Step 5: show Printful's mockups
When the task completes, the server flattens Printful's response into a list of images:
mockups: status.mockups .flatMap((m) => [ { side: m.placement, url: m.mockup_url }, ...(m.extra || []).map((x) => ({ side: x.title || x.option, url: x.url })), ]) .slice(0, 4), On the React side, the "Send to Printful" button runs the export and the request in sequence:
const send = async () => { setStep('Exporting the print files…'); setError(null); try { const files = await onExport(printArea.areas); setStep('Printful is rendering mockups. This takes 10–20 seconds…'); setResult(await sendDesign(files, color.id)); } catch (e) { setError(e?.message || 'Something went wrong'); } finally { setStep(null); } }; onExport is exportPrintSides from step 3. The result opens in a dialog that renders each mockup:
{result.mockups.map((m) => ( <div key={m.url}> <img src={m.url} alt={`Printful mockup, ${m.side}`} /> <div>{m.side}</div> </div> ))} These are Printful's own renders of the file that would be printed. If the art sits too low or a photo looks soft, the customer sees it here, before anyone pays.
From mockup to order
The order uses the same files and placements. The server already builds that request and shows it in the demo, so you can see exactly what would be sent:
{ title: 'Then order it: same files, same placements', endpointSource:https://dev.to/mishazhuro/how-to-build-a-printful-product-customizer-in-react-9jf