Oct 23, 20265 min read

How to Add File Uploads and Object Storage to a TypeScript API

Storing uploaded files from a Node.js and TypeScript backend in S3 or Google Cloud Storage with Encore, the AWS SDK or Multer, including presigned URLs, local buckets, tests and production.

A TypeScript backend can accept files easily enough, but storing them takes more decisions, since files belong in object storage such as S3 rather than on the server's disk.

Adding uploads means picking a client, choosing between proxying bytes through the API and issuing presigned URLs, creating buckets with the right access for each environment and having a bucket to develop against. This guide covers Encore, the AWS SDK for JavaScript and Multer with Express.

43.6% of respondents to the 2025 Stack Overflow Developer Survey had done extensive development work in TypeScript over the past year.

What you need to decide:

  • Whether files pass through the API or go from the browser to the bucket.
  • Which buckets are private and which serve public files.
  • How the API reaches the bucket with the right permissions.
  • Where buckets come from locally and in tests.

Object storage options for TypeScript at a glance

CapabilityEncoreAWS SDK v3Multer with multer-s3
Setupnew Bucket(...) in your codeClient, bucket name and IAMExpress middleware plus an S3 client
Presigned URLssignedUploadUrl, signedDownloadUrlgetSignedUrl from s3-request-presignerNot included, use the AWS SDK
Local developmentObjects stored on the local filesystemAn S3-compatible server or a dev bucketSame as the AWS SDK
TestsIn-memory backendMock or local serverMock or local server
Production bucketS3 in your AWS account or Cloud Storage in your GCP accountYou create itYou create it

Encore: buckets declared in your code

Encore

Encore combines the bucket in your code with the storage behind it in every environment. Object storage in Encore works with S3, Google Cloud Storage and other S3-compatible services, stores objects on the local filesystem during development and uses an in-memory backend in tests.

// uploads/uploads.ts import { api, APIError } from "encore.dev/api"; import { Bucket } from "encore.dev/storage/objects"; export const avatars = new Bucket("avatars", { versioned: false }); interface UploadUrlRequest { userId: string; } // Returns a signed URL the browser uploads the file to directly. export const uploadUrl = api( { expose: true, method: "POST", path: "/avatars/upload-url" }, async ({ userId }: UploadUrlRequest): Promise<{ url: string }> => { const { url } = await avatars.signedUploadUrl(`avatars/${userId}.png`, { ttl: 600 }); return { url }; }, ); // Small files can go through the API as raw bytes. export const upload = api.raw( { expose: true, method: "PUT", path: "/avatars/:userId" }, async (req, resp) => { const userId = new URL(req.url ?? "", "http://localhost").pathname.split("/").pop() ?? ""; const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); await avatars.upload(`avatars/${userId}.png`, Buffer.concat(chunks), { contentType: "image/png", }); resp.writeHead(201); resp.end(); }, ); export const downloadUrl = api( { expose: true, method: "GET", path: "/avatars/:userId/url" }, async ({ userId }: { userId: string }): Promise<{ url: string }> => { const key = `avatars/${userId}.png`; if (!(await avatars.exists(key))) throw APIError.notFound("no avatar"); const { url } = await avatars.signedDownloadUrl(key, { ttl: 3600 }); return { url }; }, );

The upload endpoint uses a raw endpoint to read the request body as a stream, and the file uploads guide covers multipart forms, while a bucket declared with public: true serves files through publicUrl.

On deploy the bucket is created as S3 in your AWS account or Cloud Storage on GCP, and an existing bucket can be imported. An agent asked to add attachments to a feature can declare the bucket next to the endpoint and run the flow under encore run without an AWS account, while cloud access sits with Encore's IAM role, not the agent.

Without Encore

Without Encore the code below needs a bucket per environment, an IAM policy that lets the service read and write it, CORS rules on the bucket so browsers can PUT to presigned URLs and a local stand-in for development.

Try Encore →

AWS SDK for JavaScript v3

The AWS SDK v3 splits S3 into @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner:

import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const s3 = new S3Client({}); const Bucket = process.env.AVATAR_BUCKET!; export async function uploadUrl(userId: string): Promise<string> { return getSignedUrl( s3, new PutObjectCommand({ Bucket, Key: `avatars/${userId}.png`, ContentType: "image/png" }), { expiresIn: 600 }, ); } export async function downloadUrl(userId: string): Promise<string> { return getSignedUrl(s3, new GetObjectCommand({ Bucket, Key: `avatars/${userId}.png` }), { expiresIn: 3600, }); }

Good to know

Presigned URLs created with the CLI last at most 7 days, and URLs signed with a role's temporary credentials expire with those credentials. A single PUT can carry up to 5 GB, and @aws-sdk/lib-storage handles multipart uploads above that.

Multer with Express

Multer parses multipart/form-data in Express, and the multer-s3 storage engine streams each file to S3:

import express from "express"; import multer from "multer"; import multerS3 from "multer-s3"; import { S3Client } from "@aws-sdk/client-s3"; const upload = multer({ storage: multerS3({ s3: new S3Client({}), bucket: process.env.AVATAR_BUCKET! }), limits: { fileSize: 5 * 1024 * 1024 }, }); const app = express(); app.post("/avatars", upload.single("avatar"), (req, res) => { res.status(201).json({ key: (req.file as Express.MulterS3.File).key }); });

Good to know

Every byte passes through the server, which is fine for avatars and less so for video, where a presigned URL avoids the load and request timeouts.

Buckets in production

New S3 buckets block public access by default, so private files stay private until you add a policy, and public assets usually sit behind a CDN. With Encore the bucket and its permissions are created in your own account on deploy.

How to choose

  • Encore if you want buckets that work locally, in tests and in your AWS or GCP account from the same declaration.
  • AWS SDK v3 if you manage buckets and IAM already and want direct control.
  • Multer with multer-s3 for form uploads in an existing Express app.

Frequently asked questions

How do I upload files to S3 from a Node.js TypeScript API?

For small files, read the request body or use Multer with Express and send it to S3 with PutObjectCommand from @aws-sdk/client-s3. For large files, have the API return a presigned PUT URL from @aws-sdk/s3-request-presigner and let the browser upload directly. With Encore you declare a Bucket in your code and call upload or signedUploadUrl on it, and encore run stores objects locally.

How long can a presigned S3 URL be valid?

A presigned URL created with the CLI or with IAM user credentials lasts at most 7 days. URLs signed with temporary credentials, such as those from an IAM role, expire when the credentials do, which is often sooner.

How do I test file uploads locally without AWS?

Run an S3-compatible server such as MinIO and point the SDK at it with a custom endpoint, or mock the client. With Encore, local development stores objects on the local filesystem and integration tests use an in-memory backend.

Should uploads go through my API or directly to S3?

Directly to S3 for anything large, using a presigned URL, so the bytes do not pass through your servers and request timeouts do not apply. Through the API for small files that you want to validate or transform before storing.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders