From 4facb8d8bcfa95b68c3759a600e60b5304bf007f Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Mon, 20 Jul 2026 19:13:22 -0300 Subject: [PATCH 1/3] qr api return svg format --- apps/web/app/api/qr/route.tsx | 72 +++++++++++++++++------------ apps/web/lib/openapi/qr/index.ts | 3 ++ apps/web/lib/qr/api.tsx | 77 +++++++++++++++----------------- apps/web/lib/qr/index.tsx | 2 +- apps/web/lib/zod/schemas/qr.ts | 7 ++- 5 files changed, 90 insertions(+), 71 deletions(-) diff --git a/apps/web/app/api/qr/route.tsx b/apps/web/app/api/qr/route.tsx index 60d9d771380..77b47e5199a 100644 --- a/apps/web/app/api/qr/route.tsx +++ b/apps/web/app/api/qr/route.tsx @@ -2,6 +2,7 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { ratelimitOrThrow } from "@/lib/api/utils"; import { getShortLinkViaEdge, getWorkspaceViaEdge } from "@/lib/planetscale"; import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge"; +import { getQRAsSVG } from "@/lib/qr/api"; import { QRCodeSVG } from "@/lib/qr/utils"; import { getQRCodeQuerySchema } from "@/lib/zod/schemas/qr"; import { DUB_QR_LOGO, getSearchParams, isDubDomain } from "@dub/utils"; @@ -21,37 +22,52 @@ export async function GET(req: NextRequest) { await ratelimitOrThrow(req, "qr"); - const { logo, url, size, level, fgColor, bgColor, margin, hideLogo } = - paramsParsed; + const { + logo, + url, + size, + level, + fgColor, + bgColor, + margin, + hideLogo, + format, + } = paramsParsed; const qrCodeLogo = await getQRCodeLogo({ url, logo, hideLogo }); - return new ImageResponse( - QRCodeSVG({ - value: url, - size, - level, - fgColor, - bgColor, - margin, - ...(qrCodeLogo - ? { - imageSettings: { - src: qrCodeLogo, - height: size / 4, - width: size / 4, - excavate: true, - }, - } - : {}), - isOGContext: true, - }), - { - width: size, - height: size, - headers: CORS_HEADERS, - }, - ); + const qrProps = { + value: url, + size, + level, + fgColor, + bgColor, + margin, + ...(qrCodeLogo + ? { + imageSettings: { + src: qrCodeLogo, + height: size / 4, + width: size / 4, + excavate: true, + }, + } + : {}), + }; + + if (format === "svg") { + const svgString = await getQRAsSVG(qrProps); + const headers = new Headers(CORS_HEADERS); + headers.set("Content-Type", "image/svg+xml"); + headers.set("Content-Disposition", "inline; filename=qr.svg"); + return new Response(svgString, { headers }); + } + + return new ImageResponse(QRCodeSVG({ ...qrProps, isOGContext: true }), { + width: size, + height: size, + headers: CORS_HEADERS, + }); } catch (error) { return handleAndReturnErrorResponse(error, CORS_HEADERS); } diff --git a/apps/web/lib/openapi/qr/index.ts b/apps/web/lib/openapi/qr/index.ts index 30134c09911..65de0a016a5 100644 --- a/apps/web/lib/openapi/qr/index.ts +++ b/apps/web/lib/openapi/qr/index.ts @@ -19,6 +19,9 @@ export const getQRCode: ZodOpenApiOperationObject = { "image/png": { schema: z.string(), }, + "image/svg+xml": { + schema: z.string(), + }, }, }, ...openApiErrorResponses, diff --git a/apps/web/lib/qr/api.tsx b/apps/web/lib/qr/api.tsx index 0d3312f182f..9e3c81cf183 100644 --- a/apps/web/lib/qr/api.tsx +++ b/apps/web/lib/qr/api.tsx @@ -1,6 +1,5 @@ import qrcodegen from "./codegen"; import { - DEFAULT_BGCOLOR, DEFAULT_FGCOLOR, DEFAULT_LEVEL, DEFAULT_MARGIN, @@ -10,21 +9,24 @@ import { import { QRPropsSVG } from "./types"; import { excavateModules, generatePath, getImageSettings } from "./utils"; -export async function getQRAsSVG(props: QRPropsSVG) { +export async function getQRAsSVG(props: QRPropsSVG): Promise { const { value, size = DEFAULT_SIZE, level = DEFAULT_LEVEL, - bgColor = DEFAULT_BGCOLOR, fgColor = DEFAULT_FGCOLOR, margin = DEFAULT_MARGIN, imageSettings, - ...otherProps } = props; + const shouldUseHigherErrorLevel = + imageSettings?.excavate && (level === "L" || level === "M"); + + const effectiveLevel = shouldUseHigherErrorLevel ? "Q" : level; + let cells = qrcodegen.QrCode.encodeText( value, - ERROR_LEVEL_MAP[level], + ERROR_LEVEL_MAP[effectiveLevel], ).getModules(); const numCells = cells.length + margin * 2; @@ -35,51 +37,44 @@ export async function getQRAsSVG(props: QRPropsSVG) { imageSettings, ); - let image = <>; + let image = ""; if (imageSettings != null && calculatedImageSettings != null) { if (calculatedImageSettings.excavation != null) { cells = excavateModules(cells, calculatedImageSettings.excavation); } - const base64Image = await fetch( - `https://wsrv.nl/?url=${imageSettings.src}&w=100&h=100&encoding=base64`, - ).then((res) => res.text()); + try { + const base64Image = await fetch( + `https://wsrv.nl/?url=${encodeURIComponent(imageSettings.src)}&w=100&h=100&encoding=base64`, + ).then((res) => { + if (!res.ok) { + throw new Error(`Failed to fetch logo: ${res.status}`); + } + return res.text(); + }); - image = ( - - ); + image = [ + `', + ].join(" "); + } catch { + // Omit logo if embedding fails; still return a valid QR SVG. + } } // Drawing strategy: instead of a rect per module, we're going to create a - // single path for the dark modules and layer that on top of a light rect, - // for a total of 2 DOM nodes. We pay a bit more in string concat but that's - // way faster than DOM ops. - // For level 1, 441 nodes -> 2 - // For level 40, 31329 -> 2 + // single path for the dark modules. Background is omitted so SVGs are + // transparent (bgColor applies to PNG only). const fgPath = generatePath(cells, margin); - return ( - - - - {image} - - ); + return [ + ``, + ``, + image, + "", + ].join(""); } diff --git a/apps/web/lib/qr/index.tsx b/apps/web/lib/qr/index.tsx index 9b753ed749f..e2c3af9680a 100644 --- a/apps/web/lib/qr/index.tsx +++ b/apps/web/lib/qr/index.tsx @@ -386,9 +386,9 @@ export async function getQRAsSVGDataUri(props: QRProps) { ) .join(""); + // Omit the background fill so downloaded SVGs have a transparent background. const svgData = [ ``, - ``, ``, finderSVG, image, diff --git a/apps/web/lib/zod/schemas/qr.ts b/apps/web/lib/zod/schemas/qr.ts index 1e8180080dc..04426845e55 100644 --- a/apps/web/lib/zod/schemas/qr.ts +++ b/apps/web/lib/zod/schemas/qr.ts @@ -42,7 +42,7 @@ export const getQRCodeQuerySchema = z.object({ .optional() .default(DEFAULT_BGCOLOR) .describe( - "The background color of the QR code in hex format. Defaults to `#ffffff` if not provided.", + "The background color of the QR code in hex format. Defaults to `#ffffff` if not provided. Only applies to `png` format; `svg` responses have a transparent background.", ), hideLogo: booleanQuerySchema .optional() @@ -64,4 +64,9 @@ export const getQRCodeQuerySchema = z.object({ "DEPRECATED: Margin is included by default. Use the `margin` prop to customize the margin size.", ) .meta({ deprecated: true }), + format: z + .enum(["png", "svg"]) + .optional() + .default("png") + .describe("The format of the QR code. Defaults to `png` if not provided."), }); From e38a9cff9f1af433e2b2b29c37c1224999a683c2 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Tue, 21 Jul 2026 16:26:20 -0300 Subject: [PATCH 2/3] svg bg when set --- apps/web/app/api/qr/route.tsx | 24 +++++++++++++++++------- apps/web/lib/qr/api.tsx | 9 +++++++-- apps/web/lib/zod/schemas/qr.ts | 10 ++-------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/web/app/api/qr/route.tsx b/apps/web/app/api/qr/route.tsx index 77b47e5199a..2e9bc088a6c 100644 --- a/apps/web/app/api/qr/route.tsx +++ b/apps/web/app/api/qr/route.tsx @@ -3,6 +3,7 @@ import { ratelimitOrThrow } from "@/lib/api/utils"; import { getShortLinkViaEdge, getWorkspaceViaEdge } from "@/lib/planetscale"; import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge"; import { getQRAsSVG } from "@/lib/qr/api"; +import { DEFAULT_BGCOLOR } from "@/lib/qr/constants"; import { QRCodeSVG } from "@/lib/qr/utils"; import { getQRCodeQuerySchema } from "@/lib/zod/schemas/qr"; import { DUB_QR_LOGO, getSearchParams, isDubDomain } from "@dub/utils"; @@ -41,7 +42,6 @@ export async function GET(req: NextRequest) { size, level, fgColor, - bgColor, margin, ...(qrCodeLogo ? { @@ -56,18 +56,28 @@ export async function GET(req: NextRequest) { }; if (format === "svg") { - const svgString = await getQRAsSVG(qrProps); + const svgString = await getQRAsSVG({ + ...qrProps, + ...(bgColor ? { bgColor } : {}), + }); const headers = new Headers(CORS_HEADERS); headers.set("Content-Type", "image/svg+xml"); headers.set("Content-Disposition", "inline; filename=qr.svg"); return new Response(svgString, { headers }); } - return new ImageResponse(QRCodeSVG({ ...qrProps, isOGContext: true }), { - width: size, - height: size, - headers: CORS_HEADERS, - }); + return new ImageResponse( + QRCodeSVG({ + ...qrProps, + bgColor: bgColor ?? DEFAULT_BGCOLOR, + isOGContext: true, + }), + { + width: size, + height: size, + headers: CORS_HEADERS, + }, + ); } catch (error) { return handleAndReturnErrorResponse(error, CORS_HEADERS); } diff --git a/apps/web/lib/qr/api.tsx b/apps/web/lib/qr/api.tsx index 9e3c81cf183..e43468f36ba 100644 --- a/apps/web/lib/qr/api.tsx +++ b/apps/web/lib/qr/api.tsx @@ -14,6 +14,7 @@ export async function getQRAsSVG(props: QRPropsSVG): Promise { value, size = DEFAULT_SIZE, level = DEFAULT_LEVEL, + bgColor, fgColor = DEFAULT_FGCOLOR, margin = DEFAULT_MARGIN, imageSettings, @@ -67,12 +68,16 @@ export async function getQRAsSVG(props: QRPropsSVG): Promise { } // Drawing strategy: instead of a rect per module, we're going to create a - // single path for the dark modules. Background is omitted so SVGs are - // transparent (bgColor applies to PNG only). + // single path for the dark modules. Background is only painted when bgColor + // is set — otherwise the SVG stays transparent. const fgPath = generatePath(cells, margin); + const bgPath = bgColor + ? `` + : ""; return [ ``, + bgPath, ``, image, "", diff --git a/apps/web/lib/zod/schemas/qr.ts b/apps/web/lib/zod/schemas/qr.ts index 04426845e55..7f21827cd1d 100644 --- a/apps/web/lib/zod/schemas/qr.ts +++ b/apps/web/lib/zod/schemas/qr.ts @@ -1,9 +1,4 @@ -import { - DEFAULT_BGCOLOR, - DEFAULT_FGCOLOR, - DEFAULT_MARGIN, - QR_LEVELS, -} from "@/lib/qr/constants"; +import { DEFAULT_FGCOLOR, DEFAULT_MARGIN, QR_LEVELS } from "@/lib/qr/constants"; import * as z from "zod/v4"; import { booleanQuerySchema } from "./misc"; import { parseUrlSchema } from "./utils"; @@ -40,9 +35,8 @@ export const getQRCodeQuerySchema = z.object({ bgColor: z .string() .optional() - .default(DEFAULT_BGCOLOR) .describe( - "The background color of the QR code in hex format. Defaults to `#ffffff` if not provided. Only applies to `png` format; `svg` responses have a transparent background.", + "The background color of the QR code in hex format. Defaults to `#ffffff` for `png`. When omitted for `svg`, the background is transparent.", ), hideLogo: booleanQuerySchema .optional() From 8ef8fb7880b0408162ebe803b4740cb21f758158 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Tue, 21 Jul 2026 16:52:27 -0300 Subject: [PATCH 3/3] code improvements --- apps/web/lib/qr/api.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/qr/api.tsx b/apps/web/lib/qr/api.tsx index e43468f36ba..04f60c47419 100644 --- a/apps/web/lib/qr/api.tsx +++ b/apps/web/lib/qr/api.tsx @@ -40,13 +40,10 @@ export async function getQRAsSVG(props: QRPropsSVG): Promise { let image = ""; if (imageSettings != null && calculatedImageSettings != null) { - if (calculatedImageSettings.excavation != null) { - cells = excavateModules(cells, calculatedImageSettings.excavation); - } - try { const base64Image = await fetch( `https://wsrv.nl/?url=${encodeURIComponent(imageSettings.src)}&w=100&h=100&encoding=base64`, + { signal: AbortSignal.timeout(5_000) }, ).then((res) => { if (!res.ok) { throw new Error(`Failed to fetch logo: ${res.status}`); @@ -54,6 +51,10 @@ export async function getQRAsSVG(props: QRPropsSVG): Promise { return res.text(); }); + if (calculatedImageSettings.excavation != null) { + cells = excavateModules(cells, calculatedImageSettings.excavation); + } + image = [ `