Skip to content

RESOURCES / BLOG

Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary

When you share a link on any social platform, the preview image that appears is your content’s first impression. A generic screenshot or a badly cropped logo can make your link look unprofessional or bland, and the user will scroll on to something else more exciting. A custom, eye-catching Open Graph (OG) image, on the other hand, can dramatically increase click-through rates. According to research by X (formerly known as Twitter), posts with images receive up to 150% more shares and 18% more clicks. The problem? Manually creating a unique social card in Figma for every single blog post is time-consuming for any content team.

You can clear up the bottleneck in your creative workflow with Cloudinary’s dynamic image transformations. Generate social images instantly, on the fly, just by changing a few parameters in a URL.

By manipulating URL parameters, you can tell Cloudinary’s powerful image servers to:

  1. Use any image as a dynamic background.
  2. Apply complex effects like gradients, tints, and blurs.
  3. Dynamically overlay text, logos, and even other images with precise positioning and styling.

In this guide, you’ll learn how to build a powerful, real-time social card generator in Next.js. We’ll build a complete web application that allows users to:

  • Upload their own background image.
  • Choose from predesigned, professional templates.
  • Type in a headline and subtitle and see them appear on a live preview instantly.
  • Export the final URL to use directly in their CMS or for social sharing.

We’ll accomplish this by using the excellent next-cloudinary package, which simplifies the process of building these complex URLs.

Before we dive into the code, you can see the final application in action. This is what we’ll be building: a three-step wizard that takes an image and text to generate a polished, shareable social media card.

Ready to build it? Let’s set up the project.

To get started, we’ll create a new Next.js application and install the required libraries for image handling, UI, and animation. This provides a clean foundation for our generator.

Open your terminal and run the following command to create a new Next.js project with Tailwind CSS:

npx  create-next-app@latest  og-card-generator
Code language: CSS (css)

When prompted, choose the following options:

  • Would you like to use TypeScript? Yes
  • Would you like to use ESLint? Yes
  • Would you like to use Tailwind CSS? Yes
  • Would you like to use src/ directory? Yes
  • Would you like to use App Router? Yes

Next, navigate to your project directory and install the necessary libraries:

cd  og-card-generator

npm  install  next-cloudinary  cloudinary  framer-motion  lucide-react  sonner  uuid
  • next-cloudinary. Generates Cloudinary URLs in Next.js.
  • cloudinary. Official Cloudinary Node.js SDK.
  • framer-motion. For UI animations.
  • lucide-react and sonner. Icons and toast notifications.
  • uuid. To generate unique IDs for saved templates.

We’ll use shadcn/ui for UI components like buttons and cards. Initialize it in your project:

npx  shadcn-ui@latest  init
Code language: CSS (css)

Accept the defaults, then add the components:

npx  shadcn-ui@latest  add  button  card  input  label  sonner
Code language: CSS (css)

Create a .env.local file in the root of your project and add your Cloudinary credentials:

# .env.local

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=your-upload-preset

# Server-side API credentials
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secret

These values can be found in your Cloudinary Dashboard. The upload preset should allow unsigned uploads.

Update your next.config.js file to allow images from Cloudinary:

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
      },
    ],
  },
};

module.exports = nextConfig;
Code language: JavaScript (javascript)

(See the final next.config.js on GitHub)

After saving, restart your development server for the changes to take effect. With the project fully set up, we’re ready to build the core template engine.

Our generator consists of a single, powerful function from next-cloudinary: getCldOgImageUrl. It takes a JavaScript object that describes your transformations and returns a perfectly formatted Cloudinary URL string.

Our strategy is to create a “recipe” for each of our card designs. We’ll store these recipes as reusable functions in a single file: src/lib/ogTemplates.ts.

Our simplest design places text on top of a full-bleed, darkened image. The logic for this is straightforward: we use the dynamic image as the src, apply a darkening effect, and then add our text in the overlays array.

The core of the function looks like this:

// Snippet from the `fullOgUrl` function

return getCldOgImageUrl({
  src: opts.publicId,
  effects: [
    { colorize: "80,co_black" }, // Darken the image by 80%
  ],
  overlays: [
    { text: { ...headlineOptions } }, // Add headline
    { text: { ...bodyOptions } }, // Add subtitle
  ],
});
Code language: JavaScript (javascript)

This tells Cloudinary to take the user’s image, darken it, and then layer the specified text on top.

