Next.js und imgproxy

Verlässt man sich bei der Verarbeitung von Benutzerdaten auf die Dienste von Drittanbietern, besteht immer das Risiko, dass daraus zukünftig potenzielle Datenschutzprobleme entstehen. Denn: fragt man beispielsweise ein Bild auf einem Third-Party Server an, werden dabei automatisch persönliche Daten mitgeliefert (unter anderem die IP-Adresse). Unsere neue Next.js-Website wollen wir daher auf unseren eigenen Servern hosten ohne für das Ausliefern der Inhalte auf Drittanbieter angewiesen zu sein. Dafür braucht es einen effizienten selbstgehosteten Image-Loader – zum Beispiel imgproxy.

Ein Loader ist im Grunde nur eine Funktion, die eine URL mit einigen Parametern (z.B. Breite, Qualität) entgegennimmt und eine Bild-URL zurückgibt, die der Browser verwenden soll.

Next.js enthält bereits einige nette Optimierungen für Bilder und verlässt sich auf sogenannte Loader, um in der Größe skalierte Bilder zu liefern. Der in Next integrierte Loader ist für die Entwicklung zwar in Ordnung, für Production gibt es jedoch ein paar fehlende Funktionen (wie z.B. erweitertes Processing wie ein Beschneiden) und auch die Performance war aus unserer Sicht nicht ganz optimal. Folglich brauchten wir also eine bessere Möglichkeit Bilder zu verarbeiten, die wir nachfolgend Schritt für Schritt vorstellen:

  • imgproxy als eigenständigen Dienst zur Verarbeitung von Bildern nutzen
  • imgproxy im Kontext eines Next.js-Projekts
  • Verwendung eines benutzerdefinierten Image-Loaders in Next.js mit imgproxy
  • Sollte nicht lieber ein CDN verwendet werden?
  • Fazit

imgproxy als eigenständigen Dienst zur Verarbeitung von Bildern nutzen

imgproxy is a fast and secure standalone server for resizing and converting remote images. The main principles of imgproxy are simplicity, speed, and security. - github.com/imgproxy/imgproxy

Wir nutzen imgproxy bereits in einigen Projekten, um Bilder skalieren und verarbeiten zu können, ohne alle Funktionen dafür in einem API-Backend-Dienst implementieren zu müssen. Das reduziert die Komplexität und macht es einfacher, die Anwendung zu warten. Allerdings gibt es einen Haken: da imgproxy sehr fokussiert ist, beinhaltet es von Haus aus kein Caching und verlässt sich für das Caching der verarbeiteten Bilder auf HTTP-Header. Das macht imgproxy zwar weniger komplex, erfordert jedoch auch das Einbauen einer Cache-Ebene in der Anwendung, um eine übermäßige Verarbeitung von Bildern für jeden einzelnen Besucher zu verhindern.

Für unser Next.js-Deployment haben wir bereits eine benutzerdefinierte Nginx-Konfiguration: Diese verwendet proxy_cache, um Proxy-Anfragen zu speichern und die Zugriffe auf die Node.js-Anwendung zu reduzieren. Daher ist es naheliegend, auch Anfragen an imgproxy darüber zu cachen und somit wiederzuverwenden.

imgproxy im Kontext eines Next.js-Projekts

Wie genau passt imgproxy also in ein Next.js-Projekt?

  1. Die ursprüngliche Bildquelle wird mit Hilfe der Funktion imgproxyLoader in eine URL umgewandelt, die von imgproxy erkannt wird (siehe unten)
  2. Der Browser greift auf den Nginx Reverse Proxy unter /img zu, der eine Anfrage an imgproxy stellt (falls nicht in proxy_cache gecached)
  3. imgproxy hat Zugriff auf die Dateien des Next.js-Projekts, verarbeitet das Bild gemäß der URL-Verarbeitungsoptionen und gibt das Bild an den Browser zurück
  4. Nginx speichert nun diese Antwort als Cache-Eintrag für zukünftige Anfragen der gleichen Bildvariante und sendet sie an den Browser
  5. Das Bild wird im Browser angezeigt

Verwendung eines benutzerdefinierten image loaders in Next.js mit imgproxy

Wir haben eine spezifische Loader-Funktion definiert, die eine imgproxy URL für ein Bild generiert. Sie empfängt zuerst die imgproxy URL und gibt dann eine andere Funktion zurück, die in der loader prop von next/image akzeptiert wird.

