How to build a QR code generator

How to build a QR code generator with styled squares, a short URL you can edit, and daily scan counts. Follow the prompts and skip landing pages.

  • guide
  • vibe-coding
  • qr-tiger

You can learn how to build a QR code generator as branded squares that point at a URL you control. QR Tiger is that product. The catalog line is “Squares that send people wherever you say. Even later.”

You pay $15–$49 a month. That is $180–$588 a year. The verdict is “It’s literally squares”. The first estimate is 90 minutes: styled codes, editable redirects, daily scan counts.

I already wrote the teardown, Build a QR Tiger alternative. This guide is the walk. Prompts in order, then what to look at.

The audience is a marketer putting QR codes on menus, posters, and packaging. If you need GPS heatmaps, a landing-page builder, or a public API for other people’s codes, keep paying. The same v1 also replaces Flowcode and Uniqode.

How to build a QR code generator without landing pages

The QR Tiger page splits the work into three rows.

The first row is the one you want. Colors, an optional logo, a PNG you can print. A short URL on a domain you own. A number that goes up when someone scans. That is the 90-minute v1.

The second row is 4 hours. SVG export, bulk creation, UTM tagging, expiry. Do that after one printed code already works.

The third row is a month and up. Accounts, campaigns, landing pages, GPS analytics, a public API. Skip it.

If you tell an agent to “clone QR Tiger”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 90 minutes.

const code = {
  slug: 'menu-spring',
  shortUrl: 'https://go.flaviocopes.com/r/menu-spring',
  destination: 'https://flaviocopes.com/menu',
  fg: '#171713',
  bg: '#f4f1e8',
  logo: null,
  errorCorrection: 'M',
  enabled: true,
}

A phone camera reads https://go.flaviocopes.com/r/menu-spring. Your server sends the visitor to the current destination. You change the menu URL in October. The poster from March still works.

The card lists Code, RedirectRule, and ScanEvent. Tonight the redirect is a slug and a destination on the code row. ScanEvent can be a (code_id, day) counter.

const scanDay = {
  codeId: 'menu-spring',
  day: '2026-10-25',
  count: 2,
}

codes needs a unique index on slug. scan_days needs a unique index on (code_id, day) so two Workers incrementing the same lunch rush do not invent a second row.

CODE ROW menu-spring, poster-js destination plus colors enabled flag, unique slug SHORT URL IN THE IMAGE go.flaviocopes.com /r/menu-spring SCAN DAYS UNIQUE (code_id, day) Increment after the 302
The image encodes a slug you own. The destination stays a column you can edit.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for QR Tiger is Astro, Workers, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The image still encodes the short URL. The lookup is still one row.

Open Build your own QR Tiger. The guardrail is the useful sentence: validate destinations as http or https, keep redirects fast, and record scans without cookies, fingerprints, or precise location. The non-goal sits next to it. No landing pages, GPS tracking, team workspaces, white-label domains, or a public API.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. Pick one stack and stay on it.

Prompt 1. Scaffold Squarecode and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 90-minute row only. Styled codes,
editable redirects, daily scan counts.

Project name: Squarecode.
Public URL is https://go.flaviocopes.com.
Owner is Flavio. Visitors do not create
accounts.

Do not add SVG export, bulk creation, UTM
tagging, or expiry yet.
Do not add landing pages, GPS tracking,
team workspaces, white-label domains, or
a public API.

When the plan is done, stop and wait. Do
not write implementation code until I say
go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /dashboard for the codes list
  • /codes/new for the editor
  • /r/:slug for the public redirect

You want a codes table. A scan_days table with (code_id, day, count) is enough. campaigns should not appear. landing_pages should not appear.

If the plan includes a page builder, a map of scan locations, or OAuth for people who scan, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see an empty list and a create action.

Open the migrations. codes.slug is unique. scan_days has a unique constraint on (code_id, day). If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say the image encodes the short URL, never the destination.

Prompt 2. Save a destination, encode a short URL

Agents stamp https://flaviocopes.com/menu straight into the image. That code dies the day the path changes. The printed poster cannot be updated.

The mini-prompt on the card is “The redirect that outlives the print run”. Tonight you need the save half. The 302 comes in prompt 4.

Build the editor for Squarecode on
https://go.flaviocopes.com.

A new code needs a destination and a slug.
Slug is required, 3 to 40 characters,
lowercase letters, numbers, and hyphens.
Destination is required.

Validate destinations as http or https
only. Reject javascript: and data:.
Reject credentials in the URL. Reject a
destination whose host is
go.flaviocopes.com.

Never fetch the destination to see if it
loads. You are checking shape, not
liveness.

The image payload is always
https://go.flaviocopes.com/r/{slug}.
Never encode the destination.

Seed these codes:
- menu-spring -> https://flaviocopes.com/menu
- poster-js -> https://flaviocopes.com/javascript/

Rate-limit creation so a script cannot
mint thousands of slugs.

Do not add colors, a logo, or a download
yet. Do not add the public redirect yet.

Done when I can save menu-spring, the
preview payload is
https://go.flaviocopes.com/r/menu-spring,
javascript:alert(1) is refused, and a
second menu-spring collides.

Your short domain becomes an open redirect the moment the field accepts javascript: or a loop back at itself.

function normalizeDestination(raw, redirectHost) {
  const trimmed = raw.trim()
  const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`
  const url = new URL(withScheme)

  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error('Only http and https destinations are allowed')
  }

  if (url.username !== '' || url.password !== '') {
    throw new Error('Destinations cannot include credentials')
  }

  if (url.hostname === redirectHost) {
    throw new Error('Destination cannot point at the redirect host')
  }

  return url.href
}

normalizeDestination('flaviocopes.com/menu', 'go.flaviocopes.com')
//'https://flaviocopes.com/menu'

Never fetch the destination. Fetching turns the save path into an SSRF proxy.

function shortUrl(slug) {
  return `https://go.flaviocopes.com/r/${slug}`
}

shortUrl('menu-spring')
//'https://go.flaviocopes.com/r/menu-spring'

That string is what the encoder sees. The destination stays in the database.

What to check after the editor saves

Open /codes/new. Paste flaviocopes.com/menu. Pick slug menu-spring. Save. The list shows the short URL, not only the destination.

Try slug menu-spring again. The unique index refuses. Menu-Spring is the same slug after you lowercase it.

Paste javascript:alert(1). The form shows an error. No row.

Paste https://go.flaviocopes.com/r/poster-js as a destination. Refused. A loop on your own host is how a scan never leaves.

If the preview encodes https://flaviocopes.com/menu, send prompt 2 back before you print anything.

Prompt 3. Style the squares and download a PNG

Brand colors and a centered logo are why people pay for this. They are also how codes stop scanning.

QR error correction comes in four public levels: L, M, Q, and H. A logo covers modules. The rule on the page is to raise the level to H when a logo is present, and to leave the finder patterns untouched. Cap the logo so it stays a small fraction of the code. I would keep it under a fifth of the grid.

Scanners want dark modules on a light field. A pretty inverted scheme with high contrast still fails. Warn on that, not only on a low ratio.

Add styling and a PNG download for
Squarecode.

Foreground default #171713.
Background default #f4f1e8.
Logo is optional.

When a logo is present, set error
correction to H. Leave finder patterns
untouched. Cap the logo so it stays
under a fifth of the code area.

Warn when contrast is too low to scan.
Warn when the foreground is lighter
than the background, even if the ratio
looks fine.

Encode the short URL with a QR library.
Include a 4-module quiet zone in the
export so a print shop cannot crop it.

Tonight ship one print-ready PNG: 30mm
at 300 DPI. Never replace the source
file. Download menu-spring.png.

Do not add SVG, a size picker, or bulk
export yet. A live preview I can scan
with a phone is enough. The automatic
round-trip decoder is the 4-hour row.

Done when menu-spring.png is 354 pixels
on a side, the quiet zone is in the
file, a cream-on-ink invert shows a
warning, and the payload in the image
is still https://go.flaviocopes.com/r/menu-spring.

Relative luminance is the public WCAG formula. You need it for the warning, not for a design essay.

function channel(value) {
  const c = value / 255
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}

function luminance(hex) {
  const n = hex.replace('#', '')
  const r = Number.parseInt(n.slice(0, 2), 16)
  const g = Number.parseInt(n.slice(2, 4), 16)
  const b = Number.parseInt(n.slice(4, 6), 16)
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}

