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