Using vercel blob as image hosting and display

Posted on Apr. 26th, 2026blob

Vercel blob

Vercel Blob Next.js Starter is an open-source project officially released by Vercel. Built on the Next.js framework, it integrates the Vercel Blob storage service. In essence, it functions as an image hosting system with backend capabilities: upon image upload, a CDN link is automatically returned. This approach is an order of magnitude more efficient and less cumbersome compared to traditional solutions.

Demo: vercel blob

One-click deployment:

Blob list

ts// You need to obtain the relevant token from the Vercel dashboard (configure the .env.local file).
// BLOB_READ_WRITE_TOKEN="************"
// api/vercel-blobs/route.ts
import { list } from "@vercel/blob";
import { NextResponse } from "next/server";
 
export async function GET() {
  try {
    // 注意:list 函数需要在 Node.js 环境下运行
    const { blobs } = await list({
      // 可选:按路径前缀过滤,如 'images/'
      // prefix: 'your-folder/',
      // 可选:限制单次返回数量,默认1000,最大1000[reference:4]
      limit: 1000,
    });
    return NextResponse.json({ blobs });
  } catch (error) {
    console.error("Listing error:", error);
    return NextResponse.json({ error: "failed to list" }, { status: 500 });
  }
}

Page building

tsx// app/waterfall/page.tsx
"use client";
 
import React, {
  useState,
  useEffect,
  useRef,
  useMemo,
  useCallback,
} from "react";
import Image from "next/image";
import { PhotoView } from "react-photo-view";
 
// ---------- 类型定义 ----------
interface PhotoItem {
  id: number;
  src: string;
  width: number;
  height: number;
  alt: string;
}
 
interface Position {
  top: number;
  left: number;
  width: number;
  height: number;
}
 
interface WaterfallLayout {
  positions: Position[];
  totalHeight: number;
}
 
interface BlobItem {
  url: string;
}
 
interface ApiResponse {
  blobs: BlobItem[];
}
 
// ---------- 工具函数 ----------
const calcColumns = (
  containerWidth: number,
  minColumnWidth: number,
  gap: number
): number => {
  if (containerWidth <= 0) return 1;
  const columns = Math.floor((containerWidth + gap) / (minColumnWidth + gap));
  return Math.max(1, Math.min(columns, 6));
};
 
const computeLayout = (
  photos: PhotoItem[],
  columns: number,
  containerWidth: number,
  gap: number
): WaterfallLayout => {
  if (!photos.length || columns <= 0 || containerWidth <= 0) {
    return { positions: [], totalHeight: 0 };
  }
 
  const totalGapWidth = gap * (columns - 1);
  const columnWidth = (containerWidth - totalGapWidth) / columns;
  const columnHeights = new Array(columns).fill(0);
 
  const positions = photos.map((photo) => {
    const aspectRatio = photo.height / photo.width;
    const itemHeight = columnWidth * aspectRatio;
 
    let minHeightIndex = 0;
    for (let i = 1; i < columns; i++) {
      if (columnHeights[i] < columnHeights[minHeightIndex]) {
        minHeightIndex = i;
      }
    }
 
    const top = columnHeights[minHeightIndex];
    const left = minHeightIndex * (columnWidth + gap);
    columnHeights[minHeightIndex] += itemHeight + gap;
 
    return { top, left, width: columnWidth, height: itemHeight };
  });
 
  const totalHeight = Math.max(...columnHeights) - gap;
  return { positions, totalHeight };
};
 
// ---------- 瀑布流相册组件 ----------
interface WaterfallGalleryProps {
  photos: PhotoItem[];
  gap?: number;
  minColumnWidth?: number;
  onImageError?: (src: string) => void;
}
 
const WaterfallGallery: React.FC<WaterfallGalleryProps> = ({
  photos,
  gap = 12,
  minColumnWidth = 260,
  onImageError,
}) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [containerWidth, setContainerWidth] = useState<number>(0);
 
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
 
    const resizeObserver = new ResizeObserver((entries) => {
      if (entries[0]) {
        setContainerWidth(entries[0].contentRect.width);
      }
    });
 
    resizeObserver.observe(container);
    setContainerWidth(container.clientWidth);
 
    return () => resizeObserver.disconnect();
  }, []);
 
  const columns = useMemo(() => {
    return calcColumns(containerWidth, minColumnWidth, gap);
  }, [containerWidth, minColumnWidth, gap]);
 
  const layout = useMemo(() => {
    return computeLayout(photos, columns, containerWidth, gap);
  }, [photos, columns, containerWidth, gap]);
 
  const handleImageError = useCallback(
    (src: string) => {
      onImageError?.(src);
      console.warn(`图片加载失败: ${src}`);
    },
    [onImageError]
  );
 
  return (
    <div
      ref={containerRef}
      className="relative w-full"
      style={{ minHeight: layout.totalHeight }}
    >
      {photos.map((photo, idx) => {
        const pos = layout.positions[idx];
        if (!pos) return null;
 
        return (
          <div
            key={photo.id}
            className="absolute overflow-hidden rounded-md shadow-md transition-all duration-300 hover:shadow-xl hover:scale-[1.02]"
            style={{
              top: pos.top,
              left: pos.left,
              width: pos.width,
              height: pos.height,
            }}
          >
            <div className="relative w-full h-full">
              <PhotoView src={photo.src}>
                <Image
                  src={photo.src}
                  alt={photo.alt}
                  fill
                  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
                  className="object-cover cursor-pointer"
                  loading="eager"
                  onError={() => handleImageError(photo.src)}
                />
              </PhotoView>
            </div>
          </div>
        );
      })}
    </div>
  );
};
 
// ---------- 获取图片宽高(使用 createElement 避免构造参数问题) ----------
const getImageDimensions = (
  url: string
): Promise<{ width: number; height: number }> => {
  return new Promise((resolve, reject) => {
    const img = document.createElement("img"); // 替代 new Image()
    img.onload = () => {
      resolve({ width: img.naturalWidth, height: img.naturalHeight });
    };
    img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
    img.src = url;
  });
};
 
// ---------- 主页面组件 ----------
export default function WaterfallPage() {
  const [photos, setPhotos] = useState<PhotoItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
 
        const response = await fetch("/api/vercel-blobs");
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = (await response.json()) as ApiResponse;
        const blobs = data.blobs || [];
 
        if (blobs.length === 0) {
          setPhotos([]);
          setLoading(false);
          return;
        }
 
        const photoItems = await Promise.all(
          blobs.map(async (blob: BlobItem, idx: number) => {
            const { url } = blob;
            try {
              const { width, height } = await getImageDimensions(url);
              return {
                id: idx,
                src: url,
                width,
                height,
                alt: `Photo ${idx + 1}`,
              };
            } catch (err) {
              console.error(`Failed to get dimensions for ${url}:`, err);
              // 降级默认尺寸 400x300
              return {
                id: idx,
                src: url,
                width: 400,
                height: 300,
                alt: `Photo ${idx + 1}`,
              };
            }
          })
        );
 
        setPhotos(photoItems);
      } catch (err) {
        console.error("Failed to load photos:", err);
        setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        setLoading(false);
      }
    };
 
    fetchData();
  }, []);
 
  if (loading) {
    return (
      <main>
        <h1 className="font-bold text-2xl mb-4">Photos</h1>
        <p className="mb-5 text-sm text-[#8a8a8a]">Loading moments...</p>
        <div className="flex justify-center py-20">
          <div className="animate-pulse text-gray-500">
            ✨ Loading gallery ✨
          </div>
        </div>
      </main>
    );
  }
 
  if (error) {
    return (
      <main>
        <h1 className="font-bold text-2xl mb-4">Photos</h1>
        <p className="mb-5 text-sm text-red-500">
          Failed to load gallery: {error}
        </p>
      </main>
    );
  }
 
  if (photos.length === 0) {
    return (
      <main>
        <h1 className="font-bold text-2xl mb-4">Photos</h1>
        <p className="mb-5 text-sm text-[#8a8a8a]">No photos found.</p>
      </main>
    );
  }
 
  return (
    <main>
      <h1 className="font-bold text-2xl mb-4">Photos</h1>
      <p className="mb-5 text-sm text-[#8a8a8a]">
        A collection of moments captured through the lens. <br />
        Beauty, as I see it.
      </p>
 
      <div className="bg-white/50 dark:bg-gray-800/30 rounded-md backdrop-blur-sm">
        <WaterfallGallery
          photos={photos}
          gap={12}
          minColumnWidth={260}
          onImageError={(src) => console.warn(`Failed: ${src}`)}
        />
      </div>
    </main>
  );
}

Other

When deploying a project on Vercel, don't forget to manually add the necessary configurations from .env.local (set them manually in the Environment Variables section of the project dashboard).