{"id":37820,"date":"2025-07-01T07:00:00","date_gmt":"2025-07-01T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=37820"},"modified":"2025-06-25T13:56:46","modified_gmt":"2025-06-25T20:56:46","slug":"custom-og-images-next-js","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js","title":{"rendered":"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>When you share a link on any social platform, the preview image that appears is your content\u2019s 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 <a href=\"https:\/\/www.adweek.com\/performance-marketing\/twitter-images-study\/\">150% more shares and 18% more clicks<\/a>. The problem? Manually creating a unique social card in Figma for every single blog post is time-consuming for any content team.<\/p>\n<p>You can clear up the bottleneck in your creative workflow with <a href=\"https:\/\/cloudinary.com\/documentation\/image_transformations\">Cloudinary\u2019s dynamic image transformations<\/a>. Generate social images instantly, on the fly, just by changing a few parameters in a URL.<\/p>\n<p>By manipulating URL parameters, you can tell Cloudinary\u2019s powerful image servers to:<\/p>\n<ol>\n<li>\n<strong>Use any image<\/strong> as a dynamic background.<\/li>\n<li>\n<strong>Apply complex effects<\/strong> like gradients, tints, and blurs.<\/li>\n<li>\n<strong>Dynamically overlay text<\/strong>, logos, and even other images with precise positioning and styling.<\/li>\n<\/ol>\n<p>In this guide, you\u2019ll learn how to build a powerful, real-time social card generator in <strong>Next.js<\/strong>. We\u2019ll build a complete web application that allows users to:<\/p>\n<ul>\n<li>Upload their own background image.<\/li>\n<li>Choose from predesigned, professional templates.<\/li>\n<li>Type in a headline and subtitle and see them appear on a live preview instantly.<\/li>\n<li>Export the final URL to use directly in their CMS or for social sharing.<\/li>\n<\/ul>\n<p>We\u2019ll accomplish this by using the excellent <a href=\"https:\/\/next.cloudinary.dev\/\"><code>next-cloudinary<\/code><\/a> package, which simplifies the process of building these complex URLs.<\/p>\n<h2>Final Product and Source Code<\/h2>\n<p>Before we dive into the code, you can see the final application in action. This is what we\u2019ll be building: a three-step wizard that takes an image and text to generate a polished, shareable social media card.<\/p>\n<ul>\n<li>\n<p><strong>Live Demo:<\/strong> <a href=\"https:\/\/og-card-generator.vercel.app\">https:\/\/og-card-generator.vercel.app<\/a><\/p>\n<\/li>\n<li>\n<p><strong>GitHub Repository:<\/strong> <a href=\"https:\/\/github.com\/musebe\/og-card-generator\">https:\/\/github.com\/musebe\/og-card-generator<\/a><\/p>\n<\/li>\n<\/ul>\n<p>Ready to build it? Let\u2019s set up the project.<\/p>\n<h2>Project Setup<\/h2>\n<p>To get started, we\u2019ll 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.<\/p>\n<h3>1. Create a New Next.js App<\/h3>\n<p>Open your terminal and run the following command to create a new Next.js project with Tailwind CSS:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">npx<\/span>  <span class=\"hljs-selector-tag\">create-next-app<\/span><span class=\"hljs-keyword\">@latest<\/span>  og-card-generator\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>When prompted, choose the following options:<\/p>\n<ul>\n<li>\n<code>Would you like to use TypeScript?<\/code>  <strong>Yes<\/strong>\n<\/li>\n<li>\n<code>Would you like to use ESLint?<\/code>  <strong>Yes<\/strong>\n<\/li>\n<li>\n<code>Would you like to use Tailwind CSS?<\/code>  <strong>Yes<\/strong>\n<\/li>\n<li>\n<code>Would you like to use src\/ directory?<\/code>  <strong>Yes<\/strong>\n<\/li>\n<li>\n<code>Would you like to use App Router?<\/code>  <strong>Yes<\/strong>\n<\/li>\n<\/ul>\n<h3>2. Install Core Dependencies<\/h3>\n<p>Next, navigate to your project directory and install the necessary libraries:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">cd  og-card-generator\n\nnpm  install  next-cloudinary  cloudinary  framer-motion  lucide-react  sonner  uuid\n<\/code><\/span><\/pre>\n<ul>\n<li>\n<strong><code>next-cloudinary<\/code>.<\/strong> Generates Cloudinary URLs in Next.js.<\/li>\n<li>\n<strong><code>cloudinary<\/code>.<\/strong> Official Cloudinary Node.js SDK.<\/li>\n<li>\n<strong><code>framer-motion<\/code>.<\/strong> For UI animations.<\/li>\n<li>\n<strong><code>lucide-react<\/code> and <code>sonner<\/code>.<\/strong> Icons and toast notifications.<\/li>\n<li>\n<strong><code>uuid<\/code>.<\/strong> To generate unique IDs for saved templates.<\/li>\n<\/ul>\n<h3>3. Set Up <code>shadcn\/ui<\/code><\/h3>\n<p>We\u2019ll use <code>shadcn\/ui<\/code> for UI components like buttons and cards. Initialize it in your project:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">npx<\/span>  <span class=\"hljs-selector-tag\">shadcn-ui<\/span><span class=\"hljs-keyword\">@latest<\/span>  init\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Accept the defaults, then add the components:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">npx<\/span>  <span class=\"hljs-selector-tag\">shadcn-ui<\/span><span class=\"hljs-keyword\">@latest<\/span>  add  button  card  input  label  sonner\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>4. Configure Environment Variables<\/h3>\n<p>Create a <code>.env.local<\/code> file in the root of your project and add your Cloudinary credentials:<\/p>\n<pre class=\"js-syntax-highlighted\"><code># .env.local\n\nNEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name\nNEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=your-upload-preset\n\n# Server-side API credentials\nCLOUDINARY_API_KEY=your-api-key\nCLOUDINARY_API_SECRET=your-api-secret\n<\/code><\/pre>\n<blockquote>\n<p>These values can be found in your Cloudinary Dashboard. The upload preset should allow unsigned uploads.<\/p>\n<\/blockquote>\n<h3>5. Configure Next.js for Cloudinary Images<\/h3>\n<p>Update your <code>next.config.js<\/code> file to allow images from Cloudinary:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ next.config.js<\/span>\n\n<span class=\"hljs-comment\">\/** <span class=\"hljs-doctag\">@type <span class=\"hljs-type\">{import('next').NextConfig}<\/span> <\/span>*\/<\/span>\n<span class=\"hljs-keyword\">const<\/span> nextConfig = {\n  <span class=\"hljs-attr\">images<\/span>: {\n    <span class=\"hljs-attr\">remotePatterns<\/span>: &#91;\n      {\n        <span class=\"hljs-attr\">protocol<\/span>: <span class=\"hljs-string\">'https'<\/span>,\n        <span class=\"hljs-attr\">hostname<\/span>: <span class=\"hljs-string\">'res.cloudinary.com'<\/span>,\n      },\n    ],\n  },\n};\n\n<span class=\"hljs-built_in\">module<\/span>.exports = nextConfig;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>(See the final <code>next.config.js<\/code> on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/next.config.ts\">GitHub<\/a>)<\/p>\n<\/blockquote>\n<p>After saving, restart your development server for the changes to take effect. With the project fully set up, we\u2019re ready to build the core template engine.<\/p>\n<h2>The Engine: Build Dynamic URL Templates<\/h2>\n<p>Our generator consists of a single, powerful function from <a href=\"https:\/\/next.cloudinary.dev\/\"><code>next-cloudinary<\/code><\/a>: <a href=\"https:\/\/next.cloudinary.dev\/getcldogimageurl\/basic-usage\"><code>getCldOgImageUrl<\/code><\/a>. It takes a JavaScript object that describes your transformations and returns a perfectly formatted Cloudinary URL string.<\/p>\n<p>Our strategy is to create a \u201crecipe\u201d for each of our card designs. We\u2019ll store these recipes as reusable functions in a single file: <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/lib\/ogTemplates.ts\"><code>src\/lib\/ogTemplates.ts<\/code><\/a>.<\/p>\n<h2>A Simple Template: The \u2018Full\u2019 Card<\/h2>\n<p>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 <code>src<\/code>, apply a darkening <code>effect<\/code>, and then add our text in the <code>overlays<\/code> array.<\/p>\n<p>The core of the function looks like this:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from the `fullOgUrl` function<\/span>\n\n<span class=\"hljs-keyword\">return<\/span> getCldOgImageUrl({\n  <span class=\"hljs-attr\">src<\/span>: opts.publicId,\n  <span class=\"hljs-attr\">effects<\/span>: &#91;\n    { <span class=\"hljs-attr\">colorize<\/span>: <span class=\"hljs-string\">\"80,co_black\"<\/span> }, <span class=\"hljs-comment\">\/\/ Darken the image by 80%<\/span>\n  ],\n  <span class=\"hljs-attr\">overlays<\/span>: &#91;\n    { <span class=\"hljs-attr\">text<\/span>: { ...headlineOptions } }, <span class=\"hljs-comment\">\/\/ Add headline<\/span>\n    { <span class=\"hljs-attr\">text<\/span>: { ...bodyOptions } }, <span class=\"hljs-comment\">\/\/ Add subtitle<\/span>\n  ],\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>This tells Cloudinary to take the user\u2019s image, darken it, and then layer the specified text on top.<\/p>\n<h2>A Complex Template: The \u2018Article\u2019 Card<\/h2>\n<p>The \u201cArticle\u201d 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 <strong>\u201cCanvas Technique.\u201d<\/strong><\/p>\n<p>We start with a stable, static image as a \u201ccanvas,\u201d apply our gradient effects to it, and then layer all dynamic content (including the user\u2019s uploaded image) on top as overlays.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from the `articleOgUrl` function<\/span>\n\n<span class=\"hljs-keyword\">const<\/span> canvasPublicId = <span class=\"hljs-string\">\"hackit_africa\/social_cards\/galaxy\"<\/span>;\n\n<span class=\"hljs-keyword\">return<\/span> getCldOgImageUrl({\n  <span class=\"hljs-attr\">src<\/span>: canvasPublicId, <span class=\"hljs-comment\">\/\/ 1. Start with the static canvas<\/span>\n  <span class=\"hljs-attr\">effects<\/span>: &#91;\n    <span class=\"hljs-comment\">\/\/ 2. Apply complex gradient effects to the canvas<\/span>\n    { <span class=\"hljs-attr\">background<\/span>: <span class=\"hljs-string\">\"rgb:010A44\"<\/span> },\n    { <span class=\"hljs-attr\">color<\/span>: <span class=\"hljs-string\">\"rgb:2A005F\"<\/span>, <span class=\"hljs-attr\">colorize<\/span>: <span class=\"hljs-string\">\"100\"<\/span> },\n    { <span class=\"hljs-attr\">gradientFade<\/span>: <span class=\"hljs-string\">\"symmetric\"<\/span> },\n  ],\n  <span class=\"hljs-attr\">overlays<\/span>: &#91;\n    <span class=\"hljs-comment\">\/\/ 3. Layer the user's image and all text on top<\/span>\n    { <span class=\"hljs-attr\">publicId<\/span>: opts.publicId, <span class=\"hljs-attr\">effects<\/span>: &#91;{ <span class=\"hljs-attr\">opacity<\/span>: <span class=\"hljs-number\">20<\/span> }] },\n    { <span class=\"hljs-attr\">text<\/span>: { ...headlineOptions } },\n    { <span class=\"hljs-attr\">publicId<\/span>: opts.logoPublicId, ...logoOptions },\n    <span class=\"hljs-comment\">\/\/ ... and so on<\/span>\n  ],\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>By defining our designs in these reusable functions, we\u2019ll create a powerful and maintainable engine that our UI components can easily call.<\/p>\n<p>(See the complete <code>ogTemplates.ts<\/code> file on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/lib\/ogTemplates.ts\">GitHub<\/a>)<\/p>\n<h2>The Upload Widget: Getting Images Into Cloudinary<\/h2>\n<p>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\u2019s browser to send the file directly to Cloudinary without ever passing through our own server, which is faster and doesn\u2019t require us to expose our secret API keys on the client.<\/p>\n<p>The <code>next-cloudinary<\/code> package makes this incredibly simple with its <a href=\"https:\/\/next.cloudinary.dev\/clduploadwidget\/basic-usage\"><code>CldUploadWidget<\/code><\/a> component. We\u2019ll wrap it in our own component, <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/UploadWidget.tsx\"><code>src\/components\/UploadWidget.tsx<\/code><\/a>, to handle the result.<\/p>\n<h3>Use the <code>CldUploadWidget<\/code><\/h3>\n<p>The <code>CldUploadWidget<\/code> component acts as a wrapper. It takes configuration options and provides an <code>open<\/code> function to its children, which we can call from a button\u2019s <code>onClick<\/code> handler.<\/p>\n<p>The two most important properties we\u2019ll use are:<\/p>\n<ul>\n<li>\n<p><strong><code>uploadPreset<\/code>.<\/strong> This tells Cloudinary to use a specific set of rules for the upload. In our case, we use an \u201cunsigned\u201d preset, which is required for secure client-side uploads.<\/p>\n<\/li>\n<li>\n<p><strong><code>onSuccess<\/code>.<\/strong> This is a callback function that runs after the upload is complete. It receives the result from Cloudinary, which contains the crucial <code>public_id<\/code> and <code>secure_url<\/code> of the newly uploaded image.<\/p>\n<\/li>\n<\/ul>\n<h3>Handle the Upload Result<\/h3>\n<p>Inside our <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/UploadWidget.tsx\"><code>UploadWidget.tsx<\/code><\/a> component, the implementation looks like this:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from UploadWidget.tsx<\/span>\n\nimport { CldUploadWidget } from <span class=\"hljs-string\">'next-cloudinary'<\/span>;\n\n<span class=\"hljs-comment\">\/\/ ...<\/span>\n\n<span class=\"hljs-keyword\">return<\/span> (\n  &lt;CldUploadWidget\n    uploadPreset={process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET!}\n    onSuccess={(res) =&gt; {\n      <span class=\"hljs-comment\">\/\/ 1. Check if the result info is valid<\/span>\n      <span class=\"hljs-keyword\">if<\/span> (!res.info || typeof res.info === <span class=\"hljs-string\">'string'<\/span>) <span class=\"hljs-keyword\">return<\/span>;\n\n      <span class=\"hljs-comment\">\/\/ 2. Extract the necessary data from the result<\/span>\n      <span class=\"hljs-keyword\">const<\/span> { public_id, secure_url, width, height } = res.info;\n\n      <span class=\"hljs-comment\">\/\/ 3. Call the onUpload prop function to pass the data up<\/span>\n      <span class=\"hljs-comment\">\/\/    to our main GeneratorClient component.<\/span>\n      onUpload({\n        publicId: public_id,\n        url: secure_url,\n        width,\n        height,\n      });\n    }}\n  &gt;\n    {<span class=\"hljs-comment\">\/* This function receives an `open` function *\/<\/span>}\n    {({ open }) =&gt; (\n      &lt;Button onClick={() =&gt; open()}&gt;\n        &lt;UploadCloud \/&gt;\n      &lt;\/Button&gt;\n    )}\n  &lt;\/CldUploadWidget&gt;\n);\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>By passing the <code>onUpload<\/code> function as a prop from its parent, the <code>UploadWidget<\/code> successfully \u201clifts up\u201d the result of the upload. This allows our main <code>GeneratorClient<\/code> to receive the <code>publicId<\/code> and trigger the live preview.<\/p>\n<p>(See the complete <code>UploadWidget.tsx<\/code> file on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/UploadWidget.tsx\">GitHub<\/a>)<\/p>\n<h2>Build the Generator: A 3-Step Wizard<\/h2>\n<p>To create a smooth user experience, we\u2019ll structure our generator as a simple, three-step wizard:<\/p>\n<ol>\n<li>\n<strong>Asset.<\/strong> Upload an image.<\/li>\n<li>\n<strong>Design.<\/strong> Choose a template and add text.<\/li>\n<li>\n<strong>Preview.<\/strong> View the final result and export.<\/li>\n<\/ol>\n<p>The entire flow is managed by a single, top-level client component: <code>src\/components\/GeneratorClient.tsx<\/code>. This component acts as the \u201cbrain\u201d of our application, holding all the necessary information in its state and deciding which step to show the user.<\/p>\n<h2>Manage State With <code>useState<\/code><\/h2>\n<p>Inside <code>GeneratorClient.tsx<\/code>, we\u2019ll use several <code>useState<\/code> hooks to keep track of the user\u2019s progress and choices. This is the central hub for all the dynamic data in our app.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from GeneratorClient.tsx<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">GeneratorClient<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-comment\">\/\/ 1. Tracks which step of the wizard is currently active<\/span>\n  <span class=\"hljs-keyword\">const<\/span> &#91;step, setStep] = useState&lt;Step&gt;(<span class=\"hljs-string\">'asset'<\/span>);\n\n  <span class=\"hljs-comment\">\/\/ 2. Holds the info of the image the user uploaded<\/span>\n  <span class=\"hljs-keyword\">const<\/span> &#91;uploadedInfo, setUploadedInfo] = useState&lt;UploadInfo | <span class=\"hljs-literal\">null<\/span>&gt;(<span class=\"hljs-literal\">null<\/span>);\n\n  <span class=\"hljs-comment\">\/\/ 3. Stores the text for the title and subtitle inputs<\/span>\n  <span class=\"hljs-keyword\">const<\/span> &#91;fields, setFields] = useState({ <span class=\"hljs-attr\">title<\/span>: <span class=\"hljs-string\">'...'<\/span>, <span class=\"hljs-attr\">subtitle<\/span>: <span class=\"hljs-string\">'...'<\/span> });\n\n  <span class=\"hljs-comment\">\/\/ 4. Keeps track of the currently selected template ('article', 'full', etc.)<\/span>\n  <span class=\"hljs-keyword\">const<\/span> &#91;templateId, setTemplateId] = useState&lt;TemplateId&gt;(<span class=\"hljs-string\">'article'<\/span>);\n\n  <span class=\"hljs-comment\">\/\/ ... rest of the component<\/span>\n}\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h2>Conditionally Render the Steps<\/h2>\n<p>The component then uses simple conditional rendering in its JSX to display the correct step component based on the value of the <code>step<\/code> state variable. <code>Framer Motion<\/code>\u2019s <code>&lt;AnimatePresence&gt;<\/code> is used to create smooth transitions between each step.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from GeneratorClient.tsx's return statement<\/span>\n\n&lt;AnimatePresence mode=<span class=\"hljs-string\">'wait'<\/span>&gt;\n  {step === <span class=\"hljs-string\">'asset'<\/span> &amp;&amp; (\n    &lt;AssetStep onNext={() =&gt; setStep(<span class=\"hljs-string\">'design'<\/span>)} ... \/&gt;\n  )}\n\n  {step === <span class=\"hljs-string\">'design'<\/span> &amp;&amp; (\n    &lt;DesignStep onNext={() =&gt; setStep(<span class=\"hljs-string\">'preview'<\/span>)} onBack={() =&gt; setStep(<span class=\"hljs-string\">'asset'<\/span>)} ... \/&gt;\n  )}\n\n  {step === <span class=\"hljs-string\">'preview'<\/span> &amp;&amp; (\n    &lt;PreviewStep onBack={() =&gt; setStep(<span class=\"hljs-string\">'design'<\/span>)} ... \/&gt;\n  )}\n&lt;\/AnimatePresence&gt;\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Each step component (<code>AssetStep<\/code>, <code>DesignStep<\/code>, etc.) receives the necessary state as props and uses callback functions (like <code>onNext<\/code>) to tell the parent <code>GeneratorClient<\/code> when to switch to a different step. This clean, one-way data flow makes the application easy to manage and debug.<\/p>\n<p>(See the complete <code>GeneratorClient.tsx<\/code> file on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/GeneratorClient.tsx\">GitHub<\/a>)<\/p>\n<h2>Design With a Live Preview<\/h2>\n<p>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.<\/p>\n<p>We achieve this with two components working together inside our \u201cDesign\u201d step:<\/p>\n<ol>\n<li>\n<p><a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/DesignStep.tsx\"><code>DesignStep.tsx<\/code><\/a>: The main component for this step, which holds the layout and the input fields.<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/CardPreview.tsx\"><code>CardPreview.tsx<\/code><\/a>: A dedicated component that takes the current image, text, and template choice, and renders the final image.<\/p>\n<\/li>\n<\/ol>\n<p>The core of this real-time functionality lies inside <code>CardPreview.tsx<\/code> and is powered by the React <code>useMemo<\/code> hook.<\/p>\n<h2>Use <code>useMemo<\/code> for Real-Time URL Generation<\/h2>\n<p>The <code>useMemo<\/code> hook is perfect for this task. It \u201cmemoizes\u201d 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 <em>only<\/em> when the user\u2019s image, text, or selected template changes. (So efficient!)<\/p>\n<p>Inside our <code>CardPreview<\/code> component, the logic looks like this:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from CardPreview.tsx<\/span>\n\n<span class=\"hljs-keyword\">const<\/span> finalUrl = useMemo(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-comment\">\/\/ Get the latest publicId and text from props<\/span>\n  <span class=\"hljs-keyword\">const<\/span> publicId = config.image!;\n  <span class=\"hljs-keyword\">const<\/span> headline = config.text?.title || <span class=\"hljs-string\">'Your Headline Here'<\/span>;\n\n  <span class=\"hljs-comment\">\/\/ Use a switch statement to call the correct template function<\/span>\n  <span class=\"hljs-keyword\">switch<\/span> (templateId) {\n    <span class=\"hljs-keyword\">case<\/span> <span class=\"hljs-string\">'full'<\/span>:\n      <span class=\"hljs-keyword\">return<\/span> fullOgUrl({ publicId, headline, ... });\n    <span class=\"hljs-keyword\">case<\/span> <span class=\"hljs-string\">'one-third'<\/span>:\n      <span class=\"hljs-keyword\">return<\/span> oneThirdOgUrl({ publicId, headline, ... });\n    <span class=\"hljs-keyword\">case<\/span> <span class=\"hljs-string\">'article'<\/span>:\n    <span class=\"hljs-keyword\">default<\/span>:\n      <span class=\"hljs-keyword\">return<\/span> articleOgUrl({ publicId, headline, ... });\n  }\n<span class=\"hljs-comment\">\/\/ This dependency array tells React to re-run the code<\/span>\n<span class=\"hljs-comment\">\/\/ ONLY when one of these values changes.<\/span>\n}, &#91;templateId, config.image, config.text?.title, config.text?.subtitle]);\n\n<span class=\"hljs-comment\">\/\/ The component then simply renders an &lt;img&gt; with the finalUrl<\/span>\n<span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">img<\/span> <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">{finalUrl}<\/span> <span class=\"hljs-attr\">alt<\/span>=<span class=\"hljs-string\">\"Live preview\"<\/span> \/&gt;<\/span><\/span>\n);\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Each time the user types a character in the \u201cTitle\u201d input, the state in the parent <code>GeneratorClient<\/code> updates, passing new props down to <code>CardPreview<\/code>. The <code>useMemo<\/code> hook detects this change in its dependency array, re-runs our template function with the new text, and generates a new Cloudinary URL. The <code>&lt;img \/&gt;<\/code> tag then re-renders with the new <code>src<\/code>, showing the updated design instantly.<\/p>\n<p>This creates a seamless feedback loop that is the heart of our application\u2019s user experience.<\/p>\n<p>(See the full <code>DesignStep.tsx<\/code> on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/DesignStep.tsx\">GitHub<\/a> and <code>CardPreview.tsx<\/code> on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/CardPreview.tsx\">GitHub<\/a>)<\/p>\n<h2>Finalize the Image: The \u2018Lift State Up\u2019 Pattern<\/h2>\n<p>Our generator now has a working live preview in the \u201cDesign\u201d step. However, a subtle but common problem arises when we move to the final \u201cPreview and Export\u201d step: How do we ensure the image shown there is <em>identical<\/em> to the one from the previous step?<\/p>\n<p>If we have two different components (<a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/CardPreview.tsx\"><code>CardPreview.tsx<\/code><\/a> and <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/PreviewStep.tsx\"><code>PreviewStep.tsx<\/code><\/a>) 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\u2014a different logo ID, a typo\u2014can cause the final preview to break, which is exactly what we experienced during development.<\/p>\n<h2>Solve the \u2018Stale State\u2019 Problem<\/h2>\n<p>The solution is a fundamental React pattern called <strong>\u201cLifting State Up.\u201d<\/strong> Instead of having the final step recalculate the URL, we will treat the working <code>CardPreview<\/code> component as the single source of truth.<\/p>\n<p>The flow works like this:<\/p>\n<ol>\n<li>\n<code>CardPreview<\/code> generates the correct URL.<\/li>\n<li>It \u201clifts\u201d this URL up to the parent <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/GeneratorClient.tsx\"><code>GeneratorClient<\/code><\/a> component using a callback function.<\/li>\n<li>The parent component saves this URL in its state.<\/li>\n<li>It then passes this verified, working URL down to <code>PreviewStep<\/code> as a simple prop.<\/li>\n<\/ol>\n<h2>Implement the Callback<\/h2>\n<p>First, we\u2019ll modify <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/CardPreview.tsx\"><code>CardPreview.tsx<\/code><\/a> to accept a new prop, <code>onUrlGenerated<\/code>, and use a <code>useEffect<\/code> hook to call it whenever a new URL is successfully created.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from CardPreview.tsx<\/span>\n\ninterface CardPreviewProps {\n  <span class=\"hljs-comment\">\/\/ ... other props<\/span>\n  <span class=\"hljs-attr\">onUrlGenerated<\/span>: <span class=\"hljs-function\">(<span class=\"hljs-params\">url: string<\/span>) =&gt;<\/span> <span class=\"hljs-keyword\">void<\/span>;\n}\n\n<span class=\"hljs-keyword\">const<\/span> CardPreview: FC&lt;CardPreviewProps&gt; = <span class=\"hljs-function\">(<span class=\"hljs-params\">{ <span class=\"hljs-regexp\">\/*...props*\/<\/span>, onUrlGenerated }<\/span>) =&gt;<\/span> {\n  <span class=\"hljs-comment\">\/\/ ... useMemo hook to generate finalUrl ...<\/span>\n\n  <span class=\"hljs-comment\">\/\/ This hook watches for changes to the generated URL<\/span>\n  useEffect(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">if<\/span> (finalUrl) {\n      <span class=\"hljs-comment\">\/\/ If a new URL exists, send it up to the parent.<\/span>\n      onUrlGenerated(finalUrl);\n    }\n  }, &#91;finalUrl, onUrlGenerated]);\n\n  <span class=\"hljs-comment\">\/\/ ... rest of the component<\/span>\n};\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h2>Store the URL in the Parent<\/h2>\n<p>Next, in our main <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/GeneratorClient.tsx\"><code>GeneratorClient.tsx<\/code><\/a>, we add a new piece of state to hold the URL. We then pass the setter function for this state down to the <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/DesignStep.tsx\"><code>DesignStep<\/code><\/a>.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from GeneratorClient.tsx<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">GeneratorClient<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-comment\">\/\/ Create a new state to hold the final, working URL<\/span>\n  <span class=\"hljs-keyword\">const<\/span> &#91;generatedCardUrl, setGeneratedCardUrl] = useState(<span class=\"hljs-string\">''<\/span>);\n\n  <span class=\"hljs-comment\">\/\/ ... other state ...<\/span>\n\n  <span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"hljs-comment\">\/\/ ...<\/span>\n    {step === <span class=\"hljs-string\">'design'<\/span> &amp;&amp; (\n      <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">DesignStep<\/span>\n        \/\/ <span class=\"hljs-attr\">Pass<\/span> <span class=\"hljs-attr\">the<\/span> <span class=\"hljs-attr\">setter<\/span> <span class=\"hljs-attr\">function<\/span> <span class=\"hljs-attr\">down<\/span> <span class=\"hljs-attr\">as<\/span> <span class=\"hljs-attr\">the<\/span> <span class=\"hljs-attr\">onUrlGenerated<\/span> <span class=\"hljs-attr\">prop<\/span>\n        <span class=\"hljs-attr\">onUrlGenerated<\/span>=<span class=\"hljs-string\">{setGeneratedCardUrl}<\/span>\n        <span class=\"hljs-attr\">...<\/span>\n      \/&gt;<\/span><\/span>\n    )}\n    <span class=\"hljs-comment\">\/\/ ...<\/span>\n  );\n}\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h2>Simplify the Final Preview<\/h2>\n<p>Finally, our <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/PreviewStep.tsx\"><code>PreviewStep.tsx<\/code><\/a> component becomes much simpler. It no longer needs to calculate anything. It just receives the final, correct URL as a prop and displays it.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Snippet from PreviewStep.tsx<\/span>\n\ninterface PreviewStepProps {\n  <span class=\"hljs-comment\">\/\/ ... other props<\/span>\n  <span class=\"hljs-attr\">finalImageUrl<\/span>: string; <span class=\"hljs-comment\">\/\/ Receives the working URL directly<\/span>\n}\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> PreviewStep: FC&lt;PreviewStepProps&gt; = <span class=\"hljs-function\">(<span class=\"hljs-params\">{ finalImageUrl, ... }<\/span>) =&gt;<\/span> {\n  <span class=\"hljs-comment\">\/\/ No more useMemo or calculation logic!<\/span>\n  <span class=\"hljs-keyword\">const<\/span> displayUrl = finalImageUrl;\n\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">img<\/span> <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">{displayUrl}<\/span> <span class=\"hljs-attr\">alt<\/span>=<span class=\"hljs-string\">\"Final Preview\"<\/span> \/&gt;<\/span><\/span>;\n};\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>By implementing this pattern, we\u2019ve created a more robust and reliable application. There\u2019s now a single source of truth for our generated image, eliminating an entire category of potential bugs.<\/p>\n<p>(See the full <code>PreviewStep.tsx<\/code> on <a href=\"https:\/\/github.com\/musebe\/og-card-generator\/blob\/main\/src\/components\/PreviewStep.tsx\">GitHub<\/a>)<\/p>\n<h2>Integrate With Your CMS or Application<\/h2>\n<p>The generator we\u2019ve 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.<\/p>\n<p>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.<\/p>\n<h2>The Workflow: From Generator to Live Page<\/h2>\n<p>The entire workflow can be broken down into three straightforward steps.<\/p>\n<h3>Step 1: Generate and Copy the URL<\/h3>\n<p>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.<\/p>\n<p>On the final \u201cPreview and Export\u201d page, they click the <strong>\u201cCopy URL\u201d<\/strong> button. This copies the final, fully-transformed Cloudinary URL to their clipboard.<\/p>\n<h3>Step 2: Paste the URL Into Your CMS<\/h3>\n<p>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 <code>socialCardUrl<\/code>, <code>ogImageUrl<\/code>, or something similar). Then simply paste the copied URL into this field and save the post.<\/p>\n<p>This saves the link to your card directly alongside your article\u2019s content in your database.<\/p>\n<h3>Step 3: Use the URL in <code>generateMetadata<\/code><\/h3>\n<p>Finally, your Next.js application needs to use this saved URL to generate the correct <code>&lt;meta&gt;<\/code> tags for social sharing. This is done inside the <code>generateMetadata<\/code> function for your dynamic pages (e.g., <code>src\/app\/blog\/[slug]\/page.tsx<\/code>).<\/p>\n<p>The function fetches the data for a specific post from your CMS\/database, finds the <code>socialCardUrl<\/code> field, and places it inside the <code>openGraph.images<\/code> array.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Example from src\/app\/blog\/&#91;slug]\/page.tsx<\/span>\n\n<span class=\"hljs-keyword\">import<\/span> type { Metadata } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'next'<\/span>;\n\n<span class=\"hljs-comment\">\/\/ This function fetches your post data, including the saved URL<\/span>\n<span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getPostData<\/span>(<span class=\"hljs-params\">slug: string<\/span>) <\/span>{\n  <span class=\"hljs-comment\">\/\/ In a real app, this would be a database or CMS call<\/span>\n  <span class=\"hljs-keyword\">const<\/span> post = <span class=\"hljs-keyword\">await<\/span> myCms.getPost(slug);\n  <span class=\"hljs-keyword\">return<\/span> post;\n}\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">generateMetadata<\/span>(<span class=\"hljs-params\">{ params }<\/span>): <span class=\"hljs-title\">Promise<\/span>&lt;<span class=\"hljs-title\">Metadata<\/span>&gt; <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> post = <span class=\"hljs-keyword\">await<\/span> getPostData(params.slug);\n\n  <span class=\"hljs-keyword\">return<\/span> {\n    <span class=\"hljs-attr\">title<\/span>: post.title,\n    <span class=\"hljs-attr\">openGraph<\/span>: {\n      <span class=\"hljs-attr\">title<\/span>: post.title,\n      <span class=\"hljs-attr\">images<\/span>: &#91;\n        {\n          <span class=\"hljs-comment\">\/\/ \u2705 The saved URL from your CMS is used here!<\/span>\n          <span class=\"hljs-attr\">url<\/span>: post.socialCardUrl,\n          <span class=\"hljs-attr\">width<\/span>: <span class=\"hljs-number\">1200<\/span>,\n          <span class=\"hljs-attr\">height<\/span>: <span class=\"hljs-number\">630<\/span>,\n        },\n      ],\n    },\n    <span class=\"hljs-comment\">\/\/ You can also add Twitter-specific tags<\/span>\n    <span class=\"hljs-attr\">twitter<\/span>: {\n      <span class=\"hljs-attr\">card<\/span>: <span class=\"hljs-string\">'summary_large_image'<\/span>,\n      <span class=\"hljs-attr\">title<\/span>: post.title,\n      <span class=\"hljs-attr\">images<\/span>: &#91;post.socialCardUrl],\n    }\n  };\n}\n\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>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.<\/p>\n<h2>Conclusion<\/h2>\n<p>In this guide, we\u2019ve gone from a blank <code>create-next-app<\/code> project to a powerful, full-stack social card generator. By leveraging the <code>next-cloudinary<\/code> 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.<\/p>\n<p>We\u2019ve 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\u2019ve built a robust application that isn\u2019t just a demo, but a useful tool that can be integrated directly into a professional content pipeline.<\/p>\n<p>By using Cloudinary for image processing and delivery, you\u2019re free to focus on what matters most: creating engaging user experiences and content that gets noticed. <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Sign up for a free Cloudinary account<\/a> today to get started.<\/p>\n<ul>\n<li>\n<p><strong>Live Demo:<\/strong> <a href=\"https:\/\/og-card-generator.vercel.app\/\">https:\/\/og-card-generator.vercel.app\/<\/a><\/p>\n<\/li>\n<li>\n<p><strong>Full Source Code:<\/strong> <a href=\"https:\/\/github.com\/musebe\/og-card-generator\">https:\/\/github.com\/musebe\/og-card-generator<\/a><\/p>\n<\/li>\n<\/ul>\n<\/div>","protected":false},"excerpt":{"rendered":"","protected":false},"author":87,"featured_media":37821,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":""},"categories":[1],"tags":[59,370,165,212],"class_list":["post-37820","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-cms","tag-image","tag-image-transformation","tag-next-js"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v25.6 (Yoast SEO v26.9) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2025-07-01T14:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA\" \/>\n\t<meta property=\"og:image:width\" content=\"2000\" \/>\n\t<meta property=\"og:image:height\" content=\"1100\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"NewsArticle\",\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#article\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary\",\"datePublished\":\"2025-07-01T14:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\"},\"wordCount\":15,\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA\",\"keywords\":[\"CMS\",\"Image\",\"Image Transformation\",\"Next.js\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2025\",\"copyrightHolder\":{\"@id\":\"https:\/\/cloudinary.com\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\",\"url\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\",\"name\":\"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA\",\"datePublished\":\"2025-07-01T14:00:00+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA\",\"width\":2000,\"height\":1100},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/cloudinary.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/cloudinary.com\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js","og_locale":"en_US","og_type":"article","og_title":"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary","og_url":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js","og_site_name":"Cloudinary Blog","article_published_time":"2025-07-01T14:00:00+00:00","og_image":[{"width":2000,"height":1100,"url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA","type":"image\/jpeg"}],"author":"melindapham","twitter_card":"summary_large_image","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"NewsArticle","@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary","datePublished":"2025-07-01T14:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js"},"wordCount":15,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA","keywords":["CMS","Image","Image Transformation","Next.js"],"inLanguage":"en-US","copyrightYear":"2025","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js","url":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js","name":"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA","datePublished":"2025-07-01T14:00:00+00:00","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/custom-og-images-next-js"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA","width":2000,"height":1100},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/custom-og-images-next-js#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Creating Custom OG Images on the Fly: A Guide to Using Next.js and Cloudinary"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1750465548\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary\/Blog_Dynamic_Social_Media_Card_Generator_with_Cloudinary.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/37820","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=37820"}],"version-history":[{"count":1,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/37820\/revisions"}],"predecessor-version":[{"id":37822,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/37820\/revisions\/37822"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/37821"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=37820"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=37820"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=37820"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}