function contrastRatio(fg, bg) {
  const lighter = Math.max(luminance(fg), luminance(bg))
  const darker = Math.min(luminance(fg), luminance(bg))
  return (lighter + 0.05) / (darker + 0.05)
}

contrastRatio('#171713', '#f4f1e8')
//15.9

#171713 on #f4f1e8 is dark on light. Flip them and the ratio is the same. The invert still fails a camera. Check both.

function encodeSettings(hasLogo) {
  return {
    payload: 'https://go.flaviocopes.com/r/menu-spring',
    errorCorrection: hasLogo ? 'H' : 'M',
    logoMaxShare: 0.2,
  }
}

encodeSettings(true)
//{ payload: 'https://go.flaviocopes.com/r/menu-spring', errorCorrection: 'H', logoMaxShare: 0.2 }

A screen-sized PNG is not a print file. The export mini-prompt is “The export that speaks millimeters, not pixels”. Pixel width is size in millimeters times DPI, divided by 25.4. Round so each module lands on a whole number of pixels.

function pngPixels(sizeMm, dpi) {
  return Math.round((sizeMm * dpi) / 25.4)
}

pngPixels(30, 300)
//354

Ninety minutes can ship that one size. 30mm at 300 DPI is plenty for a menu. SVG and a size picker belong on the 4-hour row.

What to check after the PNG downloads

Save menu-spring with the default colors. Download menu-spring.png. The file is 354 by 354. Open it. You can see a light border around the grid.

Point your phone at the screen. The camera should offer https://go.flaviocopes.com/r/menu-spring. If it offers https://flaviocopes.com/menu, the encoder used the destination. Delete the PNG. Send prompt 3 back.

Set foreground to #f4f1e8 and background to #171713. A warning appears. The download can stay disabled, or stay enabled with the warning on the row. I would block the download. A bad print run is the expensive failure.

Add a small logo. Error correction becomes H. The finder squares in the corners stay empty of logo pixels.

Prompt 4. Redirect the scan and count the day

Use a 302 with short cache headers. A cached 301 makes edits invisible. The lookup has to be a single row read.

Fire the redirect first. Count the scan after the visitor is already gone. Disabled codes serve a polite this-code-is-off page. They do not increment anything.

Build the public redirect for Squarecode
on https://go.flaviocopes.com/r/:slug.

Lookup is one row by slug. Return 302
to the current destination with
Cache-Control: private, no-store.

Disabled or missing slugs go to a
this-code-is-off page. Do not forward
to the old destination.

After the 302 is on the wire, increment
scan_days for (code_id, UTC day)
atomically. Filter obvious bots and
link-preview crawlers by user agent
before counting.

Store date, code, and a count. No
cookies. No IP addresses. No precise
location. Device category can wait.

Editing a destination must change where
the next scan lands. Disabling
menu-spring must take effect on the
very next request.

Seed a walk:
- scan menu-spring, land on
  https://flaviocopes.com/menu
- change destination to
  https://flaviocopes.com/javascript/
- scan again, land on the new URL
- disable poster-js, scan, see the
  off page, count stays 0

Done when two concurrent scans on
menu-spring both land and the day
count is 2, a replayed 301 is
impossible because you sent 302, and
the database has no visitor rows.

The mini-prompt wants the lookup lean enough to answer in a few milliseconds.

async function handleScan(db, slug, requestedAt) {
  const code = await db.findBySlug(slug)
  if (!code || !code.enabled) {
    return { status: 302, location: '/code-off' }
  }

  queueIncrement(db, code.id, requestedAt)

  return {
    status: 302,
    location: code.destination,
    cacheControl: 'private, no-store',
  }
}

Increment atomically so two simultaneous scans from a launch-day poster do not lose a count.

function scanKey(codeId, requestedAt) {
  const day = requestedAt.toISOString().slice(0, 10)
  return `${codeId}:${day}`
}

scanKey('menu-spring', new Date('2026-10-25T18:04:00.000Z'))
//'menu-spring:2026-10-25'

The dashboard needs a number that moves. The 7- and 30-day totals can wait until you have a week of real scans. Show a zero-state that explains when data will appear, so an empty chart does not look broken.

What to check after a scan lands