The “Article” template is more advanced because it has a synthetic gradient background. Applying these complex effects directly to a dynamic image can be unreliable. The solution is the “Canvas Technique.”

We start with a stable, static image as a “canvas,” apply our gradient effects to it, and then layer all dynamic content (including the user’s uploaded image) on top as overlays.

// Snippet from the `articleOgUrl` function

const canvasPublicId = "hackit_africa/social_cards/galaxy";

return getCldOgImageUrl({
  src: canvasPublicId, // 1. Start with the static canvas
  effects: [
    // 2. Apply complex gradient effects to the canvas
    { background: "rgb:010A44" },
    { color: "rgb:2A005F", colorize: "100" },
    { gradientFade: "symmetric" },
  ],
  overlays: [
    // 3. Layer the user's image and all text on top
    { publicId: opts.publicId, effects: [{ opacity: 20 }] },
    { text: { ...headlineOptions } },
    { publicId: opts.logoPublicId, ...logoOptions },
    // ... and so on
  ],
});
Code language: JavaScript (javascript)

By defining our designs in these reusable functions, we’ll create a powerful and maintainable engine that our UI components can easily call.

(See the complete ogTemplates.ts file on GitHub)

Before a user can design their social card, they need to provide a background image. The most secure and efficient way to handle this is with a client-side upload widget. This allows the user’s browser to send the file directly to Cloudinary without ever passing through our own server, which is faster and doesn’t require us to expose our secret API keys on the client.

The next-cloudinary package makes this incredibly simple with its CldUploadWidget component. We’ll wrap it in our own component, src/components/UploadWidget.tsx, to handle the result.

The CldUploadWidget component acts as a wrapper. It takes configuration options and provides an open function to its children, which we can call from a button’s onClick handler.

The two most important properties we’ll use are:

  • uploadPreset. This tells Cloudinary to use a specific set of rules for the upload. In our case, we use an “unsigned” preset, which is required for secure client-side uploads.

  • onSuccess. This is a callback function that runs after the upload is complete. It receives the result from Cloudinary, which contains the crucial public_id and secure_url of the newly uploaded image.

Inside our UploadWidget.tsx component, the implementation looks like this:

// Snippet from UploadWidget.tsx

import { CldUploadWidget } from 'next-cloudinary';

// ...

return (
  <CldUploadWidget
    uploadPreset={process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET!}
    onSuccess={(res) => {
      // 1. Check if the result info is valid
      if (!res.info || typeof res.info === 'string') return;

      // 2. Extract the necessary data from the result
      const { public_id, secure_url, width, height } = res.info;

      // 3. Call the onUpload prop function to pass the data up
      //    to our main GeneratorClient component.
      onUpload({
        publicId: public_id,
        url: secure_url,
        width,
        height,
      });
    }}
  >
    {/* This function receives an `open` function */}
    {({ open }) => (
      <Button onClick={() => open()}>
        <UploadCloud />
      </Button>
    )}
  </CldUploadWidget>
);

Code language: PHP (php)

By passing the onUpload function as a prop from its parent, the UploadWidget successfully “lifts up” the result of the upload. This allows our main GeneratorClient to receive the publicId and trigger the live preview.

(See the complete UploadWidget.tsx file on GitHub)

To create a smooth user experience, we’ll structure our generator as a simple, three-step wizard:

  1. Asset. Upload an image.
  2. Design. Choose a template and add text.
  3. Preview. View the final result and export.

The entire flow is managed by a single, top-level client component: src/components/GeneratorClient.tsx. This component acts as the “brain” of our application, holding all the necessary information in its state and deciding which step to show the user.

Inside GeneratorClient.tsx, we’ll use several useState hooks to keep track of the user’s progress and choices. This is the central hub for all the dynamic data in our app.

// Snippet from GeneratorClient.tsx

export default function GeneratorClient() {
  // 1. Tracks which step of the wizard is currently active
  const [step, setStep] = useState<Step>('asset');

  // 2. Holds the info of the image the user uploaded
  const [uploadedInfo, setUploadedInfo] = useState<UploadInfo | null>(null);

  // 3. Stores the text for the title and subtitle inputs
  const [fields, setFields] = useState({ title: '...', subtitle: '...' });

  // 4. Keeps track of the currently selected template ('article', 'full', etc.)
  const [templateId, setTemplateId] = useState<TemplateId>('article');

  // ... rest of the component
}

Code language: JavaScript (javascript)

The component then uses simple conditional rendering in its JSX to display the correct step component based on the value of the step state variable. Framer Motion’s <AnimatePresence> is used to create smooth transitions between each step.

// Snippet from GeneratorClient.tsx's return statement

<AnimatePresence mode='wait'>
  {step === 'asset' && (
    <AssetStep onNext={() => setStep('design')} ... />
  )}

  {step === 'design' && (
    <DesignStep onNext={() => setStep('preview')} onBack={() => setStep('asset')} ... />
  )}

  {step === 'preview' && (
    <PreviewStep onBack={() => setStep('design')} ... />
  )}
</AnimatePresence>

Code language: PHP (php)

Each step component (AssetStep, DesignStep, etc.) receives the necessary state as props and uses callback functions (like onNext) to tell the parent GeneratorClient when to switch to a different step. This clean, one-way data flow makes the application easy to manage and debug.

(See the complete GeneratorClient.tsx file on GitHub)

The most critical part of our generator is providing instant visual feedback. When a user types a new headline, they should see the card update immediately. This real-time experience is what makes the tool feel powerful and intuitive.

We achieve this with two components working together inside our “Design” step:

  1. DesignStep.tsx: The main component for this step, which holds the layout and the input fields.

  2. CardPreview.tsx: A dedicated component that takes the current image, text, and template choice, and renders the final image.

The core of this real-time functionality lies inside CardPreview.tsx and is powered by the React useMemo hook.

The useMemo hook is perfect for this task. It “memoizes” a calculation, meaning it only re-runs the calculation when one of its dependencies changes. In our case, it will generate a new Cloudinary URL only when the user’s image, text, or selected template changes. (So efficient!)

Inside our CardPreview component, the logic looks like this:

// Snippet from CardPreview.tsx

const finalUrl = useMemo(() => {
  // Get the latest publicId and text from props
  const publicId = config.image!;
  const headline = config.text?.title || 'Your Headline Here';

  // Use a switch statement to call the correct template function
  switch (templateId) {
    case 'full':
      return fullOgUrl({ publicId, headline, ... });
    case 'one-third':
      return oneThirdOgUrl({ publicId, headline, ... });
    case 'article':
    default:
      return articleOgUrl({ publicId, headline, ... });
  }
// This dependency array tells React to re-run the code
// ONLY when one of these values changes.
}, [templateId, config.image, config.text?.title, config.text?.subtitle]);

// The component then simply renders an <img> with the finalUrl
return (
    <img src={finalUrl} alt="Live preview" />
);

Code language: JavaScript (javascript)

Each time the user types a character in the “Title” input, the state in the parent GeneratorClient updates, passing new props down to CardPreview. The useMemo hook detects this change in its dependency array, re-runs our template function with the new text, and generates a new Cloudinary URL. The <img /> tag then re-renders with the new src, showing the updated design instantly.

This creates a seamless feedback loop that is the heart of our application’s user experience.

(See the full DesignStep.tsx on GitHub and CardPreview.tsx on GitHub)

Our generator now has a working live preview in the “Design” step. However, a subtle but common problem arises when we move to the final “Preview and Export” step: How do we ensure the image shown there is identical to the one from the previous step?

If we have two different components (CardPreview.tsx and PreviewStep.tsx) both trying to calculate the same complex URL, we introduce two sources of truth. This is a recipe for bugs. A tiny difference in their logic—a different logo ID, a typo—can cause the final preview to break, which is exactly what we experienced during development.

The solution is a fundamental React pattern called “Lifting State Up.” Instead of having the final step recalculate the URL, we will treat the working CardPreview component as the single source of truth.

The flow works like this:

  1. CardPreview generates the correct URL.
  2. It “lifts” this URL up to the parent GeneratorClient component using a callback function.
  3. The parent component saves this URL in its state.
  4. It then passes this verified, working URL down to PreviewStep as a simple prop.

First, we’ll modify CardPreview.tsx to accept a new prop, onUrlGenerated, and use a useEffect hook to call it whenever a new URL is successfully created.

// Snippet from CardPreview.tsx

interface CardPreviewProps {
  // ... other props
  onUrlGenerated: (url: string) => void;
}

const CardPreview: FC<CardPreviewProps> = ({ /*...props*/, onUrlGenerated }) => {
  // ... useMemo hook to generate finalUrl ...

  // This hook watches for changes to the generated URL
  useEffect(() => {
    if (finalUrl) {
      // If a new URL exists, send it up to the parent.
      onUrlGenerated(finalUrl);
    }
  }, [finalUrl, onUrlGenerated]);

  // ... rest of the component
};

