# OG Image Explorer

> A dev tool to audit and preview Open Graph images from all routes in your app. Visual gallery with status indicators for broken images.

A shadcn/ui block from the [@elements](https://www.tryelements.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/elements/og-image-explorer).

- Page, with a live preview: https://shadcn.exchange/r/elements/og-image-explorer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/elements/og-image-explorer-light-9f99d21ca0c9a120.png
- Registry item: https://www.tryelements.dev/r/og-image-explorer.json
- Categories: media, seo, dev-tools
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.tryelements.dev/r/og-image-explorer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`

## Notes from the registry

Configure the `routes` prop with your app's routes and OG image URLs. The component auto-groups by route category and shows green/red borders for loaded/broken images.

## Files

### `registry/default/blocks/media/og-image-explorer/components/elements/og-image-explorer.tsx`

```tsx
"use client";

import { useMemo } from "react";

import { ImageOff } from "lucide-react";

import { cn } from "@/lib/utils";

import { OgCategorySection } from "./og-category-section";

interface OgRoute {
  path: string;
  title?: string;
  ogImageUrl: string;
  category?: string;
}

interface OgImageExplorerProps {
  routes: OgRoute[];
  maxPerCategory?: number;
  showPath?: boolean;
  showTitle?: boolean;
  className?: string;
}

function getCategoryFromPath(path: string): string {
  const segments = path.split("/").filter(Boolean);
  return segments[0] || "root";
}

export function OgImageExplorer({
  routes,
  maxPerCategory = 8,
  showPath = true,
  showTitle = true,
  className,
}: OgImageExplorerProps) {
  const groupedRoutes = useMemo(() => {
    const groups = new Map<string, OgRoute[]>();

    for (const route of routes) {
      const category = route.category || getCategoryFromPath(route.path);
      const existing = groups.get(category) || [];
      groups.set(category, [...existing, route]);
    }

    const sortedCategories = Array.from(groups.entries()).sort(([a], [b]) => {
      if (a === "root") return -1;
      if (b === "root") return 1;
      return a.localeCompare(b);
    });

    return sortedCategories;
  }, [routes]);

  if (routes.length === 0) {
    return (
      <div
        data-slot="og-image-explorer"
        className={cn(
          "flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center",
          className,
        )}
      >
        <ImageOff className="h-10 w-10 text-muted-foreground" />
        <div className="flex flex-col gap-1">
          <p className="text-sm font-medium text-foreground">
            No routes configured
          </p>
          <p className="text-xs text-muted-foreground">
            Add routes to explore your OG images
          </p>
        </div>
      </div>
    );
  }

  return (
    <div
      data-slot="og-image-explorer"
      className={cn("flex flex-col gap-8", className)}
    >
      <div className="flex items-center justify-between border-b pb-4">
        <div className="flex flex-col gap-1">
          <h1 className="text-xl font-bold text-foreground">
            OG Image Explorer
          </h1>
          <p className="text-sm text-muted-foreground">
            {routes.length} route{routes.length !== 1 ? "s" : ""} across{" "}
            {groupedRoutes.length} categor
            {groupedRoutes.length !== 1 ? "ies" : "y"}
          </p>
        </div>
      </div>

      {groupedRoutes.map(([category, categoryRoutes]) => (
        <OgCategorySection
          key={category}
          category={category}
          routes={categoryRoutes}
          maxItems={maxPerCategory}
          showPath={showPath}
          showTitle={showTitle}
        />
      ))}
    </div>
  );
}
```

### `registry/default/blocks/media/og-image-explorer/components/elements/og-category-section.tsx`

```tsx
"use client";

import { useState } from "react";

import { ChevronDown, ChevronUp } from "lucide-react";

import { cn } from "@/lib/utils";

import { OgImageCard } from "./og-image-card";

interface OgRoute {
  path: string;
  title?: string;
  ogImageUrl: string;
  category?: string;
}

interface OgCategorySectionProps {
  category: string;
  routes: OgRoute[];
  maxItems?: number;
  showPath?: boolean;
  showTitle?: boolean;
  className?: string;
}

export function OgCategorySection({
  category,
  routes,
  maxItems = 8,
  showPath = true,
  showTitle = true,
  className,
}: OgCategorySectionProps) {
  const [expanded, setExpanded] = useState(false);

  const hasMore = routes.length > maxItems;
  const visibleRoutes = expanded ? routes : routes.slice(0, maxItems);
  const hiddenCount = routes.length - maxItems;

  const formatCategoryName = (cat: string) => {
    if (cat === "root") return "Root";
    return cat.charAt(0).toUpperCase() + cat.slice(1);
  };

  return (
    <section
      data-slot="og-category-section"
      className={cn("flex flex-col gap-4", className)}
    >
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h2 className="text-lg font-semibold text-foreground">
            {formatCategoryName(category)}
          </h2>
          <span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
            {routes.length}
          </span>
        </div>

        {hasMore && (
          <button
            type="button"
            onClick={() => setExpanded(!expanded)}
            className={cn(
              "flex items-center gap-1 text-sm text-muted-foreground transition-colors",
              "hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md px-2 py-1",
            )}
          >
            {expanded ? (
              <>
                View less
                <ChevronUp className="h-4 w-4" />
              </>
            ) : (
              <>
                View {hiddenCount} more
                <ChevronDown className="h-4 w-4" />
              </>
            )}
          </button>
        )}
      </div>

      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
        {visibleRoutes.map((route) => (
          <OgImageCard
            key={route.path}
            path={route.path}
            title={route.title}
            ogImageUrl={route.ogImageUrl}
            showPath={showPath}
            showTitle={showTitle}
          />
        ))}
      </div>
    </section>
  );
}
```

### `registry/default/blocks/media/og-image-explorer/components/elements/og-image-card.tsx`

```tsx
"use client";

import { useState } from "react";

import { ExternalLink, ImageOff } from "lucide-react";

import { cn } from "@/lib/utils";

type ImageStatus = "loading" | "loaded" | "error";

interface OgImageCardProps {
  path: string;
  title?: string;
  ogImageUrl: string;
  showPath?: boolean;
  showTitle?: boolean;
  className?: string;
}

export function OgImageCard({
  path,
  title,
  ogImageUrl,
  showPath = true,
  showTitle = true,
  className,
}: OgImageCardProps) {
  const [status, setStatus] = useState<ImageStatus>("loading");

  const handleClick = () => {
    window.open(ogImageUrl, "_blank", "noopener,noreferrer");
  };

  const statusBorderColor = {
    loading: "border-muted-foreground/30",
    loaded: "border-green-500/50",
    error: "border-red-500/50",
  };

  return (
    <div
      data-slot="og-image-card"
      className={cn("group flex flex-col gap-2", className)}
    >
      <button
        type="button"
        onClick={handleClick}
        className={cn(
          "relative aspect-[1.91/1] w-full overflow-hidden rounded-lg border-2 transition-all duration-200",
          "hover:scale-[1.02] hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          "bg-muted",
          statusBorderColor[status],
        )}
      >
        {status === "loading" && (
          <div className="absolute inset-0 animate-pulse bg-muted" />
        )}

        {status === "error" ? (
          <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground">
            <ImageOff className="h-8 w-8" />
            <span className="text-xs">Failed to load</span>
          </div>
        ) : (
          <img
            src={ogImageUrl}
            alt={title || path}
            className={cn(
              "h-full w-full object-cover transition-opacity duration-200",
              status === "loading" ? "opacity-0" : "opacity-100",
            )}
            onLoad={() => setStatus("loaded")}
            onError={() => setStatus("error")}
          />
        )}

        <div className="absolute bottom-2 right-2 rounded-md bg-background/80 p-1.5 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
          <ExternalLink className="h-3.5 w-3.5 text-foreground" />
        </div>
      </button>

      <div className="flex flex-col gap-0.5 px-1">
        {showTitle && title && (
          <span className="truncate text-sm font-medium text-foreground">
            {title}
          </span>
        )}
        {showPath && (
          <span className="truncate text-xs text-muted-foreground font-mono">
            {path}
          </span>
        )}
      </div>
    </div>
  );
}
```

### `registry/default/blocks/media/og-image-explorer/routes/layout.tsx`

```tsx
export default function OgImageExplorerLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-background p-6 md:p-10">
      <div className="mx-auto max-w-7xl">{children}</div>
    </div>
  );
}
```

### `registry/default/blocks/media/og-image-explorer/routes/page.tsx`

```tsx
// Auto-generated by scripts/generate-og-routes.ts
// Run: bun run scripts/generate-og-routes.ts

import { OgImageExplorer } from "../../components/elements/og-image-explorer";

const BASE_URL = "https://tryelements.dev";

const ROUTES = [
  // Pages (root-level)
  { path: "/", title: "Home", category: "pages" },
  { path: "/contributors", title: "Contributors", category: "pages" },
  { path: "/sponsor", title: "Sponsor", category: "pages" },
  // Providers
  { path: "/docs", title: "Documentation", category: "providers" },
  { path: "/docs/clerk", title: "Clerk", category: "providers" },
  { path: "/docs/devtools", title: "Dev Tools", category: "providers" },
  { path: "/docs/github", title: "GitHub", category: "providers" },
  { path: "/docs/logos", title: "Brand Logos", category: "providers" },
  { path: "/docs/polar", title: "Polar", category: "providers" },
  { path: "/docs/theme", title: "Theme", category: "providers" },
  { path: "/docs/tinte", title: "Tinte", category: "providers" },
  { path: "/docs/uploadthing", title: "UploadThing", category: "providers" },
  // Components - Dev Tools
  {
    path: "/docs/devtools/og-image-explorer",
    title: "OG Image Explorer",
    category: "devtools",
  },
  // Components - GitHub
  {
    path: "/docs/github/github-contributions",
    title: "GitHub Contributions",
    category: "github",
  },
  {
    path: "/docs/github/github-stars",
    title: "GitHub Stars",
    category: "github",
  },
  // Components - Polar
  {
    path: "/docs/polar/polar-sponsorship",
    title: "Polar Sponsorship",
    category: "polar",
  },
  // Components - Theme
  {
    path: "/docs/theme/theme-switcher-button",
    title: "Theme Switcher Button",
    category: "theme",
  },
  {
    path: "/docs/theme/theme-switcher-classic",
    title: "Theme Switcher Classic",
    category: "theme",
  },
  {
    path: "/docs/theme/theme-switcher-dropdown",
    title: "Theme Switcher Dropdown",
    category: "theme",
  },
  {
    path: "/docs/theme/theme-switcher-multi-button",
    title: "Theme Switcher Multi Button",
    category: "theme",
  },
  {
    path: "/docs/theme/theme-switcher-switch",
    title: "Theme Switcher Switch",
    category: "theme",
  },
  {
    path: "/docs/theme/theme-switcher-toggle",
    title: "Theme Switcher Toggle",
    category: "theme",
  },
  // Components - Tinte
  {
    path: "/docs/tinte/tinte-editor",
    title: "Tinte Theme Editor",
    category: "tinte",
  },
  // Components - UploadThing
  {
    path: "/docs/uploadthing/uploadthing-button",
    title: "UploadThing Button",
    category: "uploadthing",
  },
  {
    path: "/docs/uploadthing/uploadthing-dropzone",
    title: "UploadThing Dropzone",
    category: "uploadthing",
  },
].map((route) => ({
  ...route,
  ogImageUrl: `${BASE_URL}${route.path}/opengraph-image`,
}));

export default function OgImageExplorerPage() {
  return <OgImageExplorer routes={ROUTES} maxPerCategory={8} />;
}
```
