Overview
Convert SVG images to PNG format for email compatibility. Most email clients do not support SVG, so logos are automatically converted to PNG when uploaded. The conversion runs as an Inngest background job to avoid blocking the UI.
What it is
Server-side SVG to PNG conversion using @resvg/resvg-js, a fast Rust-based renderer compiled to WebAssembly.
Why we use it
Email clients (Gmail, Outlook, Apple Mail) do not render SVG. PNG fallbacks ensure logos display correctly in all email contexts.
When to use
When uploading SVG logos that will be used in emails, or any image that needs a rasterized fallback for compatibility.
Key Features
- Non-blocking: UI returns immediately while conversion runs in background
- Automatic retries: Inngest handles failures with 3 retry attempts
- Dual storage: SVG for web (crisp), PNG for email (compatible)
- Configurable width: Default 400px, suitable for email headers
Quick Start
Triggering Conversion
Queue a conversion job from any component.
// Queue SVG to PNG conversion from UI
import { queueLogoConversionApi } from '@/lib/images/api';
// Fire-and-forget - returns immediately
if (isSvgUrl(logoUrl) && programId) {
queueLogoConversionApi({
programId,
svgUrl: logoUrl,
field: 'logoLight', // or 'logoDark'
});
}The job updates the database directly. No need to handle the response - just refresh data after a few seconds if needed.
Patterns
Conversion Utility
Server-only utility using @resvg/resvg-js.
// src/lib/images/svg-to-png.ts
import 'server-only';
import { Resvg } from '@resvg/resvg-js';
export async function convertSvgUrlToPng(
svgUrl: string,
options: { width?: number } = {}
): Promise<Buffer> {
const { width = 400 } = options;
// Fetch SVG content
const response = await fetch(svgUrl);
const svgBuffer = Buffer.from(await response.arrayBuffer());
// Render to PNG using resvg
const resvg = new Resvg(svgBuffer, {
fitTo: { mode: 'width', value: width },
});
const rendered = resvg.render();
return Buffer.from(rendered.asPng());
}Inngest Job
Background job with step-based execution.
// src/lib/jobs/functions/convert-logo-to-png.ts
import { put } from '@vercel/blob';
import { defineJob } from '@/lib/jobs';
import { withRLSBypass } from '@/lib/db';
import { convertSvgUrlToPng } from '@/lib/images';
export const convertLogoToPng = defineJob(
{ id: 'convert-logo-to-png', retries: 3 },
{ event: 'images/logo.convert' },
async ({ event, step }) => {
const { programId, svgUrl, field } = event.data;
// Step 1: Convert SVG to PNG
const pngBuffer = await step.run('convert', async () => {
const buffer = await convertSvgUrlToPng(svgUrl, { width: 400 });
return buffer.toString('base64');
});
// Step 2: Upload PNG to blob storage
const pngUrl = await step.run('upload', async () => {
const buffer = Buffer.from(pngBuffer, 'base64');
const blob = await put('design/logos/logo.png', buffer, {
access: 'public',
addRandomSuffix: true,
contentType: 'image/png',
});
return blob.url;
});
// Step 3: Update database with PNG URL
await step.run('save', async () => {
await withRLSBypass('inngest:convert-logo-to-png', async () => {
await updateProgramDesign(programId, {
[field === 'logoLight' ? 'logoLightPng' : 'logoDarkPng']: pngUrl,
});
});
});
return { success: true, pngUrl };
}
);API Route
API endpoint to queue the conversion job.
// src/app/api/v1/images/convert-logo/route.ts
import { NextResponse } from 'next/server';
import { withApiLogging } from '@/lib/api-logging';
import { withAuth } from '@/lib/api-auth';
import { validateRequest } from '@/lib/validation';
import { dispatch } from '@/lib/jobs';
const convertLogoSchema = z.object({
programId: z.string().uuid(),
svgUrl: z.string().url(),
field: z.enum(['logoLight', 'logoDark']),
});
export const POST = withApiLogging(
withAuth({ scope: 'program' }, async (request) => {
const validation = await validateRequest(request, convertLogoSchema);
if (!validation.success) return validation.error;
// Queue background job - returns immediately
await dispatch({
name: 'images/logo.convert',
data: validation.data,
});
return NextResponse.json({ ok: true, data: { queued: true } });
})
);Client API
Client-side wrapper for components.
// src/lib/images/api.ts - Client-side API wrapper
interface ApiResult<T> {
success: boolean;
data?: T;
error?: string;
}
export async function queueLogoConversionApi(input: {
programId: string;
svgUrl: string;
field: 'logoLight' | 'logoDark';
}): Promise<ApiResult<{ queued: boolean }>> {
const response = await fetch('/api/v1/images/convert-logo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const result = await response.json();
return result.ok
? { success: true, data: result.data }
: { success: false, error: result.error?.message };
}
export function isSvgUrl(url: string): boolean {
const lowerUrl = url.toLowerCase();
return lowerUrl.includes('.svg') || lowerUrl.includes('image/svg');
}Next.js Config
Required config for native Rust module.
// next.config.ts - REQUIRED for native modules
const nextConfig: NextConfig = {
// Tell Next.js to NOT bundle native modules
serverExternalPackages: [
'pino',
'pino-pretty',
'@resvg/resvg-js', // Native Rust binding
],
};Without serverExternalPackages:
Turbopack will try to bundle the native module and fail with: "could not resolve @resvg/resvg-js-linux-x64-gnu"
Watch Out
Blocking UI during conversion
Don't
// Blocking the UI during conversion
const handleUpload = async (file: File) => {
const svgUrl = await uploadFile(file);
// This blocks for 2-5 seconds!
const pngUrl = await convertSvgToPng(svgUrl);
setBranding({ logoLight: svgUrl, logoLightPng: pngUrl });
};Do
// Non-blocking with background job
const handleUpload = async (file: File) => {
const svgUrl = await uploadFile(file);
// Save SVG immediately - UI unblocked
setBranding({ logoLight: svgUrl });
// Queue conversion in background
if (programId) {
queueLogoConversionApi({ programId, svgUrl, field: 'logoLight' });
}
// PNG will be saved to DB when job completes
};- Missing serverExternalPackages config (native module fails to load)
- Importing @resvg/resvg-js in client code (Node.js only)
- Not checking if PNG exists before using in emails
- Awaiting conversion before returning UI response