Skip to content

RESOURCES / BLOG

Standardize Your Blog Cover Images With Next.js and Cloudinary

Blog grids are much more visually appealing when every cover image is the same shape, but uploaded images rarely match. One image may be wide, another may be tall. Another may be square. When those images are arranged in a grid, the layout can look uneven.

In this guide, you’ll build a blog cover image manager in Next.js with Cloudinary. The app will let you upload a blog cover image, store the returned public_id, and use Cloudinary transformations to display it in a clean 16:9 format.

To achieve a professional, consistent look, we’ll leverage:

  • Cloudinary Upload Widget for seamless uploads.
  • c_fill to crop covers into the same shape.
  • g_auto to keep the subject in view automatically.
  • Automatic format and quality for optimized delivery.
  • Loading skeletons to maintain UI stability while images load.

By the end, your blog grid will have consistent cover images without manually editing each upload.

Blog cover images often come in different shapes if you don’t standardize them.

When placed in the same blog grid, the layout can look uneven. Cards may have different heights, covers may stretch, and key parts of an image may get cut off. The loading experience can also feel rough if the image space is not planned well.

For this project, the fix is to make every blog cover fit the same 16:9 frame by using two powerful Cloudinary features:

  • c_fill (Fill Crop) crops and resizes an image so it fills the exact space you define.
  • g_auto (Auto Gravity)helps Cloudinary detect the main subject and keep it in view during the cropping process.

The result is a portrait photo, square graphic, and wide banner that appear as clean, consistent blog covers in the same grid.

Your blog cover image manager app will include:

  • An admin upload page.
  • A Cloudinary Upload Widget button.
  • A blog card grid.
  • Clean 16:9 cover images.
  • Loading skeletons while images load.

A user uploads a cover image from the admin page. Cloudinary returns upload data, including the image public_id. The app then passes that public_id into a blog card.

Inside the card, Cloudinary transforms the image with c_fill and g_auto. This makes every cover fit the same 16:9 space while keeping the main subject visible. The result is a cleaner blog grid. Each card keeps the same shape, even when the uploaded images have different sizes.

Start by creating a new Next.js project.

npx create-next-app@latest blog-cover-manager
Code language: CSS (css)

Move into the project folder:

cd blog-cover-manager

Install the Cloudinary packages:

npm install @cloudinary/react @cloudinary/url-gen
Code language: CSS (css)

Next, create a .env.local file in the project root.

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=your_unsigned_upload_preset

Use NEXT_PUBLIC_ because the upload widget runs in the browser. Your cloud name comes from your Cloudinary dashboard. The upload preset should be unsigned for this beginner version.

This setup gives the app what it needs to upload images from the browser and display optimized covers with Cloudinary.

Next, set up the Cloudinary Upload Widget. This gives users a ready-made upload modal, so you don’t have to build the upload UI from scratch. First, add the widget script to your app.

For Next.js, you can load it with the Script component:

import Script from "next/script";

export function CloudinaryWidgetScript() {
  return (
    <Script
      src="https://upload-widget.cloudinary.com/global/all.js"
      strategy="afterInteractive"
    />
  );
}
Code language: JavaScript (javascript)

The widget needs two values from your environment file:

const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
const uploadPreset = process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET;
Code language: JavaScript (javascript)

Then, when the upload succeeds, Cloudinary returns image data.

The most important value is the public_id.

onUploadSuccess={(result) => {
  console.log("Public ID:", result.public_id);
}}
Code language: JavaScript (javascript)

You’ll use this public_id later to render the blog cover image.

This is better than saving the full image URL. The public_id lets Cloudinary create different image sizes and crops from the same asset.

The admin page is the gateway for your content. It ties together three critical components into a single workflow:

  • The Upload Widget handles the direct-to-Cloudinary file transfer.
  • The Image Preview displays the transformed 16:9 version instantly.
  • The Blog Post Form captures metadata (e.g., title, excerpt, and author).

When an image upload succeeds, the app captures the public_id and immediately generates a preview using your transformation logic:

function handleUploadSuccess(result: CloudinaryUploadResult) {
  setPublicId(result.public_id); // Store the ID, not the full URL
  setPreviewUrl(buildCoverUrl(result.public_id));
}
Code language: JavaScript (javascript)

So, why store the public_id? It’s a best practice to store the public_id, not the full URL. By saving only the ID, your covers remain flexible. If you decide to change the crop, aspect ratio, or image quality later, you only need to update the buildCoverUrl function rather than migrating every entry in your database.

The admin form collects all the necessary blog details to ensure a complete card:

  • Content includes Title and Excerpt.
  • Metadata includes Author, Category, and Read Time.

Once you hit submit, the post is saved, and the router redirects you to the homepage. The new post will appear in the grid instantly, featuring a perfectly formatted 16:9 cover image.