lib/images/imgproxy-loader.js
const imgproxyLoader =
  ({ url }) =>
  ({ src, width, quality, ratio }) => {
    const targetURL = src.startsWith("http") ? src : `local://${src}`;
    const encodedURL = urlSafeBase64(targetURL);
    let processingOptions = `preset:default/gravity:sm/resize:fill:${width}`;
    if (typeof ratio !== "undefined") {
      const height = Math.round(width / ratio);
      processingOptions += `:${height}`;
    }
    const path = `/${processingOptions}/${encodedURL}`;
    return `${url}${path}`;
  };

const urlSafeBase64 = (string) => {
  return Puffer.aus(string)
    .toString("base64")
    .replace(/=/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
};

export default imgproxyLoader;

Hinweis: Wir nutzen hier nicht das Signieren von URLs, da Next.js diese Funktion auf dem Client evaluiert - und dieser kann keine Secrets "behalten". Es gäbe also keinen Sicherheitsgewinn durch das Signieren der imgproxy-URL. Wenn die URLs auf local:///public oder eine andere feste URL mit IMGPROXY_ALLOW_ORIGIN beschränkt sind, werden die Auswirkungen der Manipulation der URL minimiert.

Wir haben dann einen benutzerdefinierten LoaderContext deklariert, der in der _app.js initialisiert wird, um eine Loader-Instanz für alle Komponenten und die imgproxy-URL aus einer Umgebungsvariablen NEXT_PUBLIC_IMGPROXY_URL bereitzustellen:

lib/images/LoaderContext.js
import { createContext, useContext } from "react";

import imgproxyLoader from "./imgproxy-loader";

const LoaderContext = createContext();

const useLoader = (opts = {}) => {
  const loader = useContext(LoaderContext);
  if (loader) {
    // Pass opts to call to loader function
    return (args) => loader({ ...opts, ...args });
  }
};

const LoaderProvider = ({ children }) => {
  const loader = createLoader();

  return (
    <LoaderContext.Provider value={loader}>{children}</LoaderContext.Provider>
  );
};

const createLoader = () => {
  if (process.env.NEXT_PUBLIC_IMGPROXY_URL) {
    return imgproxyLoader({
      url: process.env.NEXT_PUBLIC_IMGPROXY_URL,
    });
  } else {
    console.debug(
      "No imgproxy environment variables set, using no custom loader"
    );
  }
};

export { LoaderProvider, useLoader };

Hinweis: Stellt sicher, dass die env variable NEXT_PUBLIC_IMGPROXY_URL auf die URL der imgproxy-Instanz / Nginx Pfad des Proxy gesetzt ist (z.B. /img). Das kann in der .env.production mit NEXT_PUBLIC_IMGPROXY_URL=/img erreicht werden. Während der Entwicklung wird die Standard-URL verwendet, wenn keine Umgebungsvariable gesetzt ist.

pages/_app.js
import { LoaderProvider } from "../lib/images/LoaderContext";

function MyApp({ Component, pageProps }) {
  return (
    <LoaderProvider>
      <Component {...pageProps} />
    </LoaderProvider>
  );
}

export default MyApp;

Jetzt können wir den aktuellen Loader mit useLoader in allen Komponenten verwenden oder eine eigene Bildkomponente definieren, die das tut:

pages/index.js
export default function Home({ assets }) {
  const loader = useLoader();

  return (
    <p>See this image, it's beautiful:</p>
    <Image
      src="/images/large-image.jpg"
      width={800}
      height={800}
      layout="responsive"
      loader={loader}
    />
  );
}

Für die Nginx Konfiguration findet sich auf Github eine Beispiel-Konfiguration.

Sollte nicht lieber ein CDN verwendet werden?

Natürlich gibt es viele Gründe dafür, ein CDN zu verwenden, das die Bildverarbeitung und das Caching bereits übernimmt. Beim Entwickeln einer selbst gehosteten Website sollte man aber in der Lage sein, die Bilder von den eigenen Servern aus bereitzustellen, wenn das gewollt oder gebraucht wird. Außerdem gibt es einen weiteren Use-Case: interne Websites mit Bildern, die nicht öffentlich zugänglich sein sollen. Darüber hinaus kann es bei Content Delivery Networks Datenschutzbedenken geben, wenn Anfragen an Server von Dritten gesendet werden.

Fazit

Mit imgproxy und Nginx können wir ein Next.js-Projekt mit effizienter und schneller Bildverarbeitung hosten. Es lässt sich einfach und robust in Docker-Containern implementieren. Und: die Verwendung von Nginx als Reverse-Proxy und Cache-Ebene reduziert die Anfragen an imgproxy und verbessert die Performance zusätzlich.

Du benötigst Hilfe bei einem Next.js Projekt oder möchtest Dich zu dem Thema austauschen? Schreib' mich gerne an.