Every "free passport photo" site I tried asked me to upload a photo of my face to a server I knew nothing about. Crop arithmetic does not need a server, so I built one that doesn't.

Two weeks in, the traffic is close to zero and I have exactly one backlink. This post is not a growth story. It is a list of what the build actually looks like and the four things that broke, because three of them were not obvious.

The constraint

No backend. Not "serverless" in the marketing sense — literally no server code, no database, no API keys, no accounts. The whole thing is static files on Cloudflare Workers, and the image never leaves the tab.

That constraint decides the architecture:

  • 40 document specifications (print size in mm, head-height range, background, file limits, official source URL) live in one data file.
  • A single Node script turns that into 120 static pages in two languages.
  • The tool itself is vanilla JS on a canvas.
  • Hosting is a Worker serving static assets from dist/.

Nothing to patch, nothing to scale, nothing to monitor at 3am.

Millimetres first, pixels second

Every frame is stored in millimetres, because that is what the authorities publish. Pixels are derived:

const MILLIMETRES_PER_INCH = 25.4; function pixelsFor(millimetres, dpi = 300) { return Math.round((millimetres / 25.4) * dpi); } 

A 35 × 45 mm frame is 413 × 531 px at 300 DPI. The numbers look arbitrary until you realise they come from the print size, not the other way round. People search for "45x35mm" and "35x45 in pixels" as if they were different questions; they are the same number in two units.

The crop is cover-fit plus a clamped pan

The canvas shows exactly the frame, so the image can overflow and be clipped. Scale to cover, then let the user pan within the overflow:

const scale = Math.max(frameWidth / image.width, frameHeight / image.height) * zoom; const maxPanX = Math.max(0, (image.width * scale - frameWidth) / 2); const maxPanY = Math.max(0, (image.height * scale - frameHeight) / 2); 

Pan is stored as a fraction of maxPanX/maxPanY between -1 and 1, not in pixels. That is what makes the preview and the 300 DPI export match without recalculating anything: the export canvas repeats the same math at 413 × 531 instead of 200 × 200.

What broke: a portrait photo from a phone came out rotated for some users. createImageBitmap(file, { imageOrientation: 'from-image' }) handles EXIF in modern browsers, but I had a fallback path using an <img> element, and that one ignores the orientation tag. The fix was to stop having two paths.

Head height is a range, not a line

The published rules give a chin-to-crown range — 25–35 mm for the US, 29–34 for the UK, 32–36 for most of Europe. My first version drew a single target line, which was actively misleading: it implied precision that does not exist. The guide now draws the range, and the panel shows the numbers.

The print sheet

Some applications still want paper. The sheet is 6 × 4 inches at 300 DPI (1800 × 1200 px), fills the page with as many copies as fit, and draws cut marks:

const columns = Math.min(3, Math.floor((sheetWidth - margin * 2 + gap) / (photoWidth + gap))); const rows = Math.min(3, Math.floor((sheetHeight - margin * 2 + gap) / (photoHeight + gap))); 

A 35 × 45 mm photo fits six per sheet. A 2 × 2 inch photo fits two, because 51 mm × 2 plus margins is already 4.1 inches of a 4 inch page. I had to fix the copy on the site after doing this math — "six copies" was wrong for half the documents.

One build, two languages

The generator writes both locales from the same templates, with hreflang pairs and a sitemap that lists the alternates:

<url> <loc>https://www.passpics.com/passport-photo/uk/</loc> <xhtml:link rel="alternate" hreflang="en" href="https://www.passpics.com/passport-photo/uk/"/> <xhtml:link rel="alternate" hreflang="zh-Hans" href="https://www.passpics.com/zh/passport-photo/uk/"/> <xhtml:link rel="alternate" hreflang="x-default" href="https://www.passpics.com/passport-photo/uk/"/> </url> 

Adding a third language is one entry in a locale array plus one translation block. No page is hand-written, which is the only reason 120 pages is manageable.

The four things that broke

1. Cloudflare's Git build failed at clone, for two days.

error occurred while fetching repository, two seconds in. Re-authorising the GitHub App did nothing. The cause was grant_id: null on the project's git repository config: the app installation never produced a usable token. Uninstalling the Cloudflare app from GitHub and reinstalling it with All repositories — not "only select repositories" — fixed it immediately. That detail cost me a day.

2. cpSync crashed the process on Windows.

After a Node version change, my copy step (fs.cpSync(directory, target, { recursive: true })) started killing the process with STATUS_STACK_BUFFER_OVERRUN instead of throwing. I replaced it with a twelve-line manual recursion. If a build tool dies without an error message, suspect the native call, not your logic.

3. The hero was invisible to Largest Contentful Paint.

I had a scroll-reveal on every section: [data-rise] { opacity: 0; transform: translateY(26px) }, flipped on by an IntersectionObserver. The hero headline and the tool panel were inside it. The browser cannot count an element that is not painted, so LCP started when the animation finished. Cloudflare's field data showed 21% of loads in the "poor" bucket.

The fix is boring: the first screen paints immediately, animations only go below the fold. LCP P75 went from 2.1s to under 1s in the samples I have.

4. The sitemap's lastmod was frozen.

I generated it once with a fixed date, then kept editing content. Google saw "content changed, date unchanged" and had no reason to recrawl. It now hashes the source files and only moves when they change:

const contentHash = createHash('sha1').update(sourceFiles.join('\n')).digest('hex'); 

The numbers, honestly

  • 120 pages, two languages, ~2 MB of output
  • 40 document specs, 8 of them verified against the official page by hand, 32 flagged on the page as unverified
  • Cloudflare Web Analytics: 32 page views in the first 24 hours (mostly me)
  • Search Console after two weeks: 63 impressions, 1 click, average position 57
  • Backlinks: 1 (this post, hopefully)

Indexing a new domain is slow and boring. What I control is that every page is technically correct: one h1, canonical, hreflang, structured data, 300 ms responses.

What I would do differently

I would write the legal/policy data last and the tool first. The cropper took a weekend; verifying 40 countries' rules, and being honest about the ones I could not verify, is the part that actually takes judgement. Any page that says "35 × 45 mm" without a link to the authority is a liability.

The tool is at passpics.com — 40 documents, browser-only, no account. If you spot a wrong size, there is a feedback link in the footer; I would rather be corrected than be quietly wrong.