DEV Community

gleamso
gleamso

Posted on

Building an OpenGraph Image API with Next.js and Sharp

In today's tutorial, I will guide you through creating a production-ready OpenGraph image API using Next.js and Sharp. This implementation draws from my experience building Gleam.so's image generation system, which now handles thousands of requests daily.

Setting Up the API Foundation

Let's begin with the basic API route structure in Next.js 14:

// app/api/og/route.ts
import { NextResponse } from 'next/server';
import sharp from 'sharp';
import { ImageProcessor } from '../../../lib/image-processor';

export const runtime = 'edge';

export async function GET(request: Request) {
  try {
    const { searchParams } = new URL(request.url);
    const title = searchParams.get('title');
    const theme = searchParams.get('theme') || 'light';

    if (!title) {
      return new NextResponse('Missing title parameter', { 
        status: 400 
      });
    }

    const image = await generateOGImage({
      title,
      theme
    });

    return new NextResponse(image, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=31536000, immutable'
      }
    });
  } catch (error) {
    console.error('OG Generation failed:', error);
    return new NextResponse('Image generation failed', { 
      status: 500 
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing Image Processing

The image processing logic requires careful consideration of performance and quality. Here's how we can implement it:

// lib/image-processor.ts
import sharp from 'sharp';

export class ImageProcessor {
  private readonly width = 1200;
  private readonly height = 630;
  private readonly padding = 60;

  async generateOGImage(options: GenerationOptions): Promise<Buffer> {
    const {
      title,
      theme = 'light',
      template = 'default'
    } = options;

    // Create base image
    const svg = await this.generateSVG({
      title,
      theme,
      template
    });

    // Convert SVG to PNG with optimizations
    return sharp(Buffer.from(svg))
      .resize(this.width, this.height)
      .png({
        compressionLevel: 9,
        quality: 80
      })
      .toBuffer();
  }

  private async generateSVG(options: SVGOptions): Promise<string> {
    const { title, theme } = options;
    const backgroundColor = theme === 'light' ? '#ffffff' : '#1a1a1a';
    const textColor = theme === 'light' ? '#000000' : '#ffffff';

    return `
      <svg
        width="${this.width}"
        height="${this.height}"
        viewBox="0 0 ${this.width} ${this.height}"
        xmlns="http://www.w3.org/2000/svg"
      >
        <rect width="100%" height="100%" fill="${backgroundColor}"/>
        <text
          x="${this.padding}"
          y="${this.height / 2}"
          font-family="Inter"
          font-size="64"
          font-weight="bold"
          fill="${textColor}"
        >
          ${this.escapeHTML(title)}
        </text>
      </svg>
    `;
  }

  private escapeHTML(text: string): string {
    return text
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing Error Handling

Robust error handling is crucial for a production API. Here's a comprehensive approach:

// lib/error-handler.ts
export class ErrorHandler {
  async handleError(error: Error, context: ErrorContext): Promise<Buffer> {
    // Log error with context
    console.error('Image generation failed:', {
      error,
      context,
      timestamp: new Date().toISOString()
    });

    // Generate fallback image
    return this.generateFallbackImage(context);
  }

  private async generateFallbackImage(context: ErrorContext): Promise<Buffer> {
    // Create a simple fallback image
    const fallbackSVG = `
      <svg width="1200" height="630">
        <rect width="100%" height="100%" fill="#f8f9fa"/>
        <text
          x="50%"
          y="50%"
          text-anchor="middle"
          font-family="system-ui"
          font-size="48"
          fill="#343a40"
        >
          ${context.title || 'Image Unavailable'}
        </text>
      </svg>
    `;

    return sharp(Buffer.from(fallbackSVG))
      .png()
      .toBuffer();
  }
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

To ensure optimal performance, let's implement caching and optimization strategies:

// lib/cache-manager.ts
import { Redis } from 'ioredis';

export class CacheManager {
  private redis: Redis;
  private readonly TTL = 7 * 24 * 60 * 60; // 1 week

  constructor() {
    this.redis = new Redis(process.env.REDIS_URL);
  }

  async getFromCache(key: string): Promise<Buffer | null> {
    try {
      const cached = await this.redis.get(key);
      return cached ? Buffer.from(cached, 'base64') : null;
    } catch (error) {
      console.error('Cache fetch failed:', error);
      return null;
    }
  }

  async setInCache(key: string, image: Buffer): Promise<void> {
    try {
      await this.redis.set(
        key,
        image.toString('base64'),
        'EX',
        this.TTL
      );
    } catch (error) {
      console.error('Cache set failed:', error);
    }
  }
}

// Implementation in API route
import { CacheManager } from '../lib/cache-manager';

const cacheManager = new CacheManager();

export async function GET(request: Request) {
  const cacheKey = generateCacheKey(request.url);

  // Try cache first
  const cached = await cacheManager.getFromCache(cacheKey);
  if (cached) {
    return new NextResponse(cached, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=31536000, immutable'
      }
    });
  }

  // Generate and cache if not found
  const image = await generateOGImage(/* params */);
  await cacheManager.setInCache(cacheKey, image);

  return new NextResponse(image, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable'
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Monitoring and Analytics

To maintain and improve the system, implement monitoring:

// lib/analytics.ts
export class Analytics {
  async trackGeneration(params: {
    duration: number;
    success: boolean;
    cached: boolean;
    error?: Error;
  }) {
    await fetch(process.env.ANALYTICS_ENDPOINT, {
      method: 'POST',
      body: JSON.stringify({
        event: 'og_generation',
        ...params,
        timestamp: new Date().toISOString()
      })
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the API

Here's how to use the API in your Next.js application:

// Example usage in your pages
const OGImage = ({ title }: { title: string }) => {
  const ogImageUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/og?title=${
    encodeURIComponent(title)
  }&theme=light`;

  return (
    <Head>
      <meta property="og:image" content={ogImageUrl} />
      <meta property="og:image:width" content="1200" />
      <meta property="og:image:height" content="630" />
    </Head>
  );
};
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

A few key points to ensure optimal performance:

  1. Edge Runtime usage for faster response times
  2. Aggressive caching strategy
  3. Image optimization with Sharp
  4. Error handling with fallbacks
  5. Monitoring for continuous improvement

Looking for a Ready Solution?

While building your own API is educational, you might want to consider using Gleam.so for a production-ready solution. It implements all these best practices and more, saving you development and maintenance time.

Questions?

Drop your questions in the comments! I'm here to help you implement this in your own projects.


Part of the Making OpenGraph Work series. Follow for more web development insights!

Top comments (0)