Adding Video Support: YouTube Integration and Feature Flags

Jun 6
·

Now that blog posts were working end-to-end, I wanted to add a second content type: videos. This meant building a new data model, integrating with the YouTube Data API for live view/like counts, and — most importantly — making the whole feature toggleable so I could ship incrementally without breaking anything.

This post covers Phase 4: seven tasks that took the site from "blog only" to "blog + video" with category filtering, animated previews, and a feature flag system.

Video Content Type + Seed Data

First, the data model. I created a Video.ts definition alongside the existing Post.ts:

// data/content/videos/Video.ts
import { z } from "zod"
import { getVideoDetails } from "./youtube"
import { formatVideoPreview } from "./formatters"
 
export const Video = z.object({
  id: z.string(),
  title: z.string(),
  description: z.string(),
  publishedAt: z.string(),
  categories: z.array(
    z.object({
      title: z.string(),
      slug: z.string(),
    }),
  ),
  youtube: z.object({
    videoId: z.string(),
    views: z.number(),
    likes: z.number(),
    duration: z.string(),
  }),
})
 
export type Video = z.infer<typeof Video>
 
export async function getVideo(videoId: string): Promise<Video> {
  const details = await getVideoDetails(videoId)
  return Video.parse({
    id: videoId,
    ...details,
  })
}

The key thing here is the youtube field. It's not something I store manually — it's computed by calling getVideoDetails(), which fetches live data from the YouTube API. The categories, title, and description are my own metadata that I maintain in seed files.

The Seed Data Pattern

Here's the pattern I settled on: when the YOUTUBE_DATA_API_KEY environment variable is set, fetch real data from the API. When it's not, fall back to a local JSON seed file. This way the site works in development without any API keys configured.

// data/content/videos/youtube.ts
import seedData from "./youtube-seed.json"
 
export async function getVideoDetails(videoId: string) {
  if (!process.env.YOUTUBE_DATA_API_KEY) {
    const seed = seedData[videoId as keyof typeof seedData]
    if (!seed) throw new Error(`No seed data for video: ${videoId}`)
    return seed
  }
 
  const res = await fetch(
    `https://www.googleapis.com/youtube/v3/videos` +
      `?part=contentDetails,statistics,snippet` +
      `&id=${videoId}` +
      `&key=${process.env.YOUTUBE_DATA_API_KEY}`,
  )
 
  const json = await res.json()
  const item = json.items?.[0]
  if (!item) throw new Error(`Video not found: ${videoId}`)
 
  return {
    title: item.snippet.title,
    description: item.snippet.description,
    views: parseInt(item.statistics.viewCount, 10),
    likes: parseInt(item.statistics.likeCount, 10),
    duration: formatDuration(item.contentDetails.duration),
  }
}

The seed file is just a JSON object keyed by video ID:

{
  "dQw4w9WgXcQ": {
    "title": "Rick Astley - Never Gonna Give You Up",
    "description": "The official video for...",
    "views": 1500000000,
    "likes": 16000000,
    "duration": "03:33"
  }
}

I'll reuse this same seed fallback pattern later when I add tweets. It keeps the dev experience smooth — clone the repo, run npm dev, everything works without configuring external services.

formatDuration

YouTube returns durations in ISO 8601 format like PT1M2S. That's not what I want to display, so I wrote a converter:

function formatDuration(iso: string): string {
  const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
  if (!match) return iso
 
  const hours = parseInt(match[1] || "0", 10)
  const minutes = parseInt(match[2] || "0", 10)
  const seconds = parseInt(match[3] || "0", 10)
 
  const pad = (n: number) => n.toString().padStart(2, "0")
 
  if (hours > 0) {
    return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
  }
  return `${pad(minutes)}:${pad(seconds)}`
}

PT1M2S becomes 01:02. PT1H30M15S becomes 01:30:15. Simple enough.

formatVideoPreview

For listing pages, I don't need the full video object. I have a formatter that picks just the fields the preview cards need:

// data/content/videos/formatters.ts
import type { Video } from "./Video"
 
export function formatVideoPreview(video: Video) {
  return {
    id: video.id,
    title: video.title,
    description: video.description,
    publishedAt: video.publishedAt,
    categories: video.categories,
    duration: video.youtube.duration,
    youtubeId: video.youtube.videoId,
  }
}

