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).