Code language: JavaScript (javascript)

Next, in our main GeneratorClient.tsx, we add a new piece of state to hold the URL. We then pass the setter function for this state down to the DesignStep.

// Snippet from GeneratorClient.tsx

export default function GeneratorClient() {
  // Create a new state to hold the final, working URL
  const [generatedCardUrl, setGeneratedCardUrl] = useState('');

  // ... other state ...

  return (
    // ...
    {step === 'design' && (
      <DesignStep
        // Pass the setter function down as the onUrlGenerated prop
        onUrlGenerated={setGeneratedCardUrl}
        ...
      />
    )}
    // ...
  );
}

Code language: JavaScript (javascript)

Finally, our PreviewStep.tsx component becomes much simpler. It no longer needs to calculate anything. It just receives the final, correct URL as a prop and displays it.

// Snippet from PreviewStep.tsx

interface PreviewStepProps {
  // ... other props
  finalImageUrl: string; // Receives the working URL directly
}

export const PreviewStep: FC<PreviewStepProps> = ({ finalImageUrl, ... }) => {
  // No more useMemo or calculation logic!
  const displayUrl = finalImageUrl;

  return <img src={displayUrl} alt="Final Preview" />;
};

Code language: JavaScript (javascript)

By implementing this pattern, we’ve created a more robust and reliable application. There’s now a single source of truth for our generated image, eliminating an entire category of potential bugs.

(See the full PreviewStep.tsx on GitHub)

The generator we’ve built is a powerful standalone tool, but its true value is unlocked when it becomes part of your content creation workflow. The goal is to create a social card once and use its URL to automatically populate the share previews for your blog posts, product pages, or any other content.

This process is simple and can be integrated into any Content Management System (CMS) like Contentful, Sanity, Strapi, or even a simple Markdown-based blog.

The entire workflow can be broken down into three straightforward steps.

First, your content creator uses the generator to design the perfect card for their new article. They upload a background image, select a template, and enter the title and subtitle.

On the final “Preview and Export” page, they click the “Copy URL” button. This copies the final, fully-transformed Cloudinary URL to their clipboard.

Next, go to your CMS. In the entry for their new blog post, there should be a dedicated field for the social card URL (you might name this field socialCardUrl, ogImageUrl, or something similar). Then simply paste the copied URL into this field and save the post.

This saves the link to your card directly alongside your article’s content in your database.

Finally, your Next.js application needs to use this saved URL to generate the correct <meta> tags for social sharing. This is done inside the generateMetadata function for your dynamic pages (e.g., src/app/blog/[slug]/page.tsx).

The function fetches the data for a specific post from your CMS/database, finds the socialCardUrl field, and places it inside the openGraph.images array.

// Example from src/app/blog/[slug]/page.tsx

import type { Metadata } from 'next';

// This function fetches your post data, including the saved URL
async function getPostData(slug: string) {
  // In a real app, this would be a database or CMS call
  const post = await myCms.getPost(slug);
  return post;
}

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPostData(params.slug);

  return {
    title: post.title,
    openGraph: {
      title: post.title,
      images: [
        {
          // ✅ The saved URL from your CMS is used here!
          url: post.socialCardUrl,
          width: 1200,
          height: 630,
        },
      ],
    },
    // You can also add Twitter-specific tags
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      images: [post.socialCardUrl],
    }
  };
}

Code language: JavaScript (javascript)

With this in place, every page on your site can have a unique, professionally designed social card. The generator becomes a seamless part of your content pipeline, empowering your team to create engaging share previews without ever needing to open a design tool.

In this guide, we’ve gone from a blank create-next-app project to a powerful, full-stack social card generator. By leveraging the next-cloudinary package, we built a tool that can dynamically create stunning Open Graph images on the fly, transforming a tedious manual process into an instant, creative workflow.

We’ve seen how to build a multi-step wizard in Next.js, manage state with React hooks, and handle complex image transformations by creating reusable template functions. Most importantly, we’ve built a robust application that isn’t just a demo, but a useful tool that can be integrated directly into a professional content pipeline.

By using Cloudinary for image processing and delivery, you’re free to focus on what matters most: creating engaging user experiences and content that gets noticed. Sign up for a free Cloudinary account today to get started.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free