This keeps the data passed to client components minimal. The full views and likes counts get fetched separately on the client — more on that in a bit.

YouTube API Proxy

The YouTube Data API requires an API key. I absolutely did not want that key exposed in client-side code, so I created a thin API route that acts as a proxy.

// pages/api/youtube/[id].ts
import type { NextApiRequest, NextApiResponse } from "next"
import { z } from "zod"
 
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const id = z.string().parse(req.query.id)
 
  const data = await fetch(
    `https://www.googleapis.com/youtube/v3/videos` +
      `?part=statistics` +
      `&id=${id}` +
      `&key=${process.env.YOUTUBE_DATA_API_KEY}`,
  )
 
  const json = await data.json()
  const item = json.items?.[0]
 
  if (!item) {
    return res.status(404).json({ error: "Video not found" })
  }
 
  const views = parseInt(item.statistics.viewCount, 10)
  const likes = parseInt(item.statistics.likeCount, 10)
 
  res.setHeader("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=1800")
 
  res.json({ views, likes })
}

Why a proxy? Two reasons:

  1. The API key stays server-side. The client hits /api/youtube/dQw4w9WgXcQ and never sees the key. Even though YouTube API keys are free and relatively low-risk, it's a good habit.

  2. Caching. The Cache-Control header tells Vercel's edge to cache the response for an hour, with a 30-minute stale-while-revalidate window. This means I'm not hammering the YouTube API on every page view. View counts don't change that fast — a one-hour delay is fine.

I also validate the id parameter with Zod. Even in a simple proxy, input validation prevents weird edge cases.

VideoPostPreview + Metrics

Now for the fun part — the preview card. I wanted video cards to show live view and like counts, but without blocking the initial render. The solution: lazy-load the metrics only when the card scrolls into view.

useVideoMetrics

// hooks/useVideoMetrics.ts
import useSWR from "swr"
 
const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
export function useVideoMetrics(youtubeId: string) {
  const { data, error } = useSWR(`/api/youtube/${youtubeId}`, fetcher, {
    revalidateOnFocus: false,
    dedupingInterval: 60000,
  })
 
  return {
    views: data?.views,
    likes: data?.likes,
    isLoading: !data && !error,
    isError: error,
  }
}

SWR handles deduplication, caching, and error states. The dedupingInterval: 60000 means if a user scrolls a video card out of view and back within a minute, it won't refetch — it'll use the cached data.

VideoPostPreview

// components/VideoPostPreview.tsx
import { motion } from "framer-motion"
import { useVideoMetrics } from "@/hooks/useVideoMetrics"
import { useEnabledOnFirstIntersection } from "@/hooks/useEnabledOnFirstIntersection"
import { formatNumber } from "@/lib/formatNumber"
 
interface VideoPostPreviewProps {
  title: string
  description: string
  publishedAt: string
  categories: { title: string; slug: string }[]
  duration: string
  youtubeId: string
}
 
export function VideoPostPreview({
  title,
  description,
  publishedAt,
  categories,
  duration,
  youtubeId,
}: VideoPostPreviewProps) {
  const { ref, enabled } = useEnabledOnFirstIntersection()
  const { views, likes, isLoading } = useVideoMetrics(enabled ? youtubeId : "")
 
  return (
    <motion.article
      ref={ref}
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.4 }}
      className="group relative"
    >
      {/* Thumbnail */}
      <div className="relative aspect-video overflow-hidden rounded-lg">
        <img
          src={`https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`}
          alt={title}
          className="h-full w-full object-cover transition-transform group-hover:scale-105"
        />
        <span className="absolute bottom-2 right-2 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
          {duration}
        </span>
      </div>
 
      {/* Content */}
      <div className="mt-3">
        <h3 className="text-lg font-semibold leading-tight">{title}</h3>
        <p className="text-muted-foreground line-clamp-2 mt-1 text-sm">{description}</p>
 
        {/* Categories */}
        <div className="mt-2 flex flex-wrap gap-1.5">
          {categories.map((cat) => (
            <span key={cat.slug} className="bg-muted rounded-full px-2.5 py-0.5 text-xs">
              {cat.title}
            </span>
          ))}
        </div>
 
        {/* Metrics — lazy loaded */}
        <div className="text-muted-foreground mt-2 flex items-center gap-3 text-sm">
          {isLoading ? (
            <span className="bg-muted h-4 w-16 animate-pulse rounded" />
          ) : (
            <>
              <span>{formatNumber(views)} views</span>
              <span>{formatNumber(likes)} likes</span>
            </>
          )}
        </div>
      </div>
    </motion.article>
  )
}