View the full implementation of the admin dashboard in src/app/admin/page.tsx.

The secret to a clean blog grid lies in a single helper function: buildCoverUrl. Instead of manually editing images, you can use this function to apply a chain of transformations at delivery time to ensure every cover matches the layout perfectly.

View the full implementation in src/lib/cloudinary.ts.

export function buildCoverUrl(publicId: string): string {
  const transforms = [
    "c_fill",   // Fill the frame
    "ar_16:9",  // Lock the shape
    "g_auto",   // Focus on the subject
    "w_800",    // Set the width
    "q_auto",   // Compress quality
    "f_auto",   // Choose best format
  ].join(",");

  return `https://res.cloudinary.com/${CLOUD_NAME}/image/upload/${transforms}/${publicId}`;
}
Code language: JavaScript (javascript)

Each parameter in the URL performs a specific task to optimize the image:

  • c_fill (Crop Fill) resizes and crops the image to completely fill the requested dimensions without stretching or distortion.
  • ar_16:9 (Aspect Ratio) forces the image into a standard 16:9 blog cover shape, ensuring every card in your grid is the same height.
  • g_auto (Auto Gravity) uses AI to detect the most important part of the image — like a person’s face or a central object — and keeps it centered during the crop. –w_800 (Width) resizes the image to 800px wide, which is ideal for standard blog cards. –q_auto (Auto Quality) automatically compresses the file size to the lowest possible weight while maintaining visual quality.
  • f_auto (Auto Format) detects the user’s browser and serves the best format (like WebP or AVIF) for faster loading.

By moving the transformation logic to a URL helper, you gain flexibility. Whether your original upload is a tall portrait, a square graphic, or a wide banner, the blog card receives a consistent, high-quality 16:9 cover every time.

The blog card is the final destination for your transformed cover image. It takes the stored public_id, runs it through the buildCoverUrl helper, and renders the result using the optimized next/image component.

View the full implementation here: src/components/blog/BlogCard.tsx

To prevent layout shifts and ensure consistency, the card wraps the image in an AspectRatio component. This ensures that every card in your grid occupies the exact same amount of vertical space, regardless of the user’s screen size.

<AspectRatio ratio={16 / 9}>
  <Image
    src={buildCoverUrl(post.publicId)}
    alt={post.title}
    fill
    priority={priority}
    className="object-cover"
  />
</AspectRatio>
Code language: HTML, XML (xml)
  • The AspectRatio utility holds the 16:9 shape even before the image finishes loading, preventing the grid from “jumping.”
  • fill and object-cover are props that allow the image to expand and fill its container completely.
  • Because the card uses buildCoverUrl(post.publicId), it guarantees that every image is delivered with the same cropping and optimization rules.
  • The component includes a gradient overlay and frosted-glass badges. These design elements ensure that text like categories and read times remain legible, no matter how busy or bright the background image is.

The beauty of this component is its indifference to the original source. The card doesn’t need to know if the author uploaded a high-res landscape or a smartphone portrait; it simply asks for a public_id and receives a perfectly formatted 16:9 cover every time.

To create a professional user experience, your grid should maintain its structure even before the images have finished downloading. This prevents “layout shift,” where the page content jumps around as images pop into place.

View the full implementation here: src/components/blog/CoverSkeleton.tsx

The skeleton component uses the exact same aspect ratio as your Cloudinary-transformed covers. This ensures the placeholder occupies the same physical footprint as the final image.

import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Skeleton } from "@/components/ui/skeleton";

export function CoverSkeleton() {
  return (
    <AspectRatio ratio={16 / 9}>
      <Skeleton className="w-full h-full" />
    </AspectRatio>
  );
}
Code language: JavaScript (javascript)

In the BlogCard component, you can manage the transition between the skeleton and the image using a simple state variable. By keeping the image in the DOM but hidden until it’s ready, you’ll achieve a seamless transition.

// Only show the skeleton if the image hasn't loaded
{!loaded && <CoverSkeleton />}

<Image
  src={coverUrl}
  fill
  className={loaded ? "opacity-100" : "opacity-0"}
  onLoad={() => setLoaded(true)} // Trigger the switch
/>
Code language: JavaScript (javascript)

A smooth fade-in effect ensures:

  • Users see a stable card layout immediately, which reduces perceived loading time.
  • Zero layout shift, because the CoverSkeleton will match the 16:9 ratio of the rest of the blog card (title, excerpt, etc.).
  • A more polished transition than the default browser loading behavior, thanks to fading the image in from opacity-0 once loaded is true.

The first image in your blog grid is the most critical for performance. As the largest visible element during initial page load, it directly impacts Largest Contentful Paint (LCP); a core metric the browser uses to measure how fast your page feels to a user.

View the grid implementation here: src/components/blog/BlogGrid.tsx

To optimize this, the BlogGrid component identifies the first post in the array (index 0) and passes a priority prop only to that specific card.

{posts.map((post, i) => (
  <BlogCard key={post.id} post={post} priority={i === 0} />
))}
Code language: JavaScript (javascript)

The BlogCard then passes this value directly into the next/image component:

<Image
  src={coverUrl}
  fill
  priority={priority}
  className="object-cover"
/>
Code language: HTML, XML (xml)

This strategy works because:

  • By setting priority={true} for the first image, you tell Next.js to treat it as a high-priority resource. It will be loaded immediately instead of being deferred.
  • Only the first cover receives this treatment. The remaining images in the grid load lazily as the user scrolls, which saves bandwidth and keeps the initial load lightweight.
  • This targeted optimization significantly reduces LCP time, ensuring your blog looks “ready” to the user as quickly as possible.

The takeaway is you don’t need to force every image to load at once to have a fast site. By prioritizing just the first visible cover, you’ll ensure a fast “above-the-fold” experience while maintaining the efficiency of lazy loading for the rest of your content.

With the logic and components in place, you can now test the full upload-to-grid workflow. This confirms that the automation is handling various image types correctly.

View the live demo here: https://blog-cover-manager.vercel.app/

To see the system in action, try uploading several different image shapes: a portrait photo, wide banner, and square screenshot.

Despite the different original dimensions, every item should appear as a perfectly aligned 16:9 blog cover within the grid.

You can verify that the heavy lifting is happening on the fly by inspecting an image in your browser’s developer tools. Look at the image URL; you should see the following transformation string:

c_fill,ar_16:9,g_auto,w_800,q_auto,f_auto

This string confirms that the cover isn’t being manually edited or resized before upload. The original image is stored once, and Cloudinary generates the specific version required by the blog grid only when a browser requests that URL.

  • Store the public_id, not the full URL. Keeping only the ID ensures your data remains flexible if you decide to change transformation parameters later.
  • Use c_fill for fixed layouts. This ensures every cover fits the designated space without distortion.
  • Lock the aspect ratio with ar_16:9. This keeps all blog cards even and aligned in the grid.
  • Enable g_auto (Auto Gravity). This allows Cloudinary’s AI to keep the most important subject in frame during the crop.
  • Optimize delivery with q_auto and f_auto. These handle compression and format selection automatically for faster page loads.
  • Implement loading skeletons. Using a placeholder that matches the 16:9 ratio prevents “janky” layout shifts while images are downloading.
  • Prioritize the first blog cover. Passing the priority prop to the first visible image improves your LCP (Largest Contentful Paint) score.
  • Centralize logic in buildCoverUrl. Keeping transformation rules in one helper file makes the system easier to maintain and update.

By following these practices, your blog grid stays perfectly aligned regardless of the source material. Whether a contributor uploads a tall portrait, a square screenshot, or a wide banner, the system automatically delivers a uniform, high-quality 16:9 cover every time.

View the core logic reference in src/lib/cloudinary.ts

Blog cover images can come in any shape, but your grid doesn’t have to show that mess.

In this project, you used Next.js and Cloudinary to turn mixed uploads into clean 16:9 blog covers. You built an admin upload page, stored the returned public_id, and used buildCoverUrl to apply the same transformation rules to every cover. Cloudinary handled the crop, aspect ratio, smart gravity, format, and quality. Next.js handled the page, card layout, image rendering, and loading behavior.

The result is a blog grid that stays neat, even when the uploaded images are tall, wide, or square. Sign up for a free Cloudinary account today and start building your own project.

Why should I store the Cloudinary public_id instead of the full URL?

Storing the public_id gives you maximum flexibility. If you decide to change your crop, aspect ratio, or image quality settings later, you only need to update your URL helper function instead of migrating every entry in your database.

What do c_fill and g_auto do for blog covers?

These transformations work together to create uniform layouts. c_fill resizes the image to fill your specified frame without distortion. g_auto uses AI to detect the main subject and ensure it stays centered during the cropping process.

How do loading skeletons prevent layout shift?

Skeletons act as placeholders that match the exact 16:9 shape of your final images. They reserve the physical space on the page before the image finishes loading. This keeps your blog content stable and prevents the layout from jumping.

Why is the priority prop important for the first blog cover?

The first image in your grid is often the Largest Contentful Paint (LCP) element. Using the priority prop tells Next.js to download that specific image immediately. This improves your site performance scores and makes the page feel faster to users.

Do I need to manually resize images before uploading them to my blog?

No. Cloudinary handles all resizing and cropping dynamically through URL parameters. You can upload images of any original size or shape and the system will automatically transform them into the required 16:9 covers at delivery time.

Start Using Cloudinary

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

Sign Up for Free