Skip to content
Codeloom

Courses / GraphQL Complete Guide

Lesson 8 of 18

GraphQL File Uploads

Implement file uploads in GraphQL using multipart requests, presigned URLs, and streaming patterns for production-ready file handling.

Intermediate 11 min read

What you'll learn

  • How multipart file uploads work with GraphQL
  • How to implement the graphql-upload spec
  • How to use presigned URLs for direct-to-storage uploads
  • How to handle file validation and size limits
  • How to choose the right upload pattern for your use case

Prerequisites

  • Basic GraphQL mutations
  • Familiarity with Node.js and Express

The Challenge with GraphQL and Files

GraphQL was designed for structured data — JSON in, JSON out. Files are binary blobs that do not fit neatly into this model. You cannot encode a 50MB image as a GraphQL string argument. There are three established patterns for handling file uploads in GraphQL, each with different tradeoffs.

Pattern 1: Multipart Request Uploads

The GraphQL Multipart Request Spec extends GraphQL to support file uploads directly through multipart form data. The graphql-upload package implements this spec:

npm install graphql-upload

Define the schema with the Upload scalar:

scalar Upload

type File {
  id: ID!
  filename: String!
  mimetype: String!
  url: String!
  size: Int!
}

type Mutation {
  uploadAvatar(file: Upload!): File!
  uploadDocuments(files: [Upload!]!): [File!]!
}

Set up the server with the upload middleware:

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { graphqlUploadExpress } from 'graphql-upload';
import { finished } from 'stream/promises';
import { createWriteStream } from 'fs';
import { v4 as uuid } from 'uuid';
import path from 'path';
import express from 'express';

const app = express();

// Add upload middleware BEFORE Apollo middleware
app.use(graphqlUploadExpress({
  maxFileSize: 10_000_000,  // 10 MB
  maxFiles: 5,
}));

const resolvers = {
  Upload: GraphQLUpload,

  Mutation: {
    uploadAvatar: async (_, { file }, { user, db }) => {
      const { createReadStream, filename, mimetype } = await file;

      // Validate file type
      const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
      if (!allowedTypes.includes(mimetype)) {
        throw new GraphQLError('Invalid file type. Only JPEG, PNG, and WebP allowed.', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      // Generate unique filename
      const ext = path.extname(filename);
      const storedName = `${uuid()}${ext}`;
      const filePath = path.join('/uploads/avatars', storedName);

      // Stream file to disk
      const stream = createReadStream();
      const writeStream = createWriteStream(filePath);
      stream.pipe(writeStream);
      await finished(writeStream);

      // Get file size
      const stats = await fs.stat(filePath);

      // Save metadata to database
      const fileRecord = await db.files.create({
        filename: storedName,
        originalName: filename,
        mimetype,
        size: stats.size,
        url: `/uploads/avatars/${storedName}`,
        uploadedBy: user.id,
      });

      return fileRecord;
    },

    uploadDocuments: async (_, { files }, { user, db }) => {
      const results = [];

      for (const filePromise of files) {
        const { createReadStream, filename, mimetype } = await filePromise;

        const ext = path.extname(filename);
        const storedName = `${uuid()}${ext}`;
        const filePath = path.join('/uploads/documents', storedName);

        const stream = createReadStream();
        const writeStream = createWriteStream(filePath);
        stream.pipe(writeStream);
        await finished(writeStream);

        const stats = await fs.stat(filePath);

        const record = await db.files.create({
          filename: storedName,
          originalName: filename,
          mimetype,
          size: stats.size,
          url: `/uploads/documents/${storedName}`,
          uploadedBy: user.id,
        });

        results.push(record);
      }

      return results;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use('/graphql', express.json(), expressMiddleware(server));

On the client, use apollo-upload-client:

npm install apollo-upload-client
import { createUploadLink } from 'apollo-upload-client';
import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  link: createUploadLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
});
import { useMutation, gql } from '@apollo/client';

const UPLOAD_AVATAR = gql`
  mutation UploadAvatar($file: Upload!) {
    uploadAvatar(file: $file) {
      id
      url
      filename
    }
  }
`;

function AvatarUpload() {
  const [uploadAvatar, { loading, error }] = useMutation(UPLOAD_AVATAR);

  const handleChange = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    try {
      const { data } = await uploadAvatar({
        variables: { file },
      });
      console.log('Uploaded:', data.uploadAvatar.url);
    } catch (err) {
      console.error('Upload failed:', err);
    }
  };

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleChange} />
      {loading && <p>Uploading...</p>}
      {error && <p>Error: {error.message}</p>}
    </div>
  );
}

Pattern 2: Presigned URL Uploads

For large files or when using cloud storage (S3, GCS, Azure Blob), presigned URLs are the preferred approach. The GraphQL server never handles the file data — it only generates a signed URL that the client uses to upload directly to cloud storage:

type PresignedUpload {
  uploadUrl: String!
  fileUrl: String!
  fields: JSON
  expiresAt: String!
}

type Mutation {
  createUploadUrl(
    filename: String!
    contentType: String!
    size: Int!
  ): PresignedUpload!

  confirmUpload(fileUrl: String!): File!
}
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

const resolvers = {
  Mutation: {
    createUploadUrl: async (_, { filename, contentType, size }, { user }) => {
      // Validate
      if (size > 50_000_000) {
        throw new GraphQLError('File too large. Maximum size is 50MB.');
      }

      const allowedTypes = [
        'image/jpeg', 'image/png', 'image/webp',
        'application/pdf', 'video/mp4',
      ];
      if (!allowedTypes.includes(contentType)) {
        throw new GraphQLError(`Content type ${contentType} not allowed.`);
      }

      const key = `uploads/${user.id}/${uuid()}-${filename}`;

      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET,
        Key: key,
        ContentType: contentType,
        ContentLength: size,
      });

      const uploadUrl = await getSignedUrl(s3, command, {
        expiresIn: 300,  // URL valid for 5 minutes
      });

      const fileUrl = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`;

      return {
        uploadUrl,
        fileUrl,
        expiresAt: new Date(Date.now() + 300_000).toISOString(),
      };
    },

    confirmUpload: async (_, { fileUrl }, { user, db }) => {
      // Verify the file actually exists in S3
      const key = fileUrl.replace(
        `https://${process.env.S3_BUCKET}.s3.amazonaws.com/`,
        ''
      );

      try {
        const headCommand = new HeadObjectCommand({
          Bucket: process.env.S3_BUCKET,
          Key: key,
        });
        const metadata = await s3.send(headCommand);

        return db.files.create({
          url: fileUrl,
          filename: path.basename(key),
          mimetype: metadata.ContentType,
          size: metadata.ContentLength,
          uploadedBy: user.id,
        });
      } catch (err) {
        throw new GraphQLError('File not found at the specified URL');
      }
    },
  },
};

The client-side upload flow is a two-step process:

async function uploadFile(file) {
  // Step 1: Get the presigned URL from GraphQL
  const { data } = await client.mutate({
    mutation: gql`
      mutation CreateUploadUrl($filename: String!, $contentType: String!, $size: Int!) {
        createUploadUrl(filename: $filename, contentType: $contentType, size: $size) {
          uploadUrl
          fileUrl
        }
      }
    `,
    variables: {
      filename: file.name,
      contentType: file.type,
      size: file.size,
    },
  });

  // Step 2: Upload directly to S3 using the presigned URL
  await fetch(data.createUploadUrl.uploadUrl, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,
    },
  });

  // Step 3: Confirm the upload
  const { data: confirmData } = await client.mutate({
    mutation: gql`
      mutation ConfirmUpload($fileUrl: String!) {
        confirmUpload(fileUrl: $fileUrl) {
          id
          url
          filename
        }
      }
    `,
    variables: { fileUrl: data.createUploadUrl.fileUrl },
  });

  return confirmData.confirmUpload;
}

Pattern 3: Base64 Encoding (Small Files Only)

For very small files like icons or thumbnails (under 100KB), base64 encoding directly in the mutation is acceptable:

type Mutation {
  uploadIcon(name: String!, data: String!, contentType: String!): File!
}
const resolvers = {
  Mutation: {
    uploadIcon: async (_, { name, data, contentType }, { db }) => {
      const buffer = Buffer.from(data, 'base64');

      if (buffer.length > 100_000) {
        throw new GraphQLError('Icon must be under 100KB');
      }

      const filename = `${uuid()}-${name}`;
      await fs.writeFile(path.join('/uploads/icons', filename), buffer);

      return db.files.create({
        filename,
        mimetype: contentType,
        size: buffer.length,
        url: `/uploads/icons/${filename}`,
      });
    },
  },
};

Avoid this pattern for anything larger. Base64 encoding increases the payload size by approximately 33%, and the entire file must fit in memory.

Upload Progress Tracking

For large files with presigned URLs, track progress on the client using XMLHttpRequest:

function uploadWithProgress(presignedUrl, file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        const percent = Math.round((event.loaded / event.total) * 100);
        onProgress(percent);
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve();
      } else {
        reject(new Error(`Upload failed with status ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('Upload failed')));

    xhr.open('PUT', presignedUrl);
    xhr.setRequestHeader('Content-Type', file.type);
    xhr.send(file);
  });
}

Choosing the Right Pattern

Multipart uploads are best when files are small to medium (under 10MB), your server processes files before storing them (resizing images, virus scanning), and you want the simplest client-side implementation.

Presigned URLs are best when files are large, you use cloud storage (S3, GCS), you want to avoid loading file data through your GraphQL server, and you need upload progress tracking.

Base64 encoding is only suitable for tiny files like icons and favicons under 100KB.

For most production applications, presigned URLs are the recommended approach. They keep your GraphQL server lean, scale independently, and work with any cloud storage provider. Use multipart uploads for quick prototyping or when you need server-side processing before storage.

Progress is saved locally to your browser.