The useEnabledOnFirstIntersection hook is the same one I use for PostPreview. It uses an IntersectionObserver to flip enabled to true the first time the element enters the viewport. Until then, useVideoMetrics receives an empty string as the ID, which means SWR doesn't fire a request.

This is a pattern I really like — the component is self-contained. It decides when to fetch its own data based on visibility. No need for the parent to manage loading states or pass in visibility flags.

The YouTube thumbnail URL (img.youtube.com/vi/{id}/mqdefault.jpg) is a free, undocumented-but-reliable endpoint that gives you a medium-quality thumbnail. No API key needed. I could use maxresdefault.jpg for higher quality, but mqdefault loads faster and looks fine at card sizes.

Video Listing Integration

The existing listing page at [[...filter]].tsx already handled blog posts. I needed to merge videos into the same feed, sorted by date.

// pages/[[...filter]].tsx
export async function getStaticProps({ params }) {
  const posts = await getAllPosts()
  const videos = await getAllVideos()
 
  // Merge and sort by date
  const items = [
    ...posts.map((p) => ({ type: "post" as const, ...p })),
    ...videos.map((v) => ({ type: "video" as const, ...v })),
  ].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
 
  // Apply category filter if present
  const filter = params?.filter?.[0]
  const filtered = filter
    ? items.filter((item) => item.categories.some((c) => c.slug === filter))
    : items
 
  return { props: { items: filtered, filter } }
}

Then in the JSX, I render different components based on the type field:

{
  items.map((item) =>
    item.type === "post" ? (
      <PostPreview key={item.id} {...item} />
    ) : (
      <VideoPostPreview key={item.id} {...item} />
    ),
  )
}

This means blog posts and videos appear in the same chronological feed. A video from today shows up above a blog post from yesterday. Category filters work across both types — if you click "React", you see both React blog posts and React videos.

The sort is simple new Date().getTime() comparison. Since all my content has ISO date strings in publishedAt, this just works.

VideoPostPreview Animation

I used the same Framer Motion animation pattern as the blog PostPreview:

<motion.article
  ref={ref}
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.4 }}
>

This is intentional. I wanted both content types to feel like they belong together — same fade-in, same slide-up, same duration. Consistency in micro-interactions makes a site feel polished, even if each individual animation is subtle.

I considered adding a different animation for videos — maybe a scale effect or a different easing curve. But in practice, having two different card types with two different animations felt chaotic. The uniform treatment lets the content differentiate the cards, not the motion.

If you're building a multi-content-type site, I'd recommend this approach: define a shared animation config and reuse it everywhere. Something like:

// lib/animations.ts
export const cardAnimation = {
  initial: { opacity: 0, y: 20 },
  animate: { opacity: 1, y: 0 },
  transition: { duration: 0.4 },
}

Then every card component just spreads {...cardAnimation}. Change it once, it changes everywhere.

Video Category Index

I added a /videos/categories route that shows all video categories as clickable cards. The categories come from the video data itself — I don't maintain a separate category list.

// pages/videos/categories.tsx
import Link from "next/link"
import { getAllVideos } from "@/data/content/videos"
 
export async function getStaticProps() {
  const videos = await getAllVideos()
 
  const categories = videos
    .flatMap((v) => v.categories)
    .reduce((acc, cat) => {
      const existing = acc.find((c) => c.slug === cat.slug)
      if (existing) {
        existing.count++
      } else {
        acc.push({ ...cat, count: 1 })
      }
      return acc
    }, [] as { title: string; slug: string; count: number }[])
 
  return { props: { categories } }
}
 
export default function VideoCategories({
  categories,
}: {
  categories: { title: string; slug: string; count: number }[]
}) {
  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
      {categories.map((cat) => (
        <Link
          key={cat.slug}
          href={`/videos/${cat.slug}`}
          onClick={(e) => e.stopPropagation()}
          className="..."
        >
          <h3>{cat.title}</h3>
          <p>{cat.count} videos</p>
        </Link>
      ))}
    </div>
  )
}

The stopPropagation on the click handler is important here. The categories are rendered inside the video listing page layout, which has its own click handlers for navigation. Without stopPropagation, clicking a category card would also trigger the parent's handler, potentially navigating to the wrong place.

I used the same reduce pattern to aggregate categories that the blog uses. It's not the most elegant code, but it's clear about what it does: iterate through all videos, collect unique categories, and count how many videos belong to each.

Video Feature Flag

This was the most important part of the whole phase. I didn't want to ship videos to production until the whole feature was ready. The solution: a feature flag in the site config.

// siteConfig.ts
export const siteConfig = {
  // ...
  features: {
    video: process.env.NEXT_PUBLIC_ENABLE_VIDEO === "true",
    tweets: false, // Coming in Phase 5
  },
}

The flag is controlled by an environment variable, which means I can enable it in development (.env.local) without affecting the production build. When NEXT_PUBLIC_ENABLE_VIDEO is not set or is anything other than "true", videos are completely hidden.

Conditional Route Generation

The feature flag affects getStaticPaths. If videos are disabled, the video routes shouldn't be generated at all:

// pages/videos/[slug].tsx
export async function getStaticPaths() {
  if (!siteConfig.features.video) {
    return { paths: [], fallback: false }
  }
 
  const videos = await getAllVideos()
  const paths = videos.map((v) => ({
    params: { slug: v.id },
  }))
 
  return { paths, fallback: false }
}

When the flag is off, getStaticPaths returns an empty paths array. Next.js simply doesn't generate any video pages. No routes exist, no HTML is built, no data is fetched. Clean.

The same pattern applies to the category index and the video-specific parts of the main listing page:

// pages/[[...filter]].tsx
export async function getStaticProps({ params }) {
  const posts = await getAllPosts()
 
  let videos: Video[] = []
  if (siteConfig.features.video) {
    videos = await getAllVideos()
  }
 
  // ... merge and sort as before
}

The navigation component also respects the flag. There's no point showing a "Videos" link if the feature is off:

// components/Navigation.tsx
import { siteConfig } from "@/siteConfig"
 
const navItems = [
  { label: "Blog", href: "/" },
  ...(siteConfig.features.video ? [{ label: "Videos", href: "/videos" }] : []),
]
 
export function Navigation() {
  return (
    <nav>
      {navItems.map((item) => (
        <Link key={item.href} href={item.href}>
          {item.label}
        </Link>
      ))}
    </nav>
  )
}

The spread with a ternary is a clean way to conditionally include items. If the flag is off, the array simply doesn't include the Videos entry. No rendering, no DOM element, no dead link.

Why Feature Flags Matter

This pattern — flag-gated routes, flag-gated navigation, flag-gated data fetching — might seem like overkill for a personal site. But it taught me something valuable: feature flags change how you think about shipping.

Without the flag, I would have had to finish every single video feature before merging anything. With the flag, I could merge the video content type, the API proxy, the preview components, and the listing integration one at a time, knowing that none of it would leak into production until I flipped the switch.

This is the same pattern large companies use, just at a smaller scale. LaunchDarkly, Unleash, or even a simple config file — the principle is the same. Ship incomplete features safely by wrapping them in a toggle.

For a personal site, a config file is more than enough. No need for a third-party service. The NEXT_PUBLIC_ prefix means the flag is available at build time and in the browser, which is exactly what I need for conditional rendering.

Wrapping Up

Phase 4 added a whole new content type to the site. Here's what I built:

  • Video data model with YouTube API integration and seed data fallback
  • API proxy to keep the YouTube API key server-side
  • Lazy-loaded metrics using SWR and IntersectionObserver
  • Unified listing that merges posts and videos chronologically
  • Category filtering that works across both content types
  • Consistent animations shared between PostPreview and VideoPostPreview
  • Feature flag that controls route generation, data fetching, and navigation

The feature flag system was the biggest takeaway. It's a pattern I'll use for every feature going forward — including tweets, which is Phase 5.

Videos done. Phase 5 adds tweets.