Open https://go.flaviocopes.com/r/menu-spring from a second browser. You land on https://flaviocopes.com/menu. The dashboard count is 1.

Edit the destination to https://flaviocopes.com/javascript/. Scan again, or hit the URL again. You land on the JavaScript page. The printed payload did not change.

Disable poster-js. Hit /r/poster-js. You see the off page. scan_days for that code is still 0.

Open DevTools on the redirect. Status is 302. Cache-Control is private, no-store. If you see 301, stop. A print shop, a browser, or a crawler will cache the old menu.

Hit the URL twice in a row. The day count is 2. Open the database. One scan_days row, not two.

If the agent sets a cookie or stores an IP “for later analytics”, delete that column. The guardrail is the whole product.

Failures the agent ships anyway

Encoding the destination

The image is ink on paper, so it cannot change. If menu-spring.png contains https://flaviocopes.com/menu, the March poster dies in October. Encode the short URL. Edit the row.

A 301 that outlives the edit

A permanent redirect is convenient until you change the menu. 302 plus private, no-store is the rule from the mini-prompt. Confirm it in the Network panel, not in the README.

Fetching the destination on save

A HEAD or GET to “make sure the page loads” turns your app into a proxy. Validate the URL object. Do not open a socket.

Trusting contrast alone

Ink on cream scans. Cream on ink often does not, even when the ratio is 15.9. Check dark-on-light as its own rule.

Cropping the quiet zone

The ISO QR spec wants a 4-module light border. Designers crop it to make the mark larger. Bake the border into the PNG so the print shop cannot trim it off.

Counting inside the redirect write

If the increment fails, the visitor should still leave. Queue the count. Filter Slack, iMessage, and preview bots before you add one. The QR Tiger walkthrough covers the cookie-free counter.

If the agent starts a landing-page builder or a GPS field, paste the non-goal and delete it.

Deploy the short domain

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Squarecode wants Astro, Workers, and D1. Put the redirects on a subdomain you already own, go.flaviocopes.com. The Astro prompt points at Cloudflare D1 for the tables and Workers for the endpoints. The hosting post is that path: the adapter, then the bindings, then the custom domain.

Attach the domain on the Worker. The short URL is the product, so go.flaviocopes.com/r/menu-spring has to resolve on the public internet, not only on localhost. A phone on cellular has to reach it. A poster that only works on your Wi-Fi is a demo.

Ship with the two seed codes. Print menu-spring.png or tape a phone screenshot to the fridge. Scan. Edit the destination. Scan again. Confirm the off page on poster-js.

Take a D1 backup before you delete the paid account.

Use it for a week as the real generator, not as a demo. If you miss SVG or bulk, those are the 4-hour row and the other mini-prompts on the card. Add them after you print something.

Then cancel QR Tiger, or Flowcode, or Uniqode, whichever one you pay for.

What to skip on purpose

Leave out landing pages, GPS tracking, team workspaces, white-label domains, and a public API.

90-MINUTE V1 Styled codes you can scan Short URL you can edit Daily scan counts SKIP THIS HALF Landing page builder GPS tracking Public API and workspaces
Ship the 90-minute generator. Leave the campaign platform on the skip row.

SVG export is the honest next feature, not a v1 requirement. Same code, one path for the dark modules, logo inlined. Bulk creation is a CSV of destinations through the same normalizeDestination function. UTM tags can live on the destination string. Expiry is an ends_on day and the off page.

A tweet-sized short link is still Bitly, $35–$300 a month, about 90 minutes. The link shortener guide is that walk. Bitly’s 5-hour row already includes a QR download. Build the squares if the poster is the rent.

A bio page of links is Linktree, $9–$24 a month, about 30 minutes. The Linktree guide is five buttons. Put one of those URLs behind the QR if you want both.

If the agent starts a page builder, stop it. A landing page is Carrd, $2–$4 a month, about 2 hours. The Carrd walkthrough is that post. Point the QR at a page you already host.

A drop zone that shrinks a photo for the poster is the TinyPNG guide. The file stays in the tab.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own QR Tiger. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: styled codes, editable redirects, daily scan counts. Ninety minutes.

Publish go.flaviocopes.com/r/menu-spring. Download the PNG. Point one real poster at it tonight.