Episode 17 handles file uploads in GraphQL: the GraphQL Upload specification with multipart requests, implementing graphql-upload in Apollo Server, stream processing with type and size validation, AWS S3 and Cloudinary integration, and file upload security.

Files aren't ordinary data — they're large, have special formats, and need streaming handling so they don't burden the server's memory. Episode 17 covers file upload in GraphQL with the modern GraphQL Upload specification.
We'll compare upload approaches, implement graphql-upload in Apollo Server, process files with type and size validation, integrate storage like AWS S3 and Cloudinary, and discuss file upload security.
The most practical approach is the GraphQL Upload specification: the file is sent as multipart/form-data together with the GraphQL operation in a single request. The server exposes an Upload scalar, and the client sends the file as a variable of type Upload.
Other approaches you should know:
Install the graphql-upload package with npm install graphql-upload:
npm install graphql-uploadIn the schema, add the scalar and use it on mutations:
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): User!
uploadPostImages(files: [Upload!]!): Post!
}Apollo Server 4 no longer ships a built-in upload middleware, so mount graphqlUploadExpress manually on the HTTP server:
import { graphqlUploadExpress } from "graphql-upload/express.js";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import express from "express";
const app = express();
app.use("/graphql", graphqlUploadExpress({ maxFileSize: 5 * 1024 * 1024, maxFiles: 5 }));
app.use("/graphql", expressMiddleware(server));
app.listen(4000);The maxFileSize and maxFiles parameters are the first line of defense against abuse.
A file from graphql-upload is a stream (a FileUpload object). Don't read an entire large file into memory — process it in parts or stream it straight to storage:
import { Readable } from "stream";
async function uploadAvatar(_, args, ctx) {
const { createReadStream, mimetype, filename } = await args.file;
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(mimetype)) {
throw new Error("Tipe file tidak diizinkan");
}
if (filename.length > 100) {
throw new Error("Nama file terlalu panjang");
}
const stream = createReadStream();
const key = await ctx.storage.uploadStream(stream, { contentType: mimetype });
return ctx.userService.updateAvatar(ctx.user.id, key);
}mimetype and size validation happen before the file is processed. For images, process them first with sharp — for example, resizing an avatar to 128x128 and converting the format to webp — so the stored file is controlled:
npm install sharpimport sharp from "sharp";
const buffer = await sharp(await streamToBuffer(stream))
.resize(128, 128)
.webp({ quality: 80 })
.toBuffer();Remember: never trust the mimetype the client sends — detect the actual type from the file content, for example with file-type, because mimetype can be forged.
Writing files to a local filesystem is only suitable for development — local storage isn't distributed and disappears when the instance is recreated. For production, use object storage:
npm install @aws-sdk/client-s3import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function uploadToS3(key, body) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: body,
}));
return `https://cdn.kalian.com/${key}`;
}Popular alternatives: Cloudinary for images with on-the-fly transformations, and Google Cloud Storage as the GCP equivalent. Database BLOB storage isn't recommended — large files clog the database and are hard to back up.
File upload is a very popular attack vector. Apply the following checklist:
mimetype.maxFileSize in the middleware plus additional validation in the resolver.import crypto from "crypto";
const key = `${Date.now()}-${crypto.randomUUID()}.webp`;Key takeaways:
Upload scalar to the schema and mount the graphqlUploadExpress middleware manually in Apollo 4.mimetype.In the next episode, episode 18, you'll learn about advanced schema design patterns — schema design principles, designing one-to-one through many-to-many relations, global object identification with the Node interface, mutation payload design, API evolution strategies with deprecation, and schema modularization. Your schema will be designed like an experienced architect's!