Plain text links are a relic of the early web. In today’s social feeds and rich documentation, a naked URL is easy to ignore. You need a visual hook.
This project transforms boring links into high-fidelity preview cards. Using the Cloudinary URL2PNG add-on, we’ll build a system that captures live website viewports and promotes them to permanent, optimized assets in a community gallery.
- Instant screenshot generation. Automated viewport capture of any public URL.
- Persistent asset gallery. A system to “promote” dynamic previews into permanent cloud storage.
- Optimized delivery. Automatic format and quality switching for fast-loading cards.
- Modern Next.js 16 UI. A polished interface using Shadcn UI and creative loading states.
This setup is perfect for link-sharing platforms, automated portfolio galleries, or enhancing internal documentation tools.
- Live demo: https://link-to-card.vercel.app/
- GitHub repo: https://github.com/musebe/link-to-card
The architecture relies on three integrated pillars:
- Next.js 16 for the Logic Layer. Leveraging the latest App Router features, we use Server Actions to securely generate signed URLs. This ensures our API secrets never reach the browser while providing a seamless user experience.
- Cloudinary URL2PNG for the capture engine. Cloudinary visits the URL, renders the page, and delivers the screenshot via CDN.
- Cloudinary Search API for Persistence. Once a screenshot is generated, we promote it from a temporary URL to a permanent asset in a specific Cloudinary folder. We then use the Search API to build a dynamic, high-performance gallery.
Before automating screenshots, we need a stable base: a fresh Next.js environment and a correctly configured Cloudinary account.
The goal for this section is:
- A new Next.js 16 project initialized.
-
shadcncomponents ready for the UI. - URL2PNG add-on activated in the Cloudinary Marketplace.
Start with a standard App Router setup using the latest Next.js features.
npx create-next-app@latest link-to-card
cd link-to-card
Code language: CSS (css)
Choose:
- TypeScript: Yes
- Tailwind CSS: Yes
- App Router: Yes
We’ll use shadcn for a professional look and feel without writing custom CSS from scratch.
Initialize the CLI:
npx shadcn@latest init
Code language: CSS (css)
Then add the core components for our generator:
npx shadcn@latest add card button input select
Code language: CSS (css)
This is the “magic” step. You must enable the screenshot engine within your Cloudinary account.
- Log in to your Cloudinary Console.
- Click the Add-ons (puzzle icon) in the left sidebar.
- Locate URL2PNG Website Screenshots.
- Click Install, select the Free plan, and agree to the terms.
Next, securely connect your Next.js app to your Cloudinary product environment.
Your .env.local file stores your credentials. Go to Settings > API Keys in Cloudinary to find these.
Create .env.local in your root folder:
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
To prevent “Must supply cloud_name” errors across different Server Components and Actions, we use a singleton pattern.
import { v2 as cloudinary } from "cloudinary";
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
});
export default cloudinary;
Code language: JavaScript (javascript)
With our environment ready, we can implement the core logic. Capturing a website viewport requires a Signed URL for security.
In app/actions.ts, we create a function that builds a dynamic URL using the url2png delivery type. :
"use server";
import cloudinary from "@/lib/cloudinary";
export async function generateScreenshot(websiteUrl: string) {
if (!websiteUrl) return null;
return cloudinary.url(websiteUrl, {
type: "url2png",
sign_url: true,
transformation: [
{ width: 1200, height: 630, crop: "fill", gravity: "north" },
{ quality: "auto", fetch_format: "auto" },
],
});
}
Code language: JavaScript (javascript)
Dynamic screenshots are generated on the fly. To save them for a community gallery, we must upload the result to our Media Library.
Add this in app/actions.ts:
export async function saveToGallery(imageUrl: string) {
return await cloudinary.uploader.upload(imageUrl, {
folder: "snapcard_gallery",
tags: ["link-card"],
timeout: 60000,
});
}
Code language: JavaScript (javascript)
Capturing a live website is heavy work. The browser must navigate, load assets, and render the viewport. This takes time, so a creative loading state is essential.
In components/Loader.tsx, we combine Lucide icons with Tailwind animations to give the user visual feedback.:
import { Loader2, Sparkles, Camera } from "lucide-react";
export function LoadingState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center p-12 border-2 border-dashed rounded-3xl bg-slate-50/50 animate-in fade-in zoom-in">
<div className="relative">
<Camera
size={32}
className="bg-primary text-primary-foreground p-2 rounded-full animate-pulse"
/>
<Loader2 className="absolute -inset-2 h-full w-full animate-spin text-primary/30" />
</div>
<p className="mt-4 font-semibold text-slate-700">{message}</p>
</div>
);
}
Code language: JavaScript (javascript)
Full logic: Check how the Home Page manages these states in the GitHub Repo.
The home page (app/page.tsx) acts as the orchestrator for the entire application. It is a Client Component because it needs to manage interactive state, such as the URL input and the loading sequences required for capturing and saving images.
Instead of building a massive single file, we integrate specialized components Input, Button, PreviewCard, and LoadingState. The main logic revolves around two primary workflows: Generating the screenshot and Saving it to the gallery.
The first workflow captures a live website viewport. This process uses a Server Action to safely generate a signed URL using Cloudinary’s url2png delivery type.
Core Logic:
- Trigger. User clicks “Generate”.
-
State. Set
loadingto true and clear any existing screenshot. -
Action. Call
generateScreenshot(url). This backend function signs the request to ensure only authorized captures are billed to your account. -
Result. Store the returned signed URL in the
screenshotstate to render thePreviewCard.
In app/page.tsx:
async function handleGenerate() {
if (!url) return;
setScreenshot(null);
setLoading(true);
try {
const result = await generateScreenshot(url);
setScreenshot(result);
} catch {
console.error("Capture failed");
} finally {
setLoading(false);
}
}
Code language: JavaScript (javascript)
Screenshots generated via url2png are dynamic and temporary. To display them in a persistent gallery, we must “promote” them to permanent assets.
Core logic:
- Trigger. User clicks “Save Permanent Copy”.
-
Action. Send the dynamic URL to
saveToGallery(). -
Backend logic. The server uses
cloudinary.uploader.uploadto pull the dynamic screenshot into a specific folder (e.g.,snapcard_gallery). - Timeout handling. Because capturing an external site takes time, the logic uses an extended 60-second timeout to ensure the upload doesn’t drop prematurely.
// Inside app/page.tsx
async function handleSave() {
if (!screenshot) return;
setSaving(true);
try {
await saveToGallery(screenshot);
alert("Successfully saved!");
} catch {
alert("Save failed");
} finally {
setSaving(false);
}
}
Code language: JavaScript (javascript)
Explore the full interactive UI here. https://github.com/musebe/link-to-card/blob/main/app/page.tsx
Now that the Home Page is assembled and saving snapshots, we need a way to display them. Instead of maintaining a separate database to track image metadata, we use the Cloudinary Search API to fetch assets from our snapcard_gallery folder in real-time.
Core logic:
- Server Component. The Gallery page is a Server Component, meaning it fetches data during the server-side rendering phase. This improves SEO and ensures users see a populated list immediately upon arrival.
-
Search expression. We query for all assets within the
folder:snapcard_galleryand sort them by the newest creation date first. -
Mapping. The results are mapped directly to optimized Next.js
<Image />components.
In app/gallery/page.tsx, the final implementation looks like this:
import Image from "next/image";
import cloudinary from "@/lib/cloudinary";
export default async function GalleryPage() {
const { resources } = await cloudinary.search
.expression("folder:snapcard_gallery")
.sort_by("created_at", "desc")
.execute();
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{resources.map((img: any) => (
<div
key={img.public_id}
className="relative aspect-video overflow-hidden rounded-xl border"
>
<Image
src={img.secure_url}
fill
className="object-cover"
alt="Website Snapshot"
/>
</div>
))}
</div>
);
}
Code language: JavaScript (javascript)
- Cloudinary acts as both your storage engine and your metadata provider. You don’t need a backend table to track which images belong in the gallery.
- As soon as a user clicks Save on the home page, the image is tagged and stored. The next time the Gallery page is refreshed, it appears instantly.
- Cloudinary serves the optimized
secure_url, while thefillproperty in Next.js ensures images scale perfectly within their responsive containers.
- When using the
url2pngdelivery type, signing the URL ensures your account quota is only used for authorized requests from your server. - By uploading dynamic screenshots to your gallery via the uploader, you promote them to permanent assets. This avoids re-triggering the capture engine (and incurring extra costs) for repeated views.
- Cloudinary behaves like a headless browser for this add-on. Always increase your server-side timeouts to at least 60 seconds to ensure successful renders of slower or asset-heavy websites.
Ready to build your own link-preview engine?
- Live Demo: SnapCard AI
- Full Source Code: musebe/link-to-card
Sign up for a free Cloudinary account and start capturing the web today.