CentercodePorygon
DittoPorygonAPI

Image Processing

SVG to PNG conversion for email compatibility

FastReliable

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

Related

Background Jobs

Inngest job patterns

File Storage

Vercel Blob uploads

Email

Email shell rendering

External Documentation

@resvg/resvg-jsVercel Blob