Developers
How to generate QR codes in React
By Sam Moreton · updated 30 June 2026
Generating a QR code in React looks trivial until you hit the one thing most tutorials get wrong: your API key cannot live in the browser. A QR API call needs a bearer secret, and anything in client-side React — including NEXT_PUBLIC_ env vars — is shipped to every visitor and trivially readable in DevTools. This guide shows the correct architecture: React calls your backend, your backend calls the OpenQR API with the key, and the image flows back. We will also be honest about when you should skip the API entirely and render in the browser with a local library.
9 min read · Updated 30 June 2026
First decide: client-side library or server-side API?
Before any code, pick the right tool. There are two legitimate ways to put a QR code on a React page, and they solve different problems.
- A client-side library (e.g.
qrcode.reactorqrcode) renders the code entirely in the browser, in JavaScript, with no network call and no account. Perfect for purely static codes — a link, a wifi string, a vCard — that you do not need to track or change later. - A server-side API like OpenQR renders the image on a server and hands it back. Reach for it when you want consistent output across every platform you run, SVG or PNG on demand, no QR library in your bundle, or dynamic, trackable codes you can repoint after printing.
If a static in-browser code is genuinely all you need, use a library
qrcode.react is a fine choice for a static code with no tracking and no account — it renders in the browser with zero backend and no key to protect. There is no shame in it. The OpenQR API earns its place when you want server-side generation, identical output across services, codes you can edit or measure after printing, or simply to keep a QR library out of your client bundle.
The rest of this guide assumes you have chosen the API — because that is where the security nuance matters, and where most React tutorials quietly leak a secret.
The one rule: the API key never reaches the browser
The OpenQR API authenticates with a bearer key that looks like oqr_…. A bearer key is a password: anyone who has it can call the API as you. React runs in the user's browser, so any value your React code can read, the user can read too — in the bundled JS, in the network tab, in the React DevTools. There is no "hide it in a variable" that works.
Never put your oqr_ key in client-side React
Do not call https://openqr.uk/v1/qr directly from a React component, and do NOT store the key in a NEXT_PUBLIC_ (or VITE_/REACT_APP_) env var. Those prefixes deliberately inline the value into the client bundle — it ships to every visitor and is one DevTools tab away from being copied. The key must only ever exist on a server you control. The correct pattern is below: React talks to your backend, and only your backend holds the key.
This is not OpenQR-specific paranoia — it is true of every API that uses a secret key (payment providers, mail APIs, everything). The fix is always the same: proxy the call through your own backend.
The correct architecture
Put a thin server route between React and OpenQR. The browser only ever talks to your own origin; the key stays on the server.
Browser (React) Your server OpenQR
<img src="/api/qr?data=…" > ──▶ GET /api/qr ──▶ GET /v1/qr
+ Authorization: (renders image)
Bearer oqr_KEY
image bytes ◀────────────── image bytes ◀────────── image/svg+xml
(key never leaves here)- React renders
<img src="/api/qr?data=…">— a relative path on your own site. It never sees the key. - Your backend route reads the key from a server-only env var, calls
https://openqr.uk/v1/qr, and streams the image straight back. - OpenQR only ever talks to your server, never to the browser.
If you are on Next.js, the backend is a Route Handler. On a Vite/CRA single-page app with no backend, you still need a tiny server — a serverless function, an Express endpoint, a Cloudflare Worker — to hold the key. There is no secure client-only way to call a keyed API.
Get a free API key
Go to openqr.uk/api, sign in with a magic link (enter your email, click the link — no password), then create a key in the dashboard. It is shown once and stored only as a SHA-256 hash on our side, so copy it somewhere safe. Keys look like oqr_… and travel as a bearer token — server-side only, as covered above.
Store the key in a server-only environment variable
Read the key from process.env on the server. The critical detail: do not prefix it with NEXT_PUBLIC_. In Next.js, only variables with that prefix are exposed to the browser; an un-prefixed variable stays on the server, which is exactly what you want.
# .env.local — note: NO NEXT_PUBLIC_ prefix. This stays server-side.
OPENQR_KEY=oqr_YOUR_API_KEY
# WRONG — this would ship the key to every browser:
# NEXT_PUBLIC_OPENQR_KEY=oqr_YOUR_API_KEYHow to tell if a variable is exposed
Rule of thumb for Next.js: NEXT_PUBLIC_* is public, everything else is server-only. For Vite it is the reverse default — VITE_* is public. For Create React App, REACT_APP_* is public. If your secret carries any of those public prefixes, it is in the client bundle. Keep the OpenQR key on a name with no public prefix and only read it inside server code.
The Next.js Route Handler (your backend proxy)
Create app/api/qr/route.ts. It reads data from the query string, calls /v1/qr with the key from process.env, and streams the raw image bytes straight back with the right content type. Route Handlers only ever run on the server, so the key is safe here.
// app/api/qr/route.ts
import { NextRequest } from "next/server";
export async function GET(req: NextRequest) {
const data = req.nextUrl.searchParams.get("data");
if (!data) {
return new Response("Missing 'data'", { status: 400 });
}
const key = process.env.OPENQR_KEY;
if (!key) {
return new Response("Server is not configured", { status: 500 });
}
const url = new URL("https://openqr.uk/v1/qr");
url.searchParams.set("data", data);
url.searchParams.set("format", "png");
url.searchParams.set("size", "512");
const upstream = await fetch(url, {
headers: { Authorization: `Bearer ${key}` }, // key stays on the server
});
if (!upstream.ok) {
return new Response("Could not render QR", { status: 502 });
}
// Stream the raw image bytes back to the browser, unchanged.
return new Response(upstream.body, {
headers: {
"Content-Type": upstream.headers.get("Content-Type") ?? "image/png",
// Deterministic image — let the browser and CDN cache it.
"Cache-Control": "public, max-age=86400, immutable",
},
});
}Validate and constrain what you proxy
Your route is now a public endpoint, so treat it like one. The data parameter has a 2000-character limit at OpenQR; reject oversized or empty input early, and if you only ever encode your own URLs, build the data string on the server rather than trusting whatever the client sends. A small allow-list keeps people from using your key to render arbitrary content.
The React component
Now React just points an <img> at your own route. No key, no OpenQR URL, no fetch logic in the component for the simple case — the browser fetches the image from your origin and your server does the keyed call.
// components/QrImage.tsx
export function QrImage({ data, size = 256 }: { data: string; size?: number }) {
// Points at YOUR route, not openqr.uk. The key lives only on the server.
const src = `/api/qr?data=${encodeURIComponent(data)}`;
return (
<img
src={src}
width={size}
height={size}
alt={`QR code for ${data}`}
loading="lazy"
/>
);
}
// Usage:
// <QrImage data="https://example.com" size={256} />That is the whole happy path. The <img> approach is the simplest and gets you free browser caching, lazy loading and no client-side QR library. For anything more interactive — a live preview that updates as the user types, or a download button — you will want explicit loading and error states, below.
Loading and error states
A bare <img> only exposes onLoad and onError, which is enough for a placeholder and a fallback. Track those two events to show a skeleton while the image loads and a message if your route returns a non-200.
// components/QrPreview.tsx
"use client";
import { useState } from "react";
export function QrPreview({ data }: { data: string }) {
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const src = `/api/qr?data=${encodeURIComponent(data)}`;
return (
<div style={{ width: 256, height: 256 }}>
{status === "loading" && <div className="skeleton">Generating…</div>}
{status === "error" && <div className="error">Couldn't render that code.</div>}
<img
key={src} // reset state when the data changes
src={src}
width={256}
height={256}
alt={`QR code for ${data}`}
hidden={status !== "ready"}
onLoad={() => setStatus("ready")}
onError={() => setStatus("error")}
/>
</div>
);
}If you need richer error detail — the actual message from a 400 or 401 — fetch the route with fetch() instead of an <img>, inspect the status, and turn the response into an object URL. The next section shows that pattern for downloads.
Fetching the bytes (downloads and blobs)
When you want a "Download QR" button, fetch your route, read the response as a Blob, and create an object URL. This still goes through your server route — the component never touches OpenQR or the key.
// components/QrDownload.tsx
"use client";
import { useState } from "react";
export function QrDownload({ data }: { data: string }) {
const [busy, setBusy] = useState(false);
async function download() {
setBusy(true);
try {
const res = await fetch(`/api/qr?data=${encodeURIComponent(data)}`);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "qr.png";
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error(err);
alert("Could not download the QR code.");
} finally {
setBusy(false);
}
}
return (
<button onClick={download} disabled={busy}>
{busy ? "Preparing…" : "Download QR"}
</button>
);
}Rendering on the server (SVG, no extra request)
In an App Router server component you can fetch the code at render time and inline the SVG directly into the HTML — no separate /api/qr round-trip from the browser, and still no key in the client. Ask for format=svg and you get back raw <svg> markup, which you can drop straight into the page.
// app/ticket/[id]/page.tsx — a Server Component
async function renderQr(data: string): Promise<string> {
const url = new URL("https://openqr.uk/v1/qr");
url.searchParams.set("data", data);
url.searchParams.set("format", "svg");
url.searchParams.set("size", "240");
url.searchParams.set("margin", "2");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.OPENQR_KEY!}` },
next: { revalidate: 86400 }, // cache the deterministic image
});
if (!res.ok) throw new Error("QR render failed");
return res.text(); // raw <svg> markup
}
export default async function TicketPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const svg = await renderQr(`https://example.com/t/${id}`);
return (
<figure style={{ width: 240 }}>
{/* We trust our own API's SVG output. */}
<div dangerouslySetInnerHTML={{ __html: svg }} />
<figcaption>Scan to check in</figcaption>
</figure>
);
}SVG vs PNG, and the colour parameters
SVG is resolution-independent — sharp at any print size, tiny over the wire, and inlinable with no second request, so it is the better default when you control the page. PNG is right when a tool only accepts raster. The dark and light colour parameters (hex, no #) apply to SVG output; size is the pixel size for PNG and the viewBox for SVG.
The /v1/qr parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
| data | string (required) | — | Text or URL to encode. Max 2000 characters. |
| format | svg | png | svg | SVG is vector; PNG is raster. |
| size | integer | 512 | 64–2048. Pixel size for PNG, viewBox for SVG. |
| margin | integer | 4 | 0–16. The quiet zone in modules around the code. |
| dark | hex | — | Foreground colour, e.g. 232E3A (no #). SVG only. |
| light | hex | — | Background colour, e.g. FFFFFF (no #). SVG only. |
| theme | string | — | A saved theme's id or name. Applies the theme's colours and margin. |
The endpoint takes GET (params in the query string) or POST (params in a JSON body) and returns the rendered image directly — raw image/svg+xml or image/png bytes, not JSON. Use POST from your route when the data is long or awkward to URL-encode. On failure you get a JSON body like { "error": "…" } with a 400 or 401 status, which is why the route checks upstream.ok before forwarding.
Static vs dynamic codes
Everything above produces static codes
The /v1/qr endpoint encodes your data directly into the image, so the code never expires and needs no server to resolve — but you cannot change where it points after printing, and there are no scan analytics. If you need an editable, trackable code, that is a dynamic code created via a different endpoint.
When you need to repoint a printed code or read scan counts, reach for dynamic codes — the same server-route pattern applies, your backend just calls a different endpoint. For the full API tour see getting started with the QR code API, and if your stack is TypeScript end to end, the typed OpenQR SDK wraps these calls (server-side) so you skip the manual fetch.