---
title: "Upload an Image With JavaScript and Node.js"
description: "Use Node.js fetch, Blob, and FormData to upload an image, validate the JSON response, and verify the public URL."
slug: "upload-image-javascript-nodejs"
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Read the image with Node.js, append a typed Blob to FormData as file, send it with fetch, and validate HTTP 200 or 202."
cluster: Languages
intent: how-to
sources:
  - title: "Node.js global fetch and FormData documentation"
    url: "https://nodejs.org/api/globals.html#fetch"
  - title: "Node.js file system promises documentation"
    url: "https://nodejs.org/api/fs.html#fspromisesreadfilepath-options"
  - title: "MDN FormData reference"
    url: "https://developer.mozilla.org/en-US/docs/Web/API/FormData"
  - title: "imgd.dev OpenAPI specification"
    url: "https://imgd.dev/openapi.json"
---

Upload an image with JavaScript by reading its bytes, creating a typed `Blob`, and appending that blob to `FormData` as `file`. Send the form to `https://imgd.dev/v1/upload` with built-in Node.js `fetch`. Accept HTTP `202` for new bytes and HTTP `200` for deduplicated bytes.

## Before you upload

imgd.dev costs **$1 per GB as a one-time storage purchase**. It has **no free tier**. Every imgd.dev image is public to anyone with its URL. Do not upload a private, confidential, or secret image.

Use Node.js 20 or later. This example uses only Node.js built-ins. It does not install an upload library.

Load `IMGD_KEY` from a secret manager before you start. Confirm that the variable exists without printing its value:

```bash
test -n "$IMGD_KEY"
```

## Complete JavaScript example

Save this code as `examples/core/javascript/upload-image.mjs`.

```javascript
import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";

const UPLOAD_URL = "https://imgd.dev/v1/upload";
const METADATA_URL = "https://imgd.dev/v1/images";
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 60_000;

const MIME_TYPES = Object.freeze({
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".png": "image/png",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".avif": "image/avif",
});

/**
 * @typedef {object} UploadSuccess
 * @property {string} hash
 * @property {string} url
 * @property {string} status
 * @property {string} mime
 * @property {number} bytes
 * @property {number|null} width
 * @property {number|null} height
 * @property {string|null} alt_text
 * @property {string|null} unpublish_at
 * @property {boolean} [deduplicated]
 * @property {string} note
 */

function isRecord(value) {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isUploadSuccess(value) {
  return isRecord(value)
    && typeof value.hash === "string"
    && typeof value.url === "string"
    && typeof value.status === "string"
    && typeof value.mime === "string"
    && typeof value.bytes === "number";
}

async function readJson(response) {
  const text = await response.text();
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(`The server returned invalid JSON with HTTP ${response.status}.`);
  }
}

function responseError(response, payload) {
  const guidance = {
    401: "Load a valid IMGD_KEY from the secret manager.",
    402: "Buy storage or delete unused images, then retry.",
    413: "Reduce the image below 20 MB, then retry.",
    415: "Use JPEG, PNG, GIF, WebP, or AVIF with the correct media type.",
  };

  const errorCode = isRecord(payload) && typeof payload.error === "string"
    ? payload.error
    : "unknown_error";
  const fix = isRecord(payload) && typeof payload.fix === "string"
    ? payload.fix
    : guidance[response.status] ?? "Read the response and correct the request.";

  return new Error(`Request failed with HTTP ${response.status} (${errorCode}): ${fix}`);
}

async function uploadImage(imagePath, apiKey) {
  const extension = extname(imagePath).toLowerCase();
  const mediaType = MIME_TYPES[extension];
  if (!mediaType) {
    throw new Error("Use a JPEG, PNG, GIF, WebP, or AVIF file name.");
  }

  const bytes = await readFile(imagePath);
  if (bytes.byteLength === 0) {
    throw new Error("The image file is empty.");
  }
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
    throw new Error("The image is larger than the 20 MB upload limit.");
  }

  const form = new FormData();
  form.append("file", new Blob([bytes], { type: mediaType }), basename(imagePath));

  const response = await fetch(UPLOAD_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
    },
    body: form,
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  const payload = await readJson(response);

  if (!response.ok) {
    throw responseError(response, payload);
  }
  if (response.status !== 200 && response.status !== 202) {
    throw new Error(`The upload returned unexpected HTTP ${response.status}.`);
  }
  if (!isUploadSuccess(payload)) {
    throw new Error("The upload response does not match the expected success contract.");
  }

  return payload;
}

async function verifyUpload(upload, apiKey) {
  const metadataResponse = await fetch(`${METADATA_URL}/${upload.hash}`, {
    headers: {
      Authorization: `Bearer ${apiKey}`,
    },
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  const metadata = await readJson(metadataResponse);
  if (!metadataResponse.ok) {
    throw responseError(metadataResponse, metadata);
  }
  if (!isRecord(metadata) || metadata.hash !== upload.hash) {
    throw new Error("The metadata response did not confirm the upload hash.");
  }

  const publicResponse = await fetch(upload.url, {
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  if (!publicResponse.ok) {
    throw new Error(`The public URL returned HTTP ${publicResponse.status}.`);
  }
  const contentType = publicResponse.headers.get("content-type") ?? "";
  if (!contentType.startsWith("image/")) {
    throw new Error(`The public URL returned the unexpected type '${contentType}'.`);
  }
  const publicBytes = await publicResponse.arrayBuffer();
  if (publicBytes.byteLength === 0) {
    throw new Error("The public URL returned an empty image body.");
  }
}

const imagePath = process.argv[2];
const apiKey = process.env.IMGD_KEY;

if (!imagePath) {
  process.stderr.write("Usage: node upload-image.mjs IMAGE_PATH\n");
  process.exitCode = 64;
} else if (!apiKey) {
  process.stderr.write("Set IMGD_KEY in the environment.\n");
  process.exitCode = 78;
} else {
  try {
    const upload = await uploadImage(imagePath, apiKey);
    await verifyUpload(upload, apiKey);
    process.stdout.write(`${JSON.stringify(upload, null, 2)}\n`);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    process.stderr.write(`${message}\n`);
    process.exitCode = 1;
  }
}
```

## Run the example

Pass a JPEG, PNG, GIF, WebP, or AVIF path:

```bash
node examples/core/javascript/upload-image.mjs ./image.png
```

The program sets the multipart field name to `file`. It also sets the media type from the file extension.

## Expected JSON

A new PNG can produce this output after both verification requests succeed:

```json
{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "processing",
  "mime": "image/png",
  "bytes": 48211,
  "width": 1280,
  "height": 720,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
```

The URL works immediately. During `processing`, the public request verifies a blurred image placeholder. The original becomes available after a `live` result.

A deduplicated upload returns HTTP `200` and adds this field:

```json
{
  "deduplicated": true
}
```

## How the validation works

`isUploadSuccess` checks required values before the program uses them. The JSDoc type records the response convention for editors and readers.

The program rejects an empty file, an unsupported extension, and a local file above 20 MB. The server remains the final contract.

`verifyUpload` checks the authenticated metadata route. It then downloads the immediate public image response and checks its media type and size.

The upload has a 60-second timeout. A network timeout produces a nonzero process exit code.

## Recover from API errors

The program reads both `error` and `fix` from non-2xx JSON. It uses a local instruction only when the server omits `fix`.

| Status | Cause | Action |
| --- | --- | --- |
| `401` | `IMGD_KEY` is missing or invalid. | Load the correct key from the secret manager. Do not print it. |
| `402` | The account has zero storage or full storage. | Buy storage or delete unused images. Then run the command again. |
| `413` | The server received more than 20 MB. | Reduce the image below 20 MB. |
| `415` | The server received an unsupported type. | Use JPEG, PNG, GIF, WebP, or AVIF with the matching extension. |

Do not retry these responses without a change. You can retry a timeout with the same bytes. Content addressing returns the same hash and URL.

A typical error is valid JSON:

```json
{
  "error": "file_too_large",
  "fix": "file exceeds 20MB; resize the image below that and retry",
  "bytes": 22020096,
  "max_bytes": 20971520
}
```

## Security rules

Keep `IMGD_KEY` in the process environment. Do not add it to source code, a URL, an exception, or normal output.

The program prints the upload response. That response contains a public URL, not the key.

Inspect image content before upload. Public storage does not protect private text, faces, or production data.

## Decide if this example fits

This example fits Node.js scripts that need a direct public image URL and no upload dependency. It also fits safe upload retries.

It does not fit private images, access control, video, PDFs, files above 20 MB, or a free tier. Read the [service evaluation guide](/evaluate.md) before use.

## Result

The program uploads one image, validates the response, verifies metadata, and verifies the immediate public image body.

Compare it with the [TypeScript upload guide](/blog/upload-image-typescript/) or start from the [cURL upload guide](/blog/upload-image-curl-public-url/).

Give an agent the [integration procedure](/integrate.md). A person can [start from the home page](/#start).
