Skip to Content

Knowledge Base Vercel Blob

Build Imgur-style image hosting with Nuxt and Vercel Blob

Learn how to build an Imgur-style paste-to-share image host using Nuxt and Vercel Blob, with direct-to-storage client uploads, shareable ISR preview pages, and CDN caching at every layer.

Content Engineer

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

14 min read

16 Jul 2026

Quick start with an AI coding agent

I want to build an Imgur-style image sharing app using the Nuxt + Vercel Blob image hosting template. Read the setup instructions at https://agent-resources.dev/nuxt-blob-image-hosting-template.md and follow them. They cover deploying the template with a Blob store, running it locally with the Vercel CLI, building on Nuxt and Vercel Blob client uploads, and understanding how upload tokens, preview pages, and edge caching work overall.


  Show more

This guide covers the core implementation. The AI assistant prompt above covers all the details your coding agent needs, and you can see the full implementation in the template repository.


Vercel Plugin

The 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)
  • A Vercel account
  • 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/ preview link instead of the storage URL.
  • A metadata route at /api/image/ 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

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


cd nuxt-imgur-clone

npx nuxi@latest init nuxt-imgur-clone

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:

pnpmadd @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.

{

"scripts":{

"build":"nuxt build",

"dev":"nuxt dev --dotenv .env.local",

}

}

"generate":"nuxt generate",

"preview":"nuxt preview"

vercel link

2. Create a Blob store

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.

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

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

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'

exportconstALLOWED_IMAGE_TYPES=[

'image/png',

'image/jpeg',

'image/gif',

'image/webp',

]asconst

exportconstMAX_IMAGE_SIZE=10*1024*1024


For consistency and to avoid exposing original filenames, the app generates a random 10-character alphanumeric name with nanoid:

constEXTENSIONS: Record={

'image/webp':'webp',

exportfunctionnewImageName(contentType:string):string{

'image/png':'png',
10,
'image/jpeg':'jpg',
}

)

exportfunctionformatBytes(bytes:number):string{

'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
'image/gif':'gif',
}
return!!type &&(ALLOWED_IMAGE_TYPESasreadonlystring[]).includes(type)

const nanoid =customAlphabet(

exportfunctionisAllowedImageType(type:string|undefined|null):boolean{

}
}
return`${(bytes /(1024*1024)).toFixed(1)} MB`
if(bytes <1024)return`${bytes} B`

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:

body,

exportdefaultdefineEventHandler(async(event)=>{

const body =(awaitreadBody(event))as HandleUploadBody

returnawaithandleUpload({
try{
thrownewError('Invalid image name')
}

statusCode:400,

cacheControlMaxAge:60*60*24*365,

if(!IMAGE_NAME_RE.test(pathname)){
}
},
throwcreateError({

request:toWebRequest(event),

statusMessage: error instanceofError? error.message :'Upload failed',

return{
})
}catch(error){
})

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.


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:

Our Services

src:string// object URL for the local thumbnail

}

// derived from status: highlight the dropzone and swap its label while dragging

href:string// the /i/ share link
interfaceGalleryItem{
typeStatus='idle'|'drag'|'uploading'|'done'

Brand & Design

const dragging =computed(()=> status.value ==='drag')

typeUploadMethod='paste'|'drop'|'browse'
const previewSrc =ref('')
const progress =ref(0)
const status =ref('idle')

const gallery =ref([])

dragging.value ?'Release to upload':'Drag & drop your image here',

)
const errorMsg =ref('')
const dropTitle =computed(()=>
const pageUrl =ref('')

The idle and drag states share one dropzone, which is a for the hidden file input so clicking it opens the browser's picker:

Our Services

@dragover="onDragOver"

accept="image/png,image/jpeg,image/gif,image/webp,.png,.jpg,.jpeg,.gif,.webp"

v-if="status === 'idle' || status === 'drag'"
for="gi-file"
@drop="onDrop"

>

@dragleave="onDragLeave"
{{ dropTitle }}
type="file"
class="dropzone"

or click to browse — PNG, JPG, WebP, GIF up to 10MB

/>
@change="onPick"
:class="{ dragging }"
id="gi-file"

This guide does not include the complete page. The full file 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:

onMounted(()=> window.addEventListener('paste', onPaste))

if(file){

onBeforeUnmount(()=> window.removeEventListener('paste', onPaste))

const file = e.clipboardData?.files?.[0]
}
handleFile(file,'paste')
e.preventDefault()

}

}

functiononDrop(e: DragEvent){
if(status.value ==='drag') status.value ='idle'
handleFile(e.dataTransfer?.files?.[0],'drop')
functiononPaste(e: ClipboardEvent){

input.value =''

}

const input = e.target as HTMLInputElement
handleFile(input.files?.[0],'browse')
functiononPick(e: Event){
e.preventDefault()

Our Services

handleFile validates against the shared rules before anything leaves the browser:

The paste listener sits on window, so Cmd/Ctrl+V works anywhere on the page without needing to focus a specific element first.

if(!isAllowedImageType(file.type)){
return
errorMsg.value ='Only PNG, JPG, WebP, and GIF images are supported.'
}

}

asyncfunctionhandleFile(file: File |null|undefined, method: UploadMethod){

if(file.size >MAX_IMAGE_SIZE){
if(!file)return
errorMsg.value ='That image is over 10MB.'
// ...then the upload, covered in step 7

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:

return
Client-side validation is a courtesy; the signed token from step 4 is the enforcement.
QA & testing
}

// inside handleFile(), after the validation checks


},

access:'public',

status.value ='uploading'

previewSrc.value =URL.createObjectURL(file)

handleUploadUrl:'/api/upload',

pageUrl.value =`${window.location.origin}/i/${encodeURIComponent(blob.pathname)}`

})

status.value ='done'

progress.value = percentage

const blob =awaitupload(newImageName(file.type), file,{

onUploadProgress:({ percentage })=>{

gallery.value =[{ src: previewSrc.value, href: pageUrl.value },...gallery.value].slice

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/ 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 }=awaituseFetch(`/api/image/${encodeURIComponent(name)}`

const requestUrl =useRequestURL()

const shareUrl =computed(()=>`${requestUrl.origin}/i/${encodeURIComponent(name)}`)


The shape of image is whatever the /api/image/ 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 source, which keeps pixel delivery on Blob's CDN without surfacing the storage host in the UI:

Open Graph tags make the links unfurl properly in chat apps and social feeds:

ogDescription:'Fast, free image hosting. No account needed.',

useSeoMeta({

ogTitle:()=> image.value?.pathname ??'Image not found',

{{ copied ? 'Copied' : 'Copy' }}
{{ shareUrl }}

9. Resolve blob metadata on the server

title:()=>(image.value ?`${image.value.pathname} — GoodImg`:'Image not found — GoodImg'

})
twitterCard:'summary_large_image',
ogImage:()=> image.value?.url,

}

import{ head, BlobNotFoundError }from'@vercel/blob'

const name =decodeURIComponent(getRouterParam(event,'name')??'')

if(!name){
try{
size: blob.size,
}

pathname: blob.pathname,

exportdefaultdefineEventHandler(async(event)=>{

url: blob.url,
}
}
}catch(error){

uploadedAt: blob.uploadedAt,

throwcreateError({ statusCode:404, statusMessage:'Image not found'})

return{
}
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:

2// ...

4

routeRules:{

1

exportdefaultdefineNuxtConfig({

7

},

8// ...

6

'/i/**':{ isr:3600},

9 })

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

/i/**

Strategy

/

Prerendered at build, served static from the edge

ISR, 1-hour edge cache with background refresh

/api/image/*

/_nuxt/*

Blob image URLs

Fingerprinted, immutable (automatic)

s-maxage=86400, stale-while-revalidate=604800

Immutable, cacheControlMaxAge of 1 year

11. Track custom events with Web Analytics

The @vercel/analytics package ships a Nuxt module, so pageviews take one line of configuration:

exportdefaultdefineNuxtConfig({

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:

2

11

contentType: blob.contentType ??'unknown',

4// ...

9// Called by Vercel Blob after the upload lands (not reachable on localhost).

7// ....
5

try{

1

import{ track }from'@vercel/analytics/server'

19

6

returnawaithandleUpload({

3

exportdefaultdefineEventHandler(async(event)=>{

8

onUploadCompleted:async({ blob })=>{

10

awaittrack('image_upload_completed',{

13

},

15

}catch(error){

17}

18 })

14

})

12

})

16// ....

The app tracks five events in total:

Our Services

Event

image_uploaded

upload_rejected

Fired from
Properties
method, type, sizeKb
client

Brand & Design

upload_failed

client
reason, type or sizeKb
method, type
client

link_copied

image_upload_completed

client
surface (uploader or preview)
contentType
server

12. Run the app locally

Clone the repository. Then install dependencies and start the dev server:


pnpminstall

pnpm dev

Enable Web Analytics in the Vercel dashboard (project → Analytics → Enable) before deploying. Custom events require a Pro or Enterprise plan.

You should see:

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

  • The uploading card with a real progress bar, then the success card with an /i/ 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

Open the production URL, paste an image, and share the /i/ 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.

You can reach our customer support team by emailing info@yourcompany.example.com, calling +1 555-555-5556, or using the live chat on our website. Our dedicated team is available 24/7 to assist with any inquiries or issues.

We’re committed to providing prompt and effective solutions to ensure your satisfaction.

We offer a 30-day return policy for all products. Items must be in their original condition, unused, and include the receipt or proof of purchase. Refunds are processed within 5-7 business days of receiving the returned item.

/_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/ 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


FAQ

Vercel Function request bodies are capped at 4.5 MB, and proxying files through your server doubles the bandwidth cost of every upload. Client uploads send the bytes directly to Blob storage and your server only signs a short-lived token that specifies what's allowed.

Vercel Blob supports access: 'private' but it changes the serving model because private blobs aren't web-accessible, so the app would need to stream them through a server route with an access check, giving up the direct-CDN path. That's the correct approach for genuinely private content, which is why this template stays public-only.

Yes. onBeforeGenerateToken runs on your server for every upload request, so check a session there and throw if the user isn't signed in. Pass user data through tokenPayload to associate uploads with accounts in onUploadCompleted.

A 10-character alphanumeric nanoid has about 8.4 × 10¹⁷ possible values, making accidental collisions unlikely for this template’s expected usage.

Yes, because the URLs are immutable. In this template, a blob's content never changes after upload because names are random, never reused, and the token endpoint doesn't allow overwrites. So, there is no cache-invalidation problem to solve. Deleting a blob removes it from the CDN independently of the cache header.

The storage URL offers a visitor nothing the preview page doesn't. The page adds metadata, Open Graph unfurls, and analytics, and it keeps every shared link on your domain. The Blob URL remains the source, so image bytes are still served by Vercel Blob.


Related documentation