Sometimes the best UX fix is the one that requires no new infrastructure. Here's how we added YouTube thumbnails and real video titles to ClipCrafter project cards — using only what was already in the database and what YouTube freely provides.
The problem
Project cards on the dashboard looked like this:
- Title:
YouTube video (8fcJ4Y-uxCw) - Thumbnail: none
- Status badge, timestamp, action buttons
Not great. Users couldn't tell their projects apart at a glance. The fix seemed obvious — store the real title and a thumbnail URL. But that would mean:
- A database migration to add a
thumbnail_urlcolumn - A backfill for existing rows
- Storing the title at upload time
We did none of those things.
Fix 1: Real titles via YouTube's oEmbed API
YouTube's oEmbed endpoint returns video metadata including the title — no API key required:
GET https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=VIDEO_ID&format=json
Response:
{
"title": "The actual video title",
"author_name": "Channel Name",
"thumbnail_url": "https://i.ytimg.com/vi/.../hqdefault.jpg",
...
}
We call this during project creation, before inserting to the database:
async function fetchYoutubeTitle(url: string): Promise<string | null> {
try {
const res = await fetch(
`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`
);
if (!res.ok) return null;
const data = await res.json() as { title?: string };
return data.title ?? null;
} catch {
return null;
}
}
async function submitYoutubeUrl(url: string): Promise<void> {
const ytMatch = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
const fallback = ytMatch ? `YouTube video (${ytMatch[1]})` : url;
const title = (await fetchYoutubeTitle(url)) ?? fallback;
// title is now "How to Build a Rocket" instead of "YouTube video (dQw4w9WgXcQ)"
await createProject({ title, type: "youtube", youtubeUrl: url });
}
A few things worth noting:
- The fallback is always safe. If the oEmbed call fails (private video, network error, rate limit), we fall back to the ID-based title. The user flow never breaks.
- The title is stored, not fetched on read. This means no repeated API calls and the title survives even if the video is later deleted or made private.
- No API key. YouTube's oEmbed endpoint is publicly accessible. It works for any public video.
Fix 2: Thumbnails from a predictable URL pattern
YouTube serves thumbnails at deterministic URLs:
https://img.youtube.com/vi/{VIDEO_ID}/mqdefault.jpg // 320×180
https://img.youtube.com/vi/{VIDEO_ID}/hqdefault.jpg // 480×360
https://img.youtube.com/vi/{VIDEO_ID}/maxresdefault.jpg // max resolution
No API call needed — just the video ID. The question was: how do we get the video ID from our stored data?
For new projects, we store the normalized YouTube URL in r2_key (e.g. https://www.youtube.com/watch?v=8fcJ4Y-uxCw), so extracting the ID is straightforward.
For old projects, the stored r2_key is the R2 object path of the downloaded video file (videos/{uuid}/video.mp4) — no video ID there. But old projects have a predictable title format: YouTube video (8fcJ4Y-uxCw). The ID is right there in the title.
This gave us a two-path function with a graceful fallback:
function getYoutubeThumbnail(project: Project): string | null {
if (project.type !== "youtube") return null;
// New projects: r2_key is the YouTube URL
const fromKey = project.r2_key?.match(/[?&]v=([a-zA-Z0-9_-]{11})/)?.[1];
if (fromKey) return `https://img.youtube.com/vi/${fromKey}/mqdefault.jpg`;
// Old projects: r2_key is a storage path, but title has the ID
const fromTitle = project.title.match(/\(([a-zA-Z0-9_-]{11})\)$/)?.[1];
return fromTitle ? `https://img.youtube.com/vi/${fromTitle}/mqdefault.jpg` : null;
}
No migration. No backfill script. Every existing project gets thumbnails automatically.
Wiring it into the card
The project card uses Next.js <Image> with the YouTube thumbnail domain added to next.config.ts:
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [{ protocol: "https", hostname: "img.youtube.com" }],
},
};
The card component:
function YoutubeThumbnail({ project }: { project: Project }) {
const src = getYoutubeThumbnail(project);
if (!src) return null;
return (
<div className="relative w-full aspect-video">
<Image
src={src}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
unoptimized
/>
</div>
);
}
export default function ProjectCard({ project }: ProjectCardProps) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
<YoutubeThumbnail project={project} />
<div className="p-4 flex flex-col gap-3">
{/* title, status, timestamp, actions */}
</div>
</div>
);
}
unoptimized is set because YouTube's CDN already serves optimized images — running them through Next.js image optimization would be redundant.
The broader pattern
Before reaching for a migration or a new API integration, ask:
- Is the data already in the database? The video ID was in the title all along.
- Does the platform expose it for free? YouTube's oEmbed gives us the real title with no authentication.
- Can the URL be derived deterministically? Thumbnail URLs follow a predictable pattern once you have the ID.
Data you already have, combined with free platform APIs and predictable URL structures, can cover a lot of ground before you need to store anything new.
We're building ClipCrafter — AI-powered video clip extraction for creators. If you found this useful, follow along for more engineering notes.
Top comments (0)