# Build Imgur-style image hosting with Nuxt and Vercel Blob

**Author:** Anshuman Bhardwaj

---

Build an image host where anyone can paste a screenshot and get a shareable link back in seconds. The app pairs a [Nuxt](https://vercel.com/docs/frameworks/full-stack/nuxt) frontend with [Vercel Blob](https://vercel.com/docs/vercel-blob) for storage, uploads files directly from the browser to Vercel Blob, then serves public blob URLs through Vercel’s CDN.

In this guide, you'll start with a Nuxt project, add a paste/drop/browse uploader, mint client-upload tokens in a server route, then make the sharing layer production-ready with random image names, ISR preview pages, edge caching, and [Vercel Web Analytics](https://vercel.com/docs/analytics) custom events.

Deploy the template now, or follow the steps below to build it.

## Quick start with an AI coding agent

### Vercel plugin

The [Vercel Plugin](https://vercel.com/docs/agent-resources/vercel-plugin) turns your AI coding agent (e.g., OpenAI Codex, Claude Code, or Cursor) into a Vercel expert. It adds skills, slash commands, and current knowledge of the tools this template uses, including Vercel Blob and Web Analytics. The plugin is optional; it isn't required to use the template or to follow this guide.

`npx plugins add vercel/vercel-plugin`

## Prerequisites

Before you begin, make sure you have:

- Node.js 22+ and a package manager (e.g., [pnpm](https://pnpm.io/))
  
- A [Vercel account](https://vercel.com/signup)
  
- Vercel CLI installed (`npm i -g vercel`)
  

## How it works

The app is a single Nuxt project with a browser uploader and two server routes:

- The uploader page at `/` accepts images by paste, drag and drop, or file picker.
  
- A server route at `/api/upload` mints short-lived client-upload tokens with `handleUpload`.
  
- The browser uploads the file directly to Vercel Blob with `upload`, so image bytes never pass through your server.
  
- Every upload gets a random 10-character name, and the app hands out its own `/i/<name>` preview link instead of the storage URL.
  
- A metadata route at `/api/image/<name>` resolves blob details with `head()` and caches responses on the CDN.
  
- Preview pages render with ISR, and Vercel Web Analytics tracks uploads, rejections, and link copies.
  

## Steps

### 1\. Create the project

Start from an empty workspace. Scaffold a Nuxt app, then add the storage, naming, and analytics dependencies:

`npx nuxi@latest init nuxt-imgur-clone cd nuxt-imgur-clone pnpm add @vercel/blob nanoid @vercel/analytics`

The Nuxt app owns everything: the uploader UI, the preview pages, and the server routes that talk to Vercel Blob.

Nuxt only loads `.env` by default, and the Vercel CLI writes environment variables to `.env.local`. Point the dev script at that file so local development picks up the Blob token:

`{ "scripts": { "build": "nuxt build", "dev": "nuxt dev --dotenv .env.local", "generate": "nuxt generate", "preview": "nuxt preview" } }`

At this point, the repository has the same shape the rest of the guide assumes: `app/` for pages, `server/api/` for server routes, and `shared/utils/` for code that runs on both sides.

### 2\. Create a Blob store

Link the project and create a public Blob store from the CLI:

`vercel link vercel blob create-store goodimg-images --access public --yes`

The `create-store` command creates the store, connects it to the linked project, and pulls the development environment variables into `.env.local`, including `BLOB_READ_WRITE_TOKEN`. If you need to refresh them later, run:

`vercel env pull .env.local`

`BLOB_READ_WRITE_TOKEN` is the only environment variable the app needs. The server uses it to sign client-upload tokens.

### 3\. Define the image rules once

Uploads are validated in the browser and enforced on the server, so both sides need the same rules. Nuxt auto-imports everything in `shared/utils/` into both the app and the server:

`import { customAlphabet } from 'nanoid' export const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', ] as const export const MAX_IMAGE_SIZE = 10 * 1024 * 1024` For consistency and to avoid exposing original filenames, the app generates a random 10-character alphanumeric name with [nanoid](https://github.com/ai/nanoid):

``const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', } export const IMAGE_NAME_RE = /^[A-Za-z0-9]{10}\.(png|jpg|gif|webp)$/ const nanoid = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10, ) export function newImageName(contentType: string): string { return `${nanoid()}.${EXTENSIONS[contentType] ?? 'png'}` } export function isAllowedImageType(type: string | undefined | null): boolean { return !!type && (ALLOWED_IMAGE_TYPES as readonly string[]).includes(type) } export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` }`` ### 4\. Mint client-upload tokens in a server route Files larger than 4.5 MB cannot pass through a Vercel Function request body, so the browser uploads directly to Blob instead. That flow is secured by a token exchange: the browser asks your server for permission, and the server answers with a short-lived signed token that encodes exactly what the client is allowed to upload. `handleUpload` from `@vercel/blob/client` implements this. In Nuxt, it lives in a Nitro route at `server/api/upload.post.ts`: `import { handleUpload, type HandleUploadBody } from '@vercel/blob/client' import { toWebRequest } from 'h3' export default defineEventHandler(async (event) => { const body = (await readBody(event)) as HandleUploadBody try { return await handleUpload({ body, request: toWebRequest(event), onBeforeGenerateToken: async (pathname) => { if (!IMAGE_NAME_RE.test(pathname)) { throw new Error('Invalid image name') } return { allowedContentTypes: [...ALLOWED_IMAGE_TYPES], maximumSizeInBytes: MAX_IMAGE_SIZE, cacheControlMaxAge: 60 * 60 * 24 * 365, } }, }) } catch (error) { throw createError({ statusCode: 400, statusMessage: error instanceof Error ? error.message : 'Upload failed', }) } })` The restrictions returned from `onBeforeGenerateToken` are embedded inside the signed token itself. Vercel Blob enforces them at upload time, so even a client that bypasses the UI cannot push an SVG, an oversized file, or a hand-picked filename into the store. Uploads here are anonymous by design, Imgur-style. If your app has users, authenticate them inside `onBeforeGenerateToken` before returning a token. To learn more, see [authenticating client uploads](https://vercel.com/docs/vercel-blob/client-upload#authenticating-client-uploads).

### 5\. Build the uploader page

The uploader at `app/pages/index.vue` is a small state machine. One `status` ref drives which card is on screen:

`interface GalleryItem { src: string // object URL for the local thumbnail href: string // the /i/<name> share link } type Status = 'idle' | 'drag' | 'uploading' | 'done' type UploadMethod = 'paste' | 'drop' | 'browse' const status = ref<Status>('idle') const progress = ref(0) const previewSrc = ref('') const pageUrl = ref('') const errorMsg = ref('') const gallery = ref<GalleryItem[]>([]) // derived from status: highlight the dropzone and swap its label while dragging const dragging = computed(() => status.value === 'drag') const dropTitle = computed(() => dragging.value ? 'Release to upload' : 'Drag & drop your image here', )` The idle and drag states share one dropzone, which is a `<label>` for the hidden file input so clicking it opens the browser's picker: `<label v-if="status === 'idle' || status === 'drag'" for="gi-file" class="dropzone" :class="{ dragging }" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" > <div class="drop-title">{{ dropTitle }}</div> <div class="drop-sub">or click to browse — PNG, JPG, WebP, GIF up to 10MB</div> </label> <input id="gi-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,.png,.jpg,.jpeg,.gif,.webp" @change="onPick" />` This guide does not include the complete page. The [full file](https://github.com/vercel-labs/nuxt-imgur-clone/blob/main/app/pages/index.vue) also includes:

- The uploading card with a live progress bar and spinner.
  
- The success card with the share link, copy button, and "Upload another" reset.
  
- The recent uploads gallery grid and the header upload counter.
  
- The Geist-based dark theme styles.
  

### 6\. Accept paste, drop, and browse input

All three input paths merge into one `handleFile` function, tagged with the method that produced the file:

`function onPaste(e: ClipboardEvent) { const file = e.clipboardData?.files?.[0] if (file) { e.preventDefault() handleFile(file, 'paste') } } function onDrop(e: DragEvent) { e.preventDefault() if (status.value === 'drag') status.value = 'idle' handleFile(e.dataTransfer?.files?.[0], 'drop') } function onPick(e: Event) { const input = e.target as HTMLInputElement handleFile(input.files?.[0], 'browse') input.value = '' } onMounted(() => window.addEventListener('paste', onPaste)) onBeforeUnmount(() => window.removeEventListener('paste', onPaste))` The paste listener sits on `window`, so `Cmd/Ctrl+V` works anywhere on the page without needing to focus a specific element first. `handleFile` validates against the shared rules before anything leaves the browser: `async function handleFile(file: File | null | undefined, method: UploadMethod) { if (!file) return if (!isAllowedImageType(file.type)) { errorMsg.value = 'Only PNG, JPG, WebP, and GIF images are supported.' return } if (file.size > MAX_IMAGE_SIZE) { errorMsg.value = 'That image is over 10MB.' return } // ...then the upload, covered in step 7 }` Client-side validation is a courtesy; the signed token from step 4 is the enforcement. ### 7\. Upload directly to Blob with real progress The upload itself is one call to `upload` (imported from `@vercel/blob/client` at the top of the file). It requests a token from `/api/upload`, streams the file to Blob storage, and reports progress along the way. This picks up inside `handleFile` where step 6 left off, right after the validation checks: ``// inside handleFile(), after the validation checks previewSrc.value = URL.createObjectURL(file) status.value = 'uploading' const blob = await upload(newImageName(file.type), file, { access: 'public', handleUploadUrl: '/api/upload', onUploadProgress: ({ percentage }) => { progress.value = percentage }, }) pageUrl.value = `${window.location.origin}/i/${encodeURIComponent(blob.pathname)}` gallery.value = [{ src: previewSrc.value, href: pageUrl.value }, ...gallery.value].slice(0, 8) status.value = 'done'`` Two important details are: - The first argument is `newImageName(file.type)`, not `file.name`. The original filename never leaves the browser.    - The success card shows `pageUrl`, the `/i/<name>` link. The storage URL brings no utility to a visitor that the preview page doesn't already provide, and hiding it keeps every shared link on your domain.    The local preview uses `URL.createObjectURL(file)`, so the thumbnail renders instantly instead of waiting for a network round trip. ### 8\. Serve shareable preview pages The share link resolves to `app/pages/i/[name].vue`, an Imgur-style page that renders the image with its metadata. It fetches blob details during SSR and builds the canonical share URL from the request: ``const route = useRoute() const name = String(route.params.name ?? '') const { data: image, error } = await useFetch(`/api/image/${encodeURIComponent(name)}`) const requestUrl = useRequestURL() const shareUrl = computed(() => `${requestUrl.origin}/i/${encodeURIComponent(name)}`)`` The shape of `image` is whatever the `/api/image/<name>` route returns, and you'll see the exact fields (`url`, `pathname`, `contentType`, `size`, `uploadedAt`) in step 9. The template shows the image, its type, size, and upload date, and a copy button for the share URL. The blob URL appears only as the `<img>` source, which keeps pixel delivery on Blob's CDN without surfacing the storage host in the UI: `<div class="preview view-preview"> <img :src="image.url" :alt="image.pathname" /> </div> <div class="url-row"> <span class="url-field">{{ shareUrl }}</span> <button class="copy-btn" :class="{ copied }" @click="onCopy"> {{ copied ? 'Copied' : 'Copy' }} </button> </div>` Open Graph tags make the links unfurl properly in chat apps and social feeds: ``useSeoMeta({ title: () => (image.value ? `${image.value.pathname} — GoodImg` : 'Image not found — GoodImg'), ogTitle: () => image.value?.pathname ?? 'Image not found', ogDescription: 'Fast, free image hosting. No account needed.', ogImage: () => image.value?.url, twitterCard: 'summary_large_image', })`` For brevity, this guide does not include the whole page. The [full file](https://github.com/vercel-labs/nuxt-imgur-clone/blob/main/app/pages/i/%5Bname%5D.vue) also includes:

- The styled not-found card for deleted or mistyped links.
  
- The shared header with a link back to the uploader.
  
- The copy-button state handling.
  

### 9\. Resolve blob metadata on the server

The preview page needs the blob's URL, size, and upload date. A Nitro route at `server/api/image/[name].get.ts` resolves them with `head` from `@vercel/blob`: `import { head, BlobNotFoundError } from '@vercel/blob' export default defineEventHandler(async (event) => { const name = decodeURIComponent(getRouterParam(event, 'name') ?? '') if (!name) { throw createError({ statusCode: 400, statusMessage: 'Missing image name' }) } try { const blob = await head(name) if (!isAllowedImageType(blob.contentType)) { throw createError({ statusCode: 404, statusMessage: 'Image not found' }) } return { pathname: blob.pathname, url: blob.url, downloadUrl: blob.downloadUrl, contentType: blob.contentType, size: blob.size, uploadedAt: blob.uploadedAt, } } catch (error) { if (error instanceof BlobNotFoundError) { throw createError({ statusCode: 404, statusMessage: 'Image not found' }) } throw error } })` `head` accepts a pathname or a full URL and throws `BlobNotFoundError` when the blob doesn't exist, which maps cleanly onto a 404. The content-type check means that even if something unexpected ever landed in the store, the app would refuse to serve a page for it. ### 10\. Cache at the edge Most read paths can be cached because this template generates unique pathnames and does not overwrite blobs. Blob URLs never change because names are random and never reused. So, the upload token from step 4 sets `cacheControlMaxAge` to a full year instead of the default month: `cacheControlMaxAge: 60 * 60 * 24 * 365,` The home page has no per-request data, so it prerenders to static HTML at build time. Preview pages change only if the blob is deleted, so they use ISR: rendered once per URL, cached at the edge for an hour, regenerated in the background after that. Both are route rules in `nuxt.config.ts`: `export default defineNuxtConfig({ // ... experimental: { payloadExtraction: false }, routeRules: { '/': { prerender: true }, '/i/**': { isr: 3600 }, }, // ... })` The metadata API sets its own CDN policy. Responses are cached at the edge for a day and served stale for up to a week while revalidating, so most lookups never invoke the function: `setResponseHeader( event, 'Cache-Control', 'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800', )` The sequence for a shared link is: 1. A visitor opens `/i/<name>`.     2. The CDN serves the ISR-cached page, so there is no server rendering on a warm path.     3. The browser requests the image from Blob's CDN, which serves it from cache for up to a year.     4. Only a cold preview page or an expired ISR window ever reaches a Vercel Function.     | Route           | Strategy                                          | | --------------- | ------------------------------------------------- | | `/`             | Prerendered at build, served static from the edge | | `/i/**`         | ISR, 1-hour edge cache with background refresh    | | `/api/image/*`  | `s-maxage=86400, stale-while-revalidate=604800`   | | Blob image URLs | Immutable, `cacheControlMaxAge` of 1 year         | | `/_nuxt/*`      | Fingerprinted, immutable (automatic)              | ### 11\. Track custom events with Web Analytics The `@vercel/analytics` package ships a Nuxt module, so pageviews take one line of configuration: `export default defineNuxtConfig({ modules: ['@vercel/analytics'], })` Custom events show which input method people actually use, where uploads fail, and whether visitors copy the link at the end. Import `track` from `@vercel/analytics` at the top of the file, then call it in the different handlers you built earlier: `// one track() call at each outcome, across handleFile and onCopy track('image_uploaded', { method, // paste | drop | browse type: file.type, sizeKb: Math.round(file.size / 1024), }) track('upload_rejected', { reason: 'unsupported_type', type: file.type }) track('upload_failed', { method, type: file.type }) track('link_copied', { surface: 'uploader' })` The server can track too. Add `onUploadCompleted` to the upload route from step 4, which fires when Vercel Blob confirms the upload: `import { track } from '@vercel/analytics/server' export default defineEventHandler(async (event) => { // ... try { return await handleUpload({ // .... onUploadCompleted: async ({ blob }) => { // Called by Vercel Blob after the upload lands (not reachable on localhost). await track('image_upload_completed', { contentType: blob.contentType ?? 'unknown', }) }, }) } catch (error) { // .... } })` The app tracks five events in total: | Event                    | Fired from | Properties                      | | ------------------------ | ---------- | ------------------------------- | | `image_uploaded`         | client     | `method`, `type`, `sizeKb`      | | `upload_rejected`        | client     | `reason`, `type` or `sizeKb`    | | `upload_failed`          | client     | `method`, `type`                | | `link_copied`            | client     | `surface` (uploader or preview) | | `image_upload_completed` | server     | `contentType`                   | Enable Web Analytics in the Vercel dashboard (project → Analytics → Enable) before deploying. Custom events require a Pro or Enterprise plan. ### 12\. Run the app locally Clone the [repository](https://github.com/vercel-labs/nuxt-imgur-clone). Then install dependencies and start the dev server:

`pnpm install pnpm dev`

Open `http://localhost:3000` and paste an image from your clipboard.

You should see:

- The uploading card with a real progress bar, then the success card with an `/i/<name>` link.
  
- The link is already on your clipboard when you press the copy button.
  
- The preview page renders the image with its type, size, and upload date.
  
- A second upload appears in the recent-uploads gallery with the counter at "2 uploads".
  
- Analytics events are logged to the browser console in debug mode instead of being sent.
  

Two things behave differently on localhost:

- The `onUploadCompleted` webhook cannot reach your machine (use a tunnel like ngrok with `VERCEL_BLOB_CALLBACK_URL` if you need it)
  
- ISR route rules are ignored in dev
  

### 13\. Deploy and test on Vercel

The Blob store is already connected to the linked project, so deployed environments get `BLOB_READ_WRITE_TOKEN` automatically. Deploy the project:

`vercel --prod`

Open the production URL, paste an image, and share the `/i/<name>` link with another device. The preview page should load from the edge cache, the image should serve from Blob's CDN, and the upload should appear in the Analytics events panel shortly after.

## Troubleshooting

### `/_payload.json` 404s on the deployed site

**Cause:** with `prerender` + `isr` route rules, the Nuxt Vercel preset bakes a `/_payload.json` reference into the prerendered HTML but doesn't emit the file into the static output.

**Fix:** set `experimental: { payloadExtraction: false }` in `nuxt.config.ts`. The home page has no async payload, and ISR pages inline their data regardless. Note: this only reproduces in a Vercel-preset build (`NITRO_PRESET=vercel nuxt build`), not in `nuxt dev` a default local build.

### Uploads fail locally with a missing token error

**Cause**: Nuxt only loads `.env` by default, but the Vercel CLI writes `BLOB_READ_WRITE_TOKEN` to `.env.local`.

**Fix**: Keep the dev script as `nuxt dev --dotenv .env.local`, or run `vercel env pull .env.local` and restart the dev server.

### Uploads fail with "Invalid image name"

**Cause**: The token route rejects any pathname that doesn't match the 10-character nanoid format.

**Fix**: Always pass `newImageName(file.type)` as the first argument to `upload`. If you changed the name format, update `IMAGE_NAME_RE` and `newImageName` together in `shared/utils/images.ts`.

### The upload succeeds but onUploadCompleted never runs

**Cause**: Vercel Blob calls the completion webhook over the public internet, which cannot reach `localhost`.

**Fix**: This is expected in local development. To test it locally, run a tunnel like ngrok and set `VERCEL_BLOB_CALLBACK_URL` to the tunnel URL in `.env.local`.

### Custom events don't appear in the dashboard

**Cause**: Web Analytics isn't enabled on the project, the plan doesn't include custom events, or you're testing locally.

**Fix**: Enable Analytics in the project dashboard and redeploy. Custom events require a Pro or Enterprise plan. In local dev, events are logged to the browser console in debug mode and are not sent.

### A preview page 404s for an image that exists

**Cause**: The route param is decoded before the `head` lookup, so a double-encoded or truncated link won't resolve.

**Fix**: Share the exact `/i/<name>` URL the app generates. If you deleted the blob with `vercel blob del`, the 404 is correct because ISR pages regenerate within the hour.

### Deleted images still render for a while

**Cause**: The preview page is ISR-cached for an hour and the metadata API serves stale responses while revalidating.

**Fix**: This is the intended trade-off for a public image host. Lower the `isr` window and `s-maxage` values if deletions must propagate faster.

## Related resources and next steps

- Read the [client uploads with Vercel Blob](https://vercel.com/docs/vercel-blob/client-upload) guide for `upload()`, `handleUpload()`, `onBeforeGenerateToken`, and `onUploadCompleted`.
  
- Explore the [@vercel/blob SDK reference](https://vercel.com/docs/vercel-blob/using-blob-sdk) for `head()`, `put()`, `list()`, `del()`, `cacheControlMaxAge`, and filename behavior.
  
- Review [public Blob storage](https://vercel.com/docs/vercel-blob/public-storage) for CDN caching and cache-control behavior.
  
- Check [Nuxt on Vercel](https://vercel.com/docs/frameworks/full-stack/nuxt) for `routeRules`, ISR support, prerendering, and Nuxt deployment behavior on Vercel.
  
- Learn how to configure [Vercel Web Analytics custom events](https://vercel.com/docs/analytics/custom-events).

---

[View full KB sitemap](/kb/sitemap.md)
