DEV Community

Misha Zhuro
Misha Zhuro

Posted on

How to build a Printful product customizer in React

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.

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
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
}, []);
Enter fullscreen mode Exit fullscreen mode

setPrintAspect keeps the box width and recomputes its height:

export function setPrintAspect(width, height) {
  PRINT.height = Math.round((PRINT.width * height) / width);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 }),
  });
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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 || ''}`);
}
Enter fullscreen mode Exit fullscreen mode

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),
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
))}
Enter fullscreen mode Exit fullscreen mode

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',
  endpoint: 'POST https://api.printful.com/orders',
  body: {
    recipient: { name: '…', address1: '…', city: '…', country_code: '…', zip: '…' },
    items: [
      {
        variant_id: variant.id,
        quantity: 1,
        files: taskBody.files.map(({ placement, image_url, position }) => ({
          type: placement,
          url: image_url,
          position,
        })),
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

Add a real recipient and that is a Printful order. For production, put the PNGs in real storage instead of the in-memory map. Printful needs to download the file when it processes the order, and ten minutes may not be enough.

Run it

You need Node 20+, pnpm, a Polotno API key and a Printful private token for a "Manual order platform / API" store.

cd server
cp .env.example .env   # add PRINTFUL_TOKEN and PUBLIC_URL
node --env-file=.env server.mjs
Enter fullscreen mode Exit fullscreen mode
cd web
cp .env.example .env   # add VITE_POLOTNO_KEY
pnpm install
pnpm dev
Enter fullscreen mode Exit fullscreen mode

For the tunnel, something like cloudflared tunnel --url http://localhost:8787 works. Then open http://localhost:5173/?provider=printful.

Where to go from here

The same repo has Printify, Teemill, Fourthwall and Spreadconnect modes behind the ?provider= parameter. If you only care about Printful, polotno-printful-demo has just that editor and backend.

The part I'd change first for a real store is the product list. The demo hardcodes one tee. Reading products from Printful's catalog and passing the print size into the editor works the same way for mugs or posters.

If you build on it, I'd like to see what you make. Questions are welcome in the comments.

Top comments (0)