If you're building an application that handles images (profile avatars, product photos, or user uploads), you run into the same set of problems every time. Users upload 12-megapixel photos straight from their phones, and if you serve those files back as-is, pages get slow and bandwidth costs climb. Every image needs resizing for different layouts, cropping to fit, and re-encoding into modern formats like WebP. On top of that, every image needs alt text for accessibility and SEO.
This guide shows you how to build a complete image processing API that handles all of that in one place. The API provides five endpoints to resize, crop, optimize, analyze, and caption images. You'll also learn how to store the processed images in your branch's Neon Object Storage bucket, so you can serve them directly from S3 instead of reprocessing on every request.
The API runs on Neon Functions which provide a serverless compute environment in the same region as your Neon Postgres database. Image transformations run on Sharp, a high-performance image processing library powered by libvips. And for captions, the Neon AI Gateway provides access to the latest vision models.
How it works
- Upload: The client POSTs an image to any endpoint, either as a raw binary body with an
image/*content type or as amultipart/form-dataupload. - Sharp processing: The
/resize,/crop,/optimize, and/analyzeroutes decode the image and run the pipeline in-process. - AI captioning: The
/captionroute uses Sharp to downscale the image, then sends it to a vision model through the Neon AI Gateway for a one-sentence alt text caption.
Prerequisites
Before starting, ensure you have:
- Node.js: Version 20 or later (v24 recommended). Download from nodejs.org.
- Neon Account: Sign up for an account at console.neon.tech.
- The Neon CLI: Installed globally (
npm i -g neon) and authenticated (neon auth). See the Neon CLI Quickstart for details.
Set up the project
Create a directory for the project and initialize a workspace:
mkdir image-api && cd image-api npm init -yRun the Neon CLI initialization command:
neon initUse the default setup options for all prompts: this enables AI skills, configures the MCP server, and installs the VS Code extension. These ensure AI agents such as Claude Code and Cursor can assist you in building and working with Neon.
During initialization, Neon Platform and Postgres skills are installed automatically. You'll also need the Neon Functions, Neon AI Gateway, and Neon Object Storage skills so AI agents have the context to help you build and deploy your image API. Install them with the following command:
neon skills -s neon-functions -s neon-ai-gateway -s neon-object-storageLink your local workspace to a Neon project:
neon linkYou'll be prompted to select your organization, then a project. Create a new project named
image-api(or pick an existing one). Next, select a region. Choose AWS US East (Ohio) (aws-us-east-2) or AWS Europe (Frankfurt) (aws-eu-central-1); this guide uses US East (Ohio). Neon Functions are currently available in these regions during beta. Support is expanding toward all regions. When asked which Neon services you require, select Functions and AI Gateway. Finally, confirm that you want to manage your setup as code, which generates aneon.tsfile in your project root:$ neon link ✔ Which organization would you like to link? › MyOrg (org-example-12345678) ✔ Which project would you like to link? › + Create new project… ✔ Name for the new project: … image-api ✔ Which region should the new project run in? › AWS US East 2 (Ohio) (aws-us-east-2) Created project quiet-fog-09491284 ("image-api") in aws-us-east-2. Linked ~/image-api/.neon: orgId: org-example-12345678 projectId: quiet-fog-09491284 branch: main ✔ Manage this project's Neon setup as code? Adds a neon.ts you can edit and apply with `neon config apply`. … yes ✔ Which Neon services should neon.ts declare? (space to toggle, enter to confirm) › Functions, AI Gateway INFO: Pulled 5 Neon variables into ~/image-api/.env.local: NEON_BRANCH, DATABASE_URL, DATABASE_URL_UNPOOLED, NEON_AI_GATEWAY_TOKEN, NEON_AI_GATEWAY_BASE_URL INFO: Created neon.ts declaring functions, ai-gateway. INFO: Created hello.ts - the source of the hello function. INFO: Installing @neon/config, @neon/env with npm…The
neon linkcommand also creates a placeholder function:hello.ts, at your project root. You'll build the image API in your ownindex.tsfile, so delete the placeholder:rm hello.tsIt also creates a
.env.localfile with your project's variables.Install the dependencies for your function. You'll need
honofor routing,sharpfor image processing, and the Neon AI SDK provider and Vercel AI SDK for captioning. You'll also install TypeScript, type definitions, andesbuildfor bundling:npm install hono sharp @neon/ai-sdk-provider ai@6 npm install --save-dev @types/node typescript esbuildInstall
aiversion 6. Newer versions of the Vercel AI SDK have breaking changes with thegenerateTextAPI used in this guide.TypeScript needs a
tsconfig.jsonfor the linter to resolve types correctly. Create it in your project root:tsconfig.json{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }Build the image API
Create an
index.tsfile in the root of your project. It defines aGET /smoke-test route, the API's five endpoints, and the shared helpers they rely on:index.tsimport { Hono, type Context } from 'hono'; import sharp, { type FitEnum, type FormatEnum } from 'sharp'; import { neon } from '@neon/ai-sdk-provider'; import { generateText } from 'ai'; const app = new Hono(); const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB const FORMATS = ['jpeg', 'png', 'webp', 'avif']; const FITS = ['cover', 'contain', 'fill', 'inside', 'outside']; class BadRequest extends Error {} // Reads the uploaded image from the request, either as a raw binary body // (Content-Type: image/*) or as multipart/form-data with a "file" field. async function getImageBuffer(c: Context): Promise<Buffer> { const contentType = c.req.header('content-type') ?? ''; if (contentType.startsWith('multipart/form-data')) { const form = await c.req.parseBody(); const file = form['file']; if (!(file instanceof File) || !file.type.startsWith('image/')) { throw new BadRequest('Expected an image in the "file" form field'); } return checkSize(Buffer.from(await file.arrayBuffer())); } if (!contentType.startsWith('image/')) { throw new BadRequest( 'Send the image as a raw body with an image/* Content-Type, or as multipart/form-data' ); } return checkSize(Buffer.from(await c.req.arrayBuffer())); } function checkSize(buffer: Buffer): Buffer { if (buffer.byteLength === 0) throw new BadRequest('Empty request body'); if (buffer.byteLength > MAX_IMAGE_SIZE) throw new BadRequest('Image exceeds the 10 MB limit'); return buffer; } function getFormat(c: Context): keyof FormatEnum { const format = c.req.query('format') ?? 'webp'; if (!FORMATS.includes(format)) throw new BadRequest(`format must be one of: ${FORMATS.join(', ')}`); return format as keyof FormatEnum; } function imageResponse(c: Context, output: Buffer, format: keyof FormatEnum, extraHeaders: Record<string, string> = {}) { return c.body(new Uint8Array(output), 200, { 'Content-Type': `image/${format}`, 'Cache-Control': 'public, max-age=31536000, immutable', ...extraHeaders, }); } app.get('/', (c) => c.json({ endpoints: ['POST /resize', 'POST /crop', 'POST /optimize', 'POST /analyze', 'POST /caption'], }) ); app.post('/resize', async (c) => { const input = await getImageBuffer(c); const width = Number(c.req.query('width')) || undefined; const height = Number(c.req.query('height')) || undefined; const fit = c.req.query('fit') ?? 'cover'; const format = getFormat(c); if (!width && !height) throw new BadRequest('Pass at least one of ?width or ?height'); if (!FITS.includes(fit)) throw new BadRequest(`fit must be one of: ${FITS.join(', ')}`); const output = await sharp(input) .rotate() // normalize EXIF orientation from phone cameras .resize({ width, height, fit: fit as keyof FitEnum }) .toFormat(format, { quality: 80 }) .toBuffer(); return imageResponse(c, output, format); }); app.post('/crop', async (c) => { const input = await getImageBuffer(c); const left = Number(c.req.query('left')); const top = Number(c.req.query('top')); const width = Number(c.req.query('width')); const height = Number(c.req.query('height')); const format = getFormat(c); const valid = Number.isInteger(left) && left >= 0 && Number.isInteger(top) && top >= 0 && Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0; if (!valid) { throw new BadRequest('Pass non-negative integer ?left and ?top, and positive integer ?width and ?height'); } const output = await sharp(input) .rotate() .extract({ left, top, width, height }) .toFormat(format, { quality: 80 }) .toBuffer(); return imageResponse(c, output, format); }); app.post('/optimize', async (c) => { const input = await getImageBuffer(c); const format = getFormat(c); const quality = Math.min(Math.max(Number(c.req.query('quality')) || 80, 1), 100); const output = await sharp(input) .rotate() .toFormat(format, { quality }) .toBuffer(); return imageResponse(c, output, format, { 'X-Original-Size': String(input.byteLength), 'X-Optimized-Size': String(output.byteLength), }); }); app.post('/analyze', async (c) => { const input = await getImageBuffer(c); const [metadata, stats] = await Promise.all([sharp(input).metadata(), sharp(input).stats()]); const { r, g, b } = stats.dominant; const toHex = (v: number) => v.toString(16).padStart(2, '0'); return c.json({ width: metadata.width, height: metadata.height, format: metadata.format, sizeBytes: input.byteLength, hasAlpha: metadata.hasAlpha, dominantColor: `#${toHex(r)}${toHex(g)}${toHex(b)}`, }); }); app.post('/caption', async (c) => { const input = await getImageBuffer(c); // Downscale before calling the model: vision models don't need full-resolution // input, and a smaller image costs fewer tokens and less latency. const thumbnail = await sharp(input) .rotate() .resize(1024, 1024, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer(); const { text } = await generateText({ model: neon('llama-4-maverick'), messages: [ { role: 'user', content: [ { type: 'text', text: 'Write a concise one-sentence alt text caption for this image. Describe only what is visible.', }, { type: 'image', image: thumbnail, mediaType: 'image/jpeg' }, ], }, ], }); return c.json({ caption: text }); }); // Central error handler: BadRequest becomes a 400, anything else a 500. app.onError((err, c) => { if (err instanceof BadRequest) return c.json({ error: err.message }, 400); console.error(err); return c.json({ error: 'Failed to process image' }, 500); }); export default app;Here's how the pieces fit together.
Setup and shared helpers
honohandles routing andsharpdoes the image processing.MAX_IMAGE_SIZEcaps uploads at 10 MB,FORMATSandFITShold the allowedformatandfitvalues, andBadRequestis a custom error type mapped to a400by the handler.getImageBufferreads the image from every request, either as a raw binary body (Content-Type: image/*) or asmultipart/form-datawith afilefield. Anything else returns a400, andcheckSizerejects empty bodies and uploads over 10 MB.getFormatreads theformatquery parameter (defaultwebp) and validates it againstFORMATS.imageResponsesets the correctContent-Typeand a year-longCache-Controlheader, plus any extra headers a route passes in.
Endpoints
/resizeresizes to the givenwidthandheight(pass one or both; Sharp preserves the aspect ratio with one). Thefitparameter controls how the image fills the box, and.rotate()applies EXIF orientation so phone photos come out upright./cropextracts a pixel rectangle defined byleft,top,width, andheightusing Sharp'sextract./optimizere-encodes toformatatquality(1-100) and reports the savings via theX-Original-SizeandX-Optimized-Sizeheaders./analyzerunsmetadata()andstats()in parallel and returns the dimensions, format, size, alpha channel, and dominant color.
Captioning
The
/captionroute downscales the image and sends the thumbnail tollama-4-maverickfor a one-sentence alt text caption. ThegenerateTextcall uses the Neon AI SDK provider to route the request through the Neon AI Gateway.Error handler
Bad input throws
BadRequest, mapped to a400; anything else becomes a500. The final line exports the app so Neon Functions can serve it.Model access
For improved captioning, you can use frontier vision models like
claude-opus-5,gpt-5-6-solinstead ofllama-4-maverick.Frontier models are rolling out gradually. If
claude-opus-5,gpt-5-6-soletc. aren't available in your project yet, open-weight vision models such asllama-4-maverickandgemma-3-12bare accessible immediately. Just swap the model ID in the/captionroute. No other changes are required.Configure neon.ts
The
neon linkcommand created aneon.tsfile in your project root. Replace its contents with the following:neon.tsimport { defineConfig } from '@neon/config/v1'; export default defineConfig({ preview: { functions: { imageapi: { name: 'Image API', source: './index.ts', externalPackages: ['sharp'], } }, aiGateway: true } });Here's what each property does:
preview.functions.imageapi: Registersindex.tsas a deployable function. The key (imageapi) is the function's slug, which becomes part of its invocation URL.externalPackages: ['sharp']: Ships Sharp's files with the deploy instead of bundling them into the function bundle. See the note below for why this matters.aiGateway: true: Enables the Neon AI Gateway on the branch. This is what injects theNEON_AI_GATEWAY_*credentials your/captionroute uses.
Deploying from an x86-64 machine
Sharp loads a compiled libvips binary from a platform-specific package, like
@img/sharp-libvips-linux-x64. Neon Functions run onlinux-arm64, so if you deploy from an x86-64 machine, npm installs the wrong build locally, and a compiled binary can't be bundled into the function anyway.neon deploywarns about this, but the warning doesn't fail the deploy; instead, the function fails at invoke time when it tries to load Sharp. SettingexternalPackages: ['sharp']avoids this by shipping Sharp's files with the deploy instead of bundling them.Test locally
You can run your function locally using
neon dev, which starts a local server with your branch's environment variables injected:neon devGrab a sample image to test with (any photo works; this one comes from Lorem Picsum, a free placeholder image service):
curl -L -o sample.jpg "https://picsum.photos/id/1015/1280/853"Try the endpoints. First, a raw binary upload to
/resize:curl -X POST "http://localhost:8787/resize?width=400" -H "Content-Type: image/jpeg" --data-binary @sample.jpg -o resized.webpThen a multipart upload to
/optimize:curl -X POST "http://localhost:8787/optimize?format=webp&quality=70" -F "file=@sample.jpg" -o optimized.webpYou can verify the images were processed correctly by opening
resized.webpandoptimized.webpin an image viewer.Test the
/captionendpoint, which uses thellama-4-maverickfrom the Neon AI Gateway to generate a one-sentence alt text caption:curl -X POST "http://localhost:8787/caption" -H "Content-Type: image/jpeg" --data-binary @sample.jpg{ "caption": "A group of people stand on a rocky outcropping, overlooking a blue body of water surrounded by mountains under a partly cloudy sky." }You now have a working image processing API running locally. Deploy it to Neon Functions to make it publicly accessible.
Deploy the API
Deploy your function to Neon:
neon deployThe CLI bundles your function, applies the
neon.tsconfiguration, and prints the public URL:Function URLs • imageapi: https://br-damp-voice-xxx-imageapi.compute.c-3.us-east-2.aws.neon.techYour API is now live. If you need to retrieve the URL later, run
neon functions get imageapi.Test the deployed API
Export the function URL to an environment variable so you can test it with
curl:export API_URL="https://br-damp-voice-xxx-imageapi.compute.c-3.us-east-2.aws.neon.tech"List the endpoints:
curl $API_URL/Resize an image to a 400x400 square thumbnail:
curl -X POST "$API_URL/resize?width=400&height=400&fit=cover" -H "Content-Type: image/jpeg" --data-binary @sample.jpg -o thumbnail.webpCrop a 600x600 region starting at (300, 100):
curl -X POST "$API_URL/crop?left=300&top=100&width=600&height=600" -H "Content-Type: image/jpeg" --data-binary @sample.jpg -o crop.webpOptimize an image and inspect the size headers:
curl -X POST "$API_URL/optimize?format=webp&quality=70" \ -F "file=@sample.jpg" \ -D - \ -o optimized.webpcontent-type: image/webp cache-control: public, max-age=31536000, immutable x-optimized-size: 158974 x-original-size: 201611Analyze an image:
curl -X POST "$API_URL/analyze" -H "Content-Type: image/jpeg" --data-binary @sample.jpg{ "width":1280, "height":853, "format":"jpeg", "sizeBytes":201611, "hasAlpha":false, "dominantColor":"#084898" }Caption an image:
curl -X POST "$API_URL/caption" -H "Content-Type: image/jpeg" --data-binary @sample.jpgBecause LLMs are non-deterministic, the generated caption may differ from what you have seen in the local test, but it should be a concise one-sentence description of the image.
You now have a working image processing API deployed on Neon Functions. The next step is to store the processed images in your branch's Neon Object Storage bucket so you can serve them directly from S3 instead of reprocessing on every request.
Optional: Store processed images in your branch bucket
The endpoints you've built return the processed bytes directly in the response. The
Cache-Control: immutableheader only helps clients cache the result, not your function. Storing each result in your branch's Neon Object Storage bucket turns this into a real media pipeline: process once, store, and serve from the bucket.Install the AWS SDK
Add the S3 client and presigner packages:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presignerAdd a
/storerouteAdd an S3 client and a
/storeroute toindex.ts. The route processes the image the same way/resizedoes, uploads the result to your branch's bucket, and returns a presigned URL you can hand to a client:index.tsimport { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const BUCKET = 'processed-images'; const s3 = new S3Client({ region: process.env.AWS_REGION, endpoint: process.env.AWS_ENDPOINT_URL_S3, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, forcePathStyle: true, }); app.post('/store', async (c) => { const input = await getImageBuffer(c); const width = Number(c.req.query('width')) || undefined; const height = Number(c.req.query('height')) || undefined; const fit = c.req.query('fit') ?? 'cover'; const format = getFormat(c); if (!width && !height) throw new BadRequest('Pass at least one of ?width or ?height'); if (!FITS.includes(fit)) throw new BadRequest(`fit must be one of: ${FITS.join(', ')}`); const output = await sharp(input) .rotate() .resize({ width, height, fit: fit as keyof FitEnum }) .toFormat(format, { quality: 80 }) .toBuffer(); const key = `processed/${Date.now()}.${format}`; await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: output })); const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), { expiresIn: 3600, }); return c.json({ key, url }); });The
/storeroute is similar to/resize, but instead of returning the processed bytes, it uploads them to theprocessed-imagesbucket and returns a presigned GET URL that works for an hour.Declare the bucket
Add the bucket to the
previewblock inneon.ts, next to the function and the AI Gateway:neon.tsimport { defineConfig } from '@neon/config/v1'; export default defineConfig({ preview: { functions: { imageapi: { name: 'Image API', source: './index.ts', externalPackages: ['sharp'], }, }, aiGateway: true, buckets: { 'processed-images': {}, }, }, });The
{}means the bucket isprivate: only the branch's credentials can read and write it. Setaccess: 'public_read'instead if you want clients to fetch objects without a presigned URL.Redeploy and test
Redeploy. This provisions the bucket and injects the
AWS_*credentials into your function:neon deployThen store a resized image:
curl -X POST "$API_URL/store?width=400&format=webp" -H "Content-Type: image/jpeg" --data-binary @sample.jpg{ "key": "processed/1723430987654.webp", "url": "https://br-damp-voice-xxx.storage.c-3.us-east-2.aws.neon.tech/processed-images/processed/1723430987654.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&..." }The URL is a presigned GET link that works for an hour. The object also lives in your bucket, so you can list it with
neon buckets object list processed-images --recursiveor browse it in the Neon Console.
Next steps
Because the function runs on your Neon branch with Postgres and Object Storage credentials already injected, you can easily extend it to store processed images and captions in your database. For example:
- Cache transforms and captions in Postgres: Image transforms are deterministic, so hash the image bytes plus the query parameters and cache the result location in a table. You can also store every caption and
/analyzeresult alongside the image record, giving you a searchable media library with alt text included.DATABASE_URLis already injected into your function. - Add authentication and rate limiting: Image processing burns CPU, and AI captions burn tokens. Verify callers with a JWT and cap per-user usage using the pattern from Build an LLM proxy with Neon Functions, Neon AI Gateway, and Managed Better Auth.
Resources
- Neon Functions overview
- Neon AI Gateway overview
- Neon Object Storage overview
- Sharp API documentation
- Vercel AI SDK: Generate Text with Image Prompt
- Neon AI SDK Provider
- Hono Framework
Need help?
Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.








