Skip to navigation

Server crop

Send the photo and its crop value to your server, and cut the full-size original there with sharp.

Open
Send
0.0

Install sharp

A browser export can come out smaller than the crop. Loading images has the size limits, and errors explains blank. sharp has no canvas and never retries smaller, so cut on the server when you need the full‑size crop.

The route runs on your server and needs sharp. Add it and @zriz/core as in exporting:

pnpm add sharp @zriz/core

Use sharp 0.35.5 or newer and keep it updated. It bundles libvips and every image decoder, so an old sharp means old decoders. Security lists the advisories.

Send the file and the value

The demo above uploads nothing. Its send button works out the plan in the browser, the way the route does, and shows the value and the toSharp steps under the dial, with numbers rounded to four places.

getValue() returns the crop value, and serialize turns it into a small JSON string. Send that, not the export plan, so the server works out which pixels to keep on its own. The upload button waits for onLoad, because a value means nothing without its photo.

crop-limits.ts holds minSize and maxZoom for Root and for the route, so the server recomputes the plan the client saw. Change them there, and both ends stay the same.

A bad request comes back as text, and the alert shows it. A photo the browser cannot open shows the same alert.

Each pick bumps a counter that Root takes as its key, so every photo starts from the full frame. Loading images explains why. Safari fires no change for the same file twice, so onClick clears the input first.

last holds the upload in flight. A new upload, a new pick or an unmount aborts it, so an answer for an old photo never shows under a new one. Starting an upload clears the old result and error, and the effect on result revokes each URL it leaves behind.

const unreadable =
  "This photo does not open. Pick a JPEG, PNG, WebP or AVIF.";

const [picks, setPicks] = useState(0);

const last = useRef<AbortController | null>(null);

useEffect(() => () => last.current?.abort(), []);

useEffect(
  () => () => {
    if (result) URL.revokeObjectURL(result);
  },
  [result]
);

const upload = async () => {
  const value = actions.current?.getValue();

  if (!file || !value) return;
  last.current?.abort();
  const controller = new AbortController();
  const { signal } = controller;

  last.current = controller;
  setError(null);
  setResult(null);
  const form = new FormData();

  form.append("file", file);
  form.append("value", serialize(value));

  try {
    const response = await fetch("/api/crop", {
      method: "POST",
      body: form,
      signal,
    });

    if (response.ok) {
      const blob = await response.blob();

      if (!signal.aborted) {
        setResult(URL.createObjectURL(blob));
      }
    } else {
      const message = await response.text();

      if (!signal.aborted) setError(message);
    }
  } catch {
    if (!signal.aborted) {
      setError("The upload did not reach the server.");
    }
  }
};

<input
  aria-label="Choose a photo"
  type="file"
  accept="image/jpeg,image/png,image/webp,image/avif"
  onClick={(event) => {
    event.currentTarget.value = "";
  }}
  onChange={(event) => {
    last.current?.abort();
    setReady(false);
    setError(null);
    setResult(null);
    setPicks((count) => count + 1);
    setFile(event.target.files?.[0] ?? null);
  }}
/>

<Cropper.Root
  key={picks}
  actionsRef={actions}
  maxZoom={MAX_ZOOM}
  minSize={MIN_SIZE}
  src={file}
  svg={false}
  onLoad={() => setReady(true)}
  onError={() => setError(unreadable)}
>

<button type="button" disabled={!ready} onClick={upload}>
  Upload
</button>

Cut the original with sharp

rotate() with no angle turns the upload upright from its EXIF tag. The route reads the value with parse, builds a cropper at the size of that upright image and asks it for the plan. A value out of range lands inside the photo, the way it does in the browser. toSharp then turns the plan into steps for sharp.

A crop with no straighten comes back as "extract": a rectangle, a flop, a turn in steps of 90° and a resize to the size of the plan. A straightened crop comes back as "affine": extract the source window, the part of the photo the crop can touch, pad at least 2 px on each side by copying the edge, turn with a bicubic affine, then extract the crop.

In one pipeline sharp runs extend() after affine(), and affine() after extract() and rotate(), so the route ends each stage as a raw buffer and opens a new pipeline for the next one. Each new pipeline passes limitInputPixels: false, since the upload already passed the check and a turned crop can grow past it. The first one holds the limit.

toSharp does not apply perspective, so the route answers 422 for a value with perspective. Export those in the browser instead.

type Raw = { data: Buffer; info: OutputInfo };
const open = (image: Raw) =>
  sharp(image.data, {
    raw: image.info,
    limitInputPixels: false,
  });
const raw = (pipeline: Sharp) =>
  pipeline
    .timeout({ seconds: MAX_SECONDS })
    .raw()
    .toBuffer({ resolveWithObject: true });

const cut = async (input: Buffer, value: string) => {
  const saved = parse(value);

  if (!saved) throw new TypeError("not a crop value");
  const upright = await raw(
    sharp(input, { limitInputPixels: MAX_PIXELS }).rotate()
  );
  const cropper = createCropper({
    image: upright.info,
    maxZoom: MAX_ZOOM,
    minSize: MIN_SIZE,
  });

  cropper.setValue(saved);
  const plan = cropper.getPixelCrop();

  if (!plan || plan.homography)
    throw new RangeError("no flat plan");
  const steps = toSharp(plan, upright.info);

  if (steps.kind === "extract") {
    const turned = await raw(
      open(upright)
        .extract(steps.extract)
        .flop(steps.flop)
        .rotate(steps.rotate)
    );

    return open(turned).resize({
      ...steps.resize,
      fit: "fill",
    });
  }

  const [[a, b], [c, d]] = steps.affine;
  const padded = await raw(
    open(upright).extract(steps.source).extend(steps.extend)
  );
  const turned = await raw(
    open(padded).affine([a, b, c, d], steps.options)
  );

  return open(turned).extract(steps.extract);
};

The upload is cut as pixels, and each stage drops everything else, so EXIF, GPS, XMP, thumbnails, gain maps and bytes after the image never reach the answer. Don't add keepMetadata() or keepExif(). Security explains why.

Answer the request

The route is a Next.js route handler saved as app/api/crop/route.ts. A JPEG at quality 92 with clear pixels filled white comes back when all goes well. Otherwise:

StatusWhen
400The form lacks the file or the value, or sharp cannot open the file
401canCrop says no
413The body is over 50 MB or declares no content-length, or the photo is over MAX_PIXELS
415A HEIC or other HEIF that is not AVIF, because prebuilt sharp has no HEVC decoder
422The crop cannot be cut, or the value is over 4096 characters
503MAX_RUNNING crops are already running in this process

Chromium and Firefox ignore the orientation tag of a WebP and Safari honours it, so the value can describe a different frame from the one sharp turns upright. The route answers 422 for a WebP with an orientation above 1 instead of cutting the wrong pixels.

canCrop in can-crop.ts answers false until you connect your session check, so a copied route refuses everyone. Replace its body before you ship.

The value and the file come from the browser, so treat both as hostile. The top of the route holds the limits:

const MAX_BODY = 50 * 1024 * 1024;
const MAX_VALUE = 4096;
const MAX_PIXELS = 120_000_000;
const MAX_RUNNING = 2;
const MAX_SECONDS = 30;
const HEADERS = {
  "cache-control": "private, no-store",
  "x-content-type-options": "nosniff",
};

sharp.block({ operation: ["VipsForeignLoad"] });

sharp.unblock({
  operation: [
    "VipsForeignLoadJpegBuffer",
    "VipsForeignLoadPngBuffer",
    "VipsForeignLoadWebpBuffer",
    "VipsForeignLoadHeifBuffer",
  ],
});

A small PNG can ask for a huge image, and the bytes on the wire say nothing about the memory it takes. A photo at MAX_PIXELS peaked at about 2 GB in our test, for a 460 kB upload at a 45° tilt, so give the server that much per running crop. MAX_PIXELS is the one constant to raise, and only with memory to match. 120 million covers a 108 megapixel phone photo, and a 200 megapixel one gets 413. MAX_RUNNING counts one process, so add a rate limit per user on top.

sharp.block() turns off every decoder but JPEG, PNG, WebP and AVIF for the whole process, because the route never needs SVG, TIFF, GIF or the others. Move it if another route in the same process does.

const readForm = async (request: Request) => {
  let seen = 0;
  const capped = request.body?.pipeThrough(
    new TransformStream<Uint8Array, Uint8Array>({
      transform(chunk, stream) {
        seen += chunk.length;
        if (seen > MAX_BODY) stream.error(new RangeError());
        else stream.enqueue(chunk);
      },
    })
  );
  const form = await new Response(capped, {
    headers: request.headers,
  })
    .formData()
    .catch(() => null);

  return { form, over: seen > MAX_BODY };
};

const answer = async (request: Request) => {
  const { form, over } = await readForm(request);

  if (over) return refuse("This upload is too large.", 413);
  const file = form?.get("file");
  const value = form?.get("value");

  if (
    !(file instanceof File) ||
    file.size === 0 ||
    typeof value !== "string"
  ) {
    return refuse("Send a file and a crop value.", 400);
  }

  if (value.length > MAX_VALUE) {
    return refuse("This crop does not fit this photo.", 422);
  }

  const input = Buffer.from(await file.arrayBuffer());
  const meta = await sharp(input)
    .metadata()
    .catch(() => null);

  if (!meta)
    return refuse("The server cannot open this file.", 400);

  if (meta.format === "heif" && meta.compression !== "av1") {
    return refuse("Send a JPEG, PNG, WebP or AVIF.", 415);
  }

  if (meta.width * meta.height > MAX_PIXELS) {
    return refuse("This photo has too many pixels.", 413);
  }

  if (meta.format === "webp" && (meta.orientation ?? 1) > 1) {
    return refuse(
      "save this webp without an orientation tag.",
      422
    );
  }

  try {
    const crop = await cut(input, value);
    const jpeg = await crop
      .timeout({ seconds: MAX_SECONDS })
      .flatten({ background: "#ffffff" })
      .jpeg({ quality: 92 })
      .toBuffer();

    return new Response(new Uint8Array(jpeg), {
      headers: { ...HEADERS, "content-type": "image/jpeg" },
    });
  } catch {
    return refuse("This crop does not fit this photo.", 422);
  }
};
export const POST = async (request: Request) => {
  if (!(await canCrop(request))) {
    return refuse("Sign in to crop a photo.", 401);
  }

  const length = Number(request.headers.get("content-length"));

  if (!(length > 0) || length > MAX_BODY) {
    return refuse("This upload is too large.", 413);
  }

  if (running >= MAX_RUNNING) {
    return refuse("The server is busy. Try again.", 503);
  }

  running += 1;

  try {
    return await answer(request);
  } finally {
    running -= 1;
  }
};

All four files

Save crop-limits.ts, can-crop.ts and crop-upload.tsx next to the app folder. That is where the imports look for them.

crop-limits.ts is shared by both:

export const MIN_SIZE = { width: 1, height: 1 };
export const MAX_ZOOM = 15;

can-crop.ts runs on the server. Connect your session check here:

export const canCrop = async (_request: Request) => false;

crop-upload.tsx runs in the browser:

"use client";

import { serialize } from "@zriz/core";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useRef, useState } from "react";
import { MAX_ZOOM, MIN_SIZE } from "./crop-limits";

const unreadable =
  "This photo does not open. Pick a JPEG, PNG, WebP or AVIF.";

export const CropUpload = () => {
  const [file, setFile] = useState<File | null>(null);
  const [ready, setReady] = useState(false);
  const [result, setResult] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [picks, setPicks] = useState(0);
  const actions = useRef<CropperApi>(null);
  const last = useRef<AbortController | null>(null);

  useEffect(() => () => last.current?.abort(), []);

  useEffect(
    () => () => {
      if (result) URL.revokeObjectURL(result);
    },
    [result]
  );

  const upload = async () => {
    const value = actions.current?.getValue();

    if (!file || !value) return;
    last.current?.abort();
    const controller = new AbortController();
    const { signal } = controller;

    last.current = controller;
    setError(null);
    setResult(null);
    const form = new FormData();

    form.append("file", file);
    form.append("value", serialize(value));

    try {
      const response = await fetch("/api/crop", {
        method: "POST",
        body: form,
        signal,
      });

      if (response.ok) {
        const blob = await response.blob();

        if (!signal.aborted) {
          setResult(URL.createObjectURL(blob));
        }
      } else {
        const message = await response.text();

        if (!signal.aborted) setError(message);
      }
    } catch {
      if (!signal.aborted) {
        setError("The upload did not reach the server.");
      }
    }
  };

  return (
    <>
      <input
        aria-label="Choose a photo"
        type="file"
        accept="image/jpeg,image/png,image/webp,image/avif"
        onClick={(event) => {
          event.currentTarget.value = "";
        }}
        onChange={(event) => {
          last.current?.abort();
          setReady(false);
          setError(null);
          setResult(null);
          setPicks((count) => count + 1);
          setFile(event.target.files?.[0] ?? null);
        }}
      />
      <Cropper.Root
        key={picks}
        actionsRef={actions}
        maxZoom={MAX_ZOOM}
        minSize={MIN_SIZE}
        src={file}
        svg={false}
        onLoad={() => setReady(true)}
        onError={() => setError(unreadable)}
      >
        <Cropper.Viewport
          style={{ maxWidth: 640, height: 480 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
        <Cropper.Dial
          aria-label="Straighten"
          style={{ maxWidth: 361 }}
        >
          <Cropper.DialTicks />
        </Cropper.Dial>
      </Cropper.Root>
      <button type="button" disabled={!ready} onClick={upload}>
        Upload
      </button>
      {error && <p role="alert">{error}</p>}
      {result && (
        <img src={result} alt="Crop from the server" />
      )}
    </>
  );
};

app/api/crop/route.ts runs on the server:

import { createCropper, parse, toSharp } from "@zriz/core";
import sharp from "sharp";
import type { OutputInfo, Sharp } from "sharp";
import { canCrop } from "../../../can-crop";
import { MAX_ZOOM, MIN_SIZE } from "../../../crop-limits";

const MAX_BODY = 50 * 1024 * 1024;
const MAX_VALUE = 4096;
const MAX_PIXELS = 120_000_000;
const MAX_RUNNING = 2;
const MAX_SECONDS = 30;
const HEADERS = {
  "cache-control": "private, no-store",
  "x-content-type-options": "nosniff",
};

sharp.block({ operation: ["VipsForeignLoad"] });

sharp.unblock({
  operation: [
    "VipsForeignLoadJpegBuffer",
    "VipsForeignLoadPngBuffer",
    "VipsForeignLoadWebpBuffer",
    "VipsForeignLoadHeifBuffer",
  ],
});

let running = 0;

const refuse = (text: string, status: number) =>
  new Response(text, { headers: HEADERS, status });

type Raw = { data: Buffer; info: OutputInfo };
const open = (image: Raw) =>
  sharp(image.data, {
    raw: image.info,
    limitInputPixels: false,
  });
const raw = (pipeline: Sharp) =>
  pipeline
    .timeout({ seconds: MAX_SECONDS })
    .raw()
    .toBuffer({ resolveWithObject: true });

const cut = async (input: Buffer, value: string) => {
  const saved = parse(value);

  if (!saved) throw new TypeError("not a crop value");
  const upright = await raw(
    sharp(input, { limitInputPixels: MAX_PIXELS }).rotate()
  );
  const cropper = createCropper({
    image: upright.info,
    maxZoom: MAX_ZOOM,
    minSize: MIN_SIZE,
  });

  cropper.setValue(saved);
  const plan = cropper.getPixelCrop();

  if (!plan || plan.homography)
    throw new RangeError("no flat plan");
  const steps = toSharp(plan, upright.info);

  if (steps.kind === "extract") {
    const turned = await raw(
      open(upright)
        .extract(steps.extract)
        .flop(steps.flop)
        .rotate(steps.rotate)
    );

    return open(turned).resize({
      ...steps.resize,
      fit: "fill",
    });
  }

  const [[a, b], [c, d]] = steps.affine;
  const padded = await raw(
    open(upright).extract(steps.source).extend(steps.extend)
  );
  const turned = await raw(
    open(padded).affine([a, b, c, d], steps.options)
  );

  return open(turned).extract(steps.extract);
};

const readForm = async (request: Request) => {
  let seen = 0;
  const capped = request.body?.pipeThrough(
    new TransformStream<Uint8Array, Uint8Array>({
      transform(chunk, stream) {
        seen += chunk.length;
        if (seen > MAX_BODY) stream.error(new RangeError());
        else stream.enqueue(chunk);
      },
    })
  );
  const form = await new Response(capped, {
    headers: request.headers,
  })
    .formData()
    .catch(() => null);

  return { form, over: seen > MAX_BODY };
};

const answer = async (request: Request) => {
  const { form, over } = await readForm(request);

  if (over) return refuse("This upload is too large.", 413);
  const file = form?.get("file");
  const value = form?.get("value");

  if (
    !(file instanceof File) ||
    file.size === 0 ||
    typeof value !== "string"
  ) {
    return refuse("Send a file and a crop value.", 400);
  }

  if (value.length > MAX_VALUE) {
    return refuse("This crop does not fit this photo.", 422);
  }

  const input = Buffer.from(await file.arrayBuffer());
  const meta = await sharp(input)
    .metadata()
    .catch(() => null);

  if (!meta)
    return refuse("The server cannot open this file.", 400);

  if (meta.format === "heif" && meta.compression !== "av1") {
    return refuse("Send a JPEG, PNG, WebP or AVIF.", 415);
  }

  if (meta.width * meta.height > MAX_PIXELS) {
    return refuse("This photo has too many pixels.", 413);
  }

  if (meta.format === "webp" && (meta.orientation ?? 1) > 1) {
    return refuse(
      "save this webp without an orientation tag.",
      422
    );
  }

  try {
    const crop = await cut(input, value);
    const jpeg = await crop
      .timeout({ seconds: MAX_SECONDS })
      .flatten({ background: "#ffffff" })
      .jpeg({ quality: 92 })
      .toBuffer();

    return new Response(new Uint8Array(jpeg), {
      headers: { ...HEADERS, "content-type": "image/jpeg" },
    });
  } catch {
    return refuse("This crop does not fit this photo.", 422);
  }
};

export const POST = async (request: Request) => {
  if (!(await canCrop(request))) {
    return refuse("Sign in to crop a photo.", 401);
  }

  const length = Number(request.headers.get("content-length"));

  if (!(length > 0) || length > MAX_BODY) {
    return refuse("This upload is too large.", 413);
  }

  if (running >= MAX_RUNNING) {
    return refuse("The server is busy. Try again.", 503);
  }

  running += 1;

  try {
    return await answer(request);
  } finally {
    running -= 1;
  }
};

The demo above keeps the limits and parts of crop-upload.tsx. It adds rotate, flip, handles, a grid and reset, shows the angle to one decimal and uses the styles from the zriz site.