graphql-upload-nextjs
v2.0.0
Published
The middleware and Upload scalar in this package enable GraphQL multipart requests (file uploads via queries and mutations) in your Apollo Server Next.js integration.
Maintainers
Readme
graphql-upload-nextjs
A GraphQL multipart request spec implementation for Next.js App Router with Apollo Server. Enables file uploads via GraphQL mutations using the Upload scalar, with built-in MIME type verification via magic bytes.
Features
- Implements the GraphQL multipart request spec.
- Designed for Next.js App Router route handlers.
- Supports single file uploads, multiple file uploads, and operation batching.
- File deduplication (one file mapped to multiple operation paths).
- MIME type verified via magic bytes using file-type, not trusting client headers.
- Configurable
allowedTypes,maxFileSize, andmaxFiles. - Only reads 4KB for MIME detection — streams the rest directly from
Blob. - Spec-compatible property names:
filename,mimetype,encoding,createReadStream.
Installation
npm install graphql-upload-nextjsMigrating from graphql-upload
graphql-upload uses Express/Koa middleware that is incompatible with Next.js App Router route handlers. This package provides the same Upload scalar and file object interface for the Next.js environment.
Property names match the original: filename, mimetype, encoding, createReadStream. An additional fileSize property is also available.
Before (graphql-upload with Express):
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'
app.use(graphqlUploadExpress())After (graphql-upload-nextjs with App Router):
import { GraphQLUpload, uploadProcess } from 'graphql-upload-nextjs'
// In your route handler:
if (request.headers.get("content-type")?.includes("multipart/form-data")) {
return await uploadProcess(request, context, server);
}Resolver code stays the same — filename, mimetype, encoding, and createReadStream work identically.
Usage
Schema and Resolvers
import path from 'node:path'
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream'
import { gql } from '@apollo/client' // Optional: syntax highlighting only.
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { type File, GraphQLUpload, uploadProcess } from 'graphql-upload-nextjs'
import type { NextRequest } from 'next/server.js'
const typeDefs = gql`
scalar Upload
type File {
encoding: String!
filename: String!
fileSize: Int!
mimetype: String!
uri: String!
}
type Query {
default: Boolean!
}
type Mutation {
uploadFile(file: Upload!): File!
uploadFiles(files: [Upload!]!): [File!]!
}
`
interface FileResponse {
encoding: string;
filename: string;
fileSize: number;
mimetype: string;
uri: string;
}
const resolvers = {
Mutation: {
uploadFile: async (
_parent: undefined,
{ file }: { file: Promise<File> },
): Promise<FileResponse> => {
const { createReadStream, encoding, filename, fileSize, mimetype } = await file;
const safeName = path.basename(filename);
return new Promise((resolve, reject) => {
pipeline(
createReadStream(),
createWriteStream(`./uploads/${safeName}`),
(error) => {
if (error) reject(new Error("Error during file upload."));
else resolve({ encoding, filename: safeName, fileSize, mimetype, uri: `/${safeName}` });
},
);
});
},
uploadFiles: async (
_parent: undefined,
{ files }: { files: Promise<File>[] },
): Promise<FileResponse[]> => {
const resolvedFiles = await Promise.all(files);
return Promise.all(resolvedFiles.map(async ({ createReadStream, encoding, filename, fileSize, mimetype }) => {
const safeName = path.basename(filename);
return new Promise<FileResponse>((resolve, reject) => {
pipeline(
createReadStream(),
createWriteStream(`./uploads/${safeName}`),
(error) => {
if (error) reject(new Error(`Error during upload of ${safeName}.`));
else resolve({ encoding, filename: safeName, fileSize, mimetype, uri: `/${safeName}` });
},
);
});
}));
},
},
Query: { default: async () => true },
Upload: GraphQLUpload,
}Security: Always sanitize filenames with
path.basename()to prevent path traversal attacks. Never write uploaded files to publicly accessible directories in production.
Route Handler
const server = new ApolloServer({ resolvers, typeDefs });
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
context: async (req: NextRequest) => ({
ip: req.headers.get("x-forwarded-for") || "",
req,
}),
});
const requestHandler = async (request: NextRequest) => {
if (request.headers.get("content-type")?.includes("multipart/form-data")) {
const context = { ip: request.headers.get("x-forwarded-for") || "", req: request };
return await uploadProcess(request, context, server);
}
return handler(request);
};
export const GET = requestHandler;
export const POST = requestHandler;
export const OPTIONS = requestHandler;Configuration
await uploadProcess(request, context, server, {
allowedTypes: ["image/jpeg", "image/png", "text/plain"],
maxFiles: 10,
maxFileSize: 10 * 1024 * 1024, // 10MB
});| Option | Type | Default | Description |
|---|---|---|---|
| allowedTypes | string[] | undefined | Restrict MIME types. Verified via magic bytes. |
| maxFiles | number | undefined | Max files per request. Returns 413 if exceeded. |
| maxFileSize | number | undefined | Max file size in bytes. Returns 413 if exceeded. |
Client Usage
When using a GraphQL client library (apollo-upload-client, urql, extract-files), file uploads are constructed automatically per the spec.
import { gql, useMutation } from '@apollo/client';
const UPLOAD = gql`
mutation UploadFile($file: Upload!) {
uploadFile(file: $file) { filename mimetype fileSize }
}
`;
function Uploader() {
const [upload] = useMutation(UPLOAD);
return <input type="file" onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload({ variables: { file } });
}} />;
}API Reference
Core Exports
| Export | Type | Description |
|---|---|---|
| GraphQLUpload | GraphQLScalarType | The Upload scalar for your GraphQL schema. |
| uploadProcess | function | Processes a multipart request with file uploads. |
| Upload | class | Holds a promise that resolves with file upload details. |
File Object
The resolved file object passed to resolvers:
| Property | Type | Description |
|---|---|---|
| createReadStream | () => ReadableStream | Creates a Node.js readable stream of the file contents. Replayable. |
| encoding | string | Transfer encoding (always 'binary' with FormData API). |
| filename | string | Original file name from the client. |
| fileSize | number | File size in bytes. |
| mimetype | string | MIME type verified via magic bytes. |
Utility Exports
| Export | Type | Description |
|---|---|---|
| bufferToStream | (buffer: Buffer) => ReadableStream | Converts a Buffer to a Node.js readable stream. |
| parseOperationsJSON | (input: string) => object \| object[] | Parses the operations field (object or array for batching). |
| sanitizeAndValidateJSON | (input: string) => object | Parses JSON and validates it's a non-null, non-array object. |
| setValueAtPath | (obj, path, value) => void | Sets a value at a dot-notation path in a nested object. |
| streamToBuffer | (stream: ReadableStream) => Promise<Buffer> | Collects a readable stream into a Buffer. |
| validateMap | (map: object) => string \| null | Validates map entries are arrays of string paths. Returns error or null. |
TypeScript Interfaces
| Export | Description |
|---|---|
| File | Resolved file object with filename, mimetype, encoding, fileSize, createReadStream. |
| FileStream | FormData file entry with a stream() method. |
| FormDataFile | Raw file entry from multipart FormData. |
| MinimalRequest | Request interface compatible with Next.js and Web API. |
Spec Compliance
Implements the GraphQL multipart request specification by jaydenseric.
The spec defines a multipart form field structure with three ordered fields (operations, map, file fields) and enables nesting files anywhere within operations, operation batching, file deduplication, and file upload streams in resolvers.
Supported Capabilities
| Spec Capability | Status | Details |
|---|---|---|
| operations field (JSON-encoded GraphQL operation) | Supported | Parsed and validated as object or array. |
| map field (file-to-path mapping) | Supported | Validated: each entry must be an array of string paths. |
| Single file upload | Supported | File mapped via "variables.file" path. |
| File list upload | Supported | Files mapped via "variables.files.0", "variables.files.1", etc. |
| Batching | Supported | Operations as array, paths prefixed with operation index ("0.variables.file"). |
| File deduplication | Supported | One file field mapped to multiple operation paths. |
| object-path dot-notation | Supported | Handles nested objects and array indices. |
| File upload streams in resolvers | Supported | createReadStream() returns a Node.js readable stream. |
| Missing file → rejected promise | Supported | Upload promise rejected with "File missing in the request." |
| maxFiles / maxFileSize limits | Supported | Returns HTTP 413 when exceeded. |
Additions Beyond the Spec
| Feature | Description |
|---|---|
| MIME magic byte verification | Real file type detected via file-type, not trusting client-provided Content-Type. |
| allowedTypes filtering | Server-side restriction of accepted MIME types. |
| fileSize property | File size in bytes available on the resolved file object. |
Limitations
These are inherent trade-offs of using the Web FormData API for Next.js App Router compatibility. They do not affect normal spec usage.
| Limitation | Reason |
|---|---|
| No field ordering validation | The spec requires operations → map → files ordering. The Web FormData API retrieves fields by name (formData.get('operations')), not by position, so ordering cannot be validated. In practice, all major client libraries (apollo-upload-client, extract-files) send fields in the correct order. |
| No maxFieldSize | The original graphql-upload limits non-file field sizes via busboy. Next.js parses the full request body into FormData before our code runs, so field size limiting is not possible at this layer. |
| encoding is always 'binary' | FormData does not expose the transfer encoding of file parts. The original graphql-upload reads this from busboy's stream events. In practice, transfer encoding is rarely used by resolvers. |
| No mid-stream abort | The original uses fs-capacitor to buffer uploads to disk, allowing resolvers to abort an in-progress upload. With FormData, the entire request body is already parsed by the runtime. However, resolvers can still choose not to call createReadStream() to skip processing. |
| Blob.stream() instead of busboy streaming | Next.js route handlers receive a Request object (Web API), not a Node.js IncomingMessage. There is no access to the raw request stream. Blob.stream() provides a replayable readable stream per file. |
Example
A full example project is available at examples/example-graphql-upload-nextjs/.
Contributing
Contributions are welcome! To get started:
git clone https://github.com/lafittemehdy/graphql-upload-nextjs.git
cd graphql-upload-nextjs
npm install
npm run build
npm testCI runs automatically on push and pull requests via GitHub Actions (build + test on Node.js 22/24, example build + lint).
License
MIT. See LICENSE.
Acknowledgements
Sincere gratitude to jaydenseric for the GraphQL multipart request specification and graphql-upload, and to meabed for graphql-upload-ts which served as a valuable reference.
Finally, heartfelt gratitude to my mom for her unwavering support, which has allowed me to dedicate my time to working on open-source software.
