NextImg Components React

Copy-paste React components for image upload & compression. Built for Next.js + Tailwind CSS — zero dependencies, just drop into your project.

Point your components at your NextImg server:

NEXT_PUBLIC_NEXTIMG_URL=http://localhost:3000
Components

ImageUploader

Drop Zone · Upload · Compress
All-in-one drag-and-drop image upload with built-in compression. Returns compressed binary by default, or full JSON stats when returnStats is set. Supports quality, format, and onComplete callback.
"use client";

import { useCallback, useRef, useState } from "react";

const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? "";

interface CompressResult {
  blob: Blob;
  originalSize: number;
  compressedSize: number;
  savedPercent: number;
  downloadUrl?: string;
  filename?: string;
}

interface ImageUploaderProps {
  quality?: number;                  // 1-100 (default 80)
  format?: "webp" | "avif" | "jpeg" | "png";
  returnStats?: boolean;             // true → JSON mode with download URL
  accept?: string;                   // file input accept (default "image/*")
  maxSizeMB?: number;                // max file size in MB (default 10)
  onComplete?: (r: CompressResult) => void;
  onError?: (err: string) => void;
  className?: string;
  children?: React.ReactNode;       // custom drop zone content
}

export function ImageUploader({
  quality = 80,
  format,
  returnStats = false,
  accept = "image/*",
  maxSizeMB = 10,
  onComplete,
  onError,
  className = "",
  children,
}: ImageUploaderProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = useState(false);
  const [loading, setLoading] = useState(false);
  const [preview, setPreview] = useState<string | null>(null);
  const [result, setResult] = useState<CompressResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  const compress = useCallback(async (file: File) => {
    if (file.size > maxSizeMB * 1024 * 1024) {
      const msg = `File exceeds ${maxSizeMB} MB limit`;
      setError(msg); onError?.(msg); return;
    }

    setError(null); setLoading(true); setResult(null);
    setPreview(URL.createObjectURL(file));

    const params = new URLSearchParams({ quality: String(quality) });
    if (format) params.set("format", format);

    const body = new FormData();
    body.append("file", file);

    try {
      const headers: Record<string, string> = {};
      if (returnStats) headers["Accept"] = "application/json";

      const res = await fetch(
        `${NEXTIMG_URL}/upload/compress?${params}`,
        { method: "POST", headers, body }
      );
      if (!res.ok) throw new Error(await res.text());

      let r: CompressResult;

      if (returnStats) {
        const json = await res.json();
        const dlPath = new URL(json.downloadUrl, location.href).pathname;
        r = {
          blob: await fetch(dlPath).then((r) => r.blob()),
          originalSize: json.originalSize,
          compressedSize: json.optimizedSize,
          savedPercent: json.savedPercent,
          downloadUrl: dlPath,
          filename: json.filename,
        };
      } else {
        const blob = await res.blob();
        r = {
          blob,
          originalSize: file.size,
          compressedSize: blob.size,
          savedPercent: +((1 - blob.size / file.size) * 100).toFixed(1),
        };
      }

      setResult(r);
      onComplete?.(r);
    } catch (e: any) {
      const msg = e?.message ?? "Compression failed";
      setError(msg); onError?.(msg);
    } finally {
      setLoading(false);
    }
  }, [quality, format, returnStats, maxSizeMB, onComplete, onError]);

  const onDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault(); setDragging(false);
    const file = e.dataTransfer.files[0];
    if (file) compress(file);
  }, [compress]);

  return (
    <div className={`relative ${className}`}>
      <div
        onClick={() => inputRef.current?.click()}
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
        onDragLeave={() => setDragging(false)}
        onDrop={onDrop}
        className={`
          flex flex-col items-center justify-center gap-2
          rounded-xl border-2 border-dashed p-8 cursor-pointer
          transition-colors duration-200
          ${dragging
            ? "border-indigo-500 bg-indigo-500/5"
            : "border-zinc-700 hover:border-zinc-500 bg-zinc-900/50"
          }
        `}
      >
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          className="hidden"
          onChange={(e) => e.target.files?.[0] && compress(e.target.files[0])}
        />
        {children ?? (
          <>
            <svg className="w-8 h-8 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
              <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
            </svg>
            <p className="text-sm text-zinc-400">
              <span className="font-medium text-white">Click to upload</span> or drag & drop
            </p>
            <p className="text-xs text-zinc-500">PNG, JPG, WebP, AVIF • max {maxSizeMB}MB</p>
          </>
        )}
      </div>

      {loading && (
        <div className="mt-3 flex items-center gap-2 text-sm text-zinc-400">
          <span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-600 border-t-indigo-400" />
          Compressing…
        </div>
      )}

      {error && (
        <p className="mt-3 text-sm text-red-400">⚠ {error}</p>
      )}

      {result && (
        <div className="mt-4 rounded-lg border border-zinc-800 bg-zinc-900 p-4">
          {preview && (
            <img
              src={URL.createObjectURL(result.blob)}
              alt="compressed"
              className="mb-3 max-h-48 rounded-md object-contain"
            />
          )}
          <div className="grid grid-cols-3 gap-3 text-center text-xs">
            <div>
              <div className="text-lg font-bold">{fmtBytes(result.originalSize)}</div>
              <div className="text-zinc-500">Original</div>
            </div>
            <div>
              <div className="text-lg font-bold">{fmtBytes(result.compressedSize)}</div>
              <div className="text-zinc-500">Compressed</div>
            </div>
            <div>
              <div className={`text-lg font-bold ${
                result.savedPercent > 0 ? "text-green-400" : "text-red-400"
              }`}>
                {result.savedPercent > 0 ? "−" : "+"}{Math.abs(result.savedPercent)}%
              </div>
              <div className="text-zinc-500">Saved</div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

function fmtBytes(b: number): string {
  if (b < 1024) return b + " B";
  if (b < 1024 * 1024) return (b / 1024).toFixed(1) + " KB";
  return (b / 1024 / 1024).toFixed(2) + " MB";
}
import { ImageUploader } from "@/components/image-uploader";

export default function Page() {
  return (
    <ImageUploader
      quality={75}
      format="webp"
      onComplete={(r) => console.log("Compressed!", r)}
      className="max-w-md mx-auto"
    />
  );
}

// With JSON stats mode (returns download URL + server-side stats):
<ImageUploader returnStats quality={60} format="avif" />

// Custom drop zone content:
<ImageUploader quality={80}>
  <p>Drop your photo here</p>
</ImageUploader>

useImageCompress

Hook · Headless
A headless React hook for when you want full control over the UI. Provides compress(file), loading/error state, and typed result — wire it to any file input or custom drop zone.
"use client";

import { useCallback, useState } from "react";

const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? "";

interface CompressOptions {
  quality?: number;
  format?: "webp" | "avif" | "jpeg" | "png";
  returnStats?: boolean;
}

interface CompressResult {
  blob: Blob;
  url: string;            // object URL for preview
  originalSize: number;
  compressedSize: number;
  savedPercent: number;
  downloadUrl?: string;   // server path (stats mode)
  filename?: string;
}

export function useImageCompress(opts: CompressOptions = {}) {
  const { quality = 80, format, returnStats = false } = opts;
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [result, setResult] = useState<CompressResult | null>(null);

  const compress = useCallback(async (file: File) => {
    setLoading(true); setError(null); setResult(null);

    const params = new URLSearchParams({ quality: String(quality) });
    if (format) params.set("format", format);

    const body = new FormData();
    body.append("file", file);

    try {
      const headers: Record<string, string> = {};
      if (returnStats) headers["Accept"] = "application/json";

      const res = await fetch(
        `${NEXTIMG_URL}/upload/compress?${params}`,
        { method: "POST", headers, body }
      );
      if (!res.ok) throw new Error(await res.text());

      if (returnStats) {
        const json = await res.json();
        const dlPath = new URL(json.downloadUrl, location.href).pathname;
        const blob = await fetch(dlPath).then((r) => r.blob());
        setResult({
          blob, url: URL.createObjectURL(blob),
          originalSize: json.originalSize,
          compressedSize: json.optimizedSize,
          savedPercent: json.savedPercent,
          downloadUrl: dlPath, filename: json.filename,
        });
      } else {
        const blob = await res.blob();
        setResult({
          blob, url: URL.createObjectURL(blob),
          originalSize: file.size,
          compressedSize: blob.size,
          savedPercent: +(((1 - blob.size / file.size) * 100).toFixed(1)),
        });
      }
    } catch (e: any) {
      setError(e?.message ?? "Compression failed");
    } finally {
      setLoading(false);
    }
  }, [quality, format, returnStats]);

  const reset = useCallback(() => {
    if (result?.url) URL.revokeObjectURL(result.url);
    setResult(null); setError(null);
  }, [result]);

  return { compress, reset, loading, error, result } as const;
}
"use client";

import { useImageCompress } from "@/hooks/use-image-compress";

export default function MyUploadPage() {
  const { compress, loading, error, result, reset } = useImageCompress({
    quality: 75,
    format: "webp",
  });

  return (
    <div className="p-8 max-w-md mx-auto">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => e.target.files?.[0] && compress(e.target.files[0])}
      />

      {loading && <p>Compressing…</p>}
      {error && <p className="text-red-400">{error}</p>}

      {result && (
        <div>
          <img src={result.url} alt="compressed" className="max-h-64 rounded" />
          <p>{result.compressedSize} bytes ({result.savedPercent}% saved)</p>
          <button onClick={reset}>Reset</button>
        </div>
      )}
    </div>
  );
}

ImageCompareSlider

Before / After · Slider
A before/after image comparison slider. Feed it originalSrc and compressedSrc (blob URLs or regular URLs) and it renders a draggable divider. Works great with the hook above.
"use client";

import { useCallback, useRef, useState } from "react";

interface ImageCompareSliderProps {
  originalSrc: string;
  compressedSrc: string;
  height?: number;        // px, default 300
  className?: string;
}

export function ImageCompareSlider({
  originalSrc,
  compressedSrc,
  height = 300,
  className = "",
}: ImageCompareSliderProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState(50);
  const dragging = useRef(false);

  const updatePos = useCallback((clientX: number) => {
    const el = containerRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const pct = Math.max(0, Math.min(100,
      ((clientX - rect.left) / rect.width) * 100
    ));
    setPos(pct);
  }, []);

  const onPointerDown = useCallback((e: React.PointerEvent) => {
    dragging.current = true;
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
    updatePos(e.clientX);
  }, [updatePos]);

  const onPointerMove = useCallback((e: React.PointerEvent) => {
    if (dragging.current) updatePos(e.clientX);
  }, [updatePos]);

  const onPointerUp = useCallback(() => {
    dragging.current = false;
  }, []);

  return (
    <div
      ref={containerRef}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      className={`
        relative select-none cursor-col-resize overflow-hidden
        rounded-xl border border-zinc-800 bg-zinc-950 ${className}
      `}
      style={{ height }}
    >
      {/* Original (right side / base) */}
      <img
        src={originalSrc}
        alt="original"
        className="absolute inset-0 h-full w-full object-contain"
        draggable={false}
      />

      {/* Compressed (left side, clipped) */}
      <div
        className="absolute inset-y-0 left-0 overflow-hidden"
        style={{ width: `${pos}%` }}
      >
        <img
          src={compressedSrc}
          alt="compressed"
          className="h-full object-contain"
          style={{ width: containerRef.current?.offsetWidth }}
          draggable={false}
        />
      </div>

      {/* Divider line */}
      <div
        className="absolute inset-y-0 w-0.5 bg-white/80 -translate-x-1/2"
        style={{ left: `${pos}%` }}
      >
        <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
          w-8 h-8 rounded-full bg-white shadow-lg flex items-center justify-center">
          <span className="text-zinc-600 text-sm">↔</span>
        </div>
      </div>

      {/* Labels */}
      <span className="absolute top-2 left-2 text-[10px] font-bold uppercase
        tracking-wider bg-black/50 text-white px-2 py-0.5 rounded"
      >Compressed</span>
      <span className="absolute top-2 right-2 text-[10px] font-bold uppercase
        tracking-wider bg-black/50 text-white px-2 py-0.5 rounded"
      >Original</span>
    </div>
  );
}
import { ImageCompareSlider } from "@/components/image-compare-slider";

// Using with the useImageCompress hook:
const { compress, result } = useImageCompress({ quality: 70 });

{result && (
  <ImageCompareSlider
    originalSrc={originalPreviewUrl}
    compressedSrc={result.url}
    height={400}
    className="max-w-xl"
  />
)}

// Or with static URLs:
<ImageCompareSlider
  originalSrc="/photos/original.jpg"
  compressedSrc="http://localhost:3000/compress/https://example.com/photo.jpg?quality=60"
  height={350}
/>

CompressedImage

Next/Image · Drop-in
A drop-in <img> replacement that proxies the src through NextImg for on-the-fly compression. Ideal for displaying remote images already optimized — no upload needed.
import React from "react";

const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? "";

interface CompressedImageProps
  extends React.ImgHTMLAttributes<HTMLImageElement> {
  src: string;             // full image URL
  quality?: number;        // 1-100 (default 80)
  format?: "webp" | "avif" | "jpeg" | "png";
  width?: number;
  height?: number;
}

export function CompressedImage({
  src,
  quality = 80,
  format,
  width,
  height,
  ...props
}: CompressedImageProps) {
  const params = new URLSearchParams({ quality: String(quality) });
  if (format) params.set("format", format);
  if (width)  params.set("width", String(width));
  if (height) params.set("height", String(height));

  // Use /compress/ for optimization, /image/ for resize
  const endpoint = width || height ? "image" : "compress";
  const proxiedSrc = `${NEXTIMG_URL}/${endpoint}/${src}?${params}`;

  return <img src={proxiedSrc} loading="lazy" {...props} />;
}
import { CompressedImage } from "@/components/compressed-image";

// Compress a remote image on-the-fly
<CompressedImage
  src="https://images.unsplash.com/photo-123456"
  quality={70}
  format="webp"
  alt="My photo"
  className="w-full rounded-xl"
/>

// Resize + compress
<CompressedImage
  src="https://example.com/hero.png"
  width={800}
  height={600}
  quality={60}
  format="avif"
  alt="Hero"
/>

compressImage

Utility · Promise-based
A standalone async function (no React needed). Use it in API routes, server actions, or vanilla scripts. Returns a typed result object.
const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? "";

interface CompressImageOptions {
  quality?: number;
  format?: "webp" | "avif" | "jpeg" | "png";
}

interface CompressImageResult {
  blob: Blob;
  originalSize: number;
  compressedSize: number;
  savedPercent: number;
}

export async function compressImage(
  file: File,
  opts: CompressImageOptions = {}
): Promise<CompressImageResult> {
  const { quality = 80, format } = opts;
  const params = new URLSearchParams({ quality: String(quality) });
  if (format) params.set("format", format);

  const body = new FormData();
  body.append("file", file);

  const res = await fetch(
    `${NEXTIMG_URL}/upload/compress?${params}`,
    { method: "POST", body }
  );

  if (!res.ok) throw new Error(await res.text());

  const blob = await res.blob();
  return {
    blob,
    originalSize: file.size,
    compressedSize: blob.size,
    savedPercent: +(((1 - blob.size / file.size) * 100).toFixed(1)),
  };
}
import { compressImage } from "@/lib/compress-image";

// In a form submit handler:
async function handleSubmit(form: FormData) {
  const file = form.get("avatar") as File;
  const { blob, savedPercent } = await compressImage(file, {
    quality: 70,
    format: "webp",
  });
  console.log(`Saved ${savedPercent}%`);

  // Upload the compressed blob to your storage
  const uploadForm = new FormData();
  uploadForm.append("file", blob, "avatar.webp");
  await fetch("/api/upload", { method: "POST", body: uploadForm });
}

// In a Next.js API route / Server Action:
const result = await compressImage(file, { quality: 80 });
// result.blob → write to S3, disk, etc.