Serving WebP and JPEG XL with content negotiation

WebP is decoded by every current browser. JPEG XL is decoded by Safari and, behind flags or recent releases, by a growing set of others. The problem has never been encoding the files; it is serving the right one to each visitor without rewriting every <img> tag, every theme and every cached page. Content negotiation solves that at the web server, and it is what Alemedia does.

How content negotiation works

Every browser sends an Accept header with image requests. A browser that can decode WebP includes image/webp; one that decodes JPEG XL includes image/jxl. The server can read that header and answer a request for photo.jpg with a different file, as long as it tells the browser what it sent.

That is the whole trick. The HTML keeps pointing at photo.jpg. The server checks what the browser accepts, checks whether a converted sibling exists, and serves it with the matching Content-Type.

What Alemedia does on upload

When you upload a JPEG or PNG, WordPress generates its usual subsizes (thumbnail, medium, large and so on). Alemedia hooks into that step and, for the original and every subsize, writes a WebP copy next to the file. The naming is photo.jpg.webp, the same convention EWWW uses, so sites that already have those files keep working.

If the cjxl encoder from libjxl is installed on the server (the plugin looks in /usr/bin and /usr/local/bin, or wherever ALEMEDIA_CJXL points), it also writes photo.jpg.jxl. JPEGs are repacked losslessly, so the original bytes are recoverable from the JXL file. PNGs are encoded at the same quality setting as WebP.

Two rules keep the output honest:

  • A converted copy that is not smaller than its source is deleted. You never serve a bigger file for the sake of a newer format.
  • Because JXL is served ahead of WebP, the JXL copy has to beat the WebP copy, not just the original. Otherwise it is discarded.

The plugin stores the byte totals per attachment, so you can see what each image actually saved. Images uploaded before the plugin was active (or before cjxl was available) are converted in batches from the Library screen.

The server rules

On Apache and LiteSpeed, Alemedia writes a block into .htaccess on activation, between # BEGIN Alemedia and # END Alemedia markers. Simplified, it reads:

RewriteCond %{HTTP_ACCEPT} image/jxl
RewriteCond %{REQUEST_FILENAME}.jxl -f
RewriteRule ^(wp-content/uploads/.+.(?:jpe?g|png))$ $1.jxl [NC,T=image/jxl,E=ALEMEDIA_NEG:1,L]
RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{REQUEST_FILENAME}.webp -f
RewriteRule ^(wp-content/uploads/.+.(?:jpe?g|png))$ $1.webp [NC,T=image/webp,E=ALEMEDIA_NEG:1,L]
Header append Vary Accept env=REDIRECT_ALEMEDIA_NEG

Read it top to bottom: if the browser accepts JXL and the .jxl sibling exists, serve it. Otherwise, if the browser accepts WebP and the .webp sibling exists, serve that. Otherwise the request falls through and Apache serves the original JPEG or PNG. The T= flag sets the content type, and the Vary: Accept header tells caches and CDNs that the response depends on the Accept header, so a WebP response is not cached and handed to a browser that cannot decode it.

The rules are refreshed when the plugin updates itself, since a self-update does not run the activation hook. They are removed on deactivation.

If you run nginx, the plugin does not write server configuration for you. The equivalent is a map on $http_accept and a try_files that checks for the sibling file first; the fallback behavior is the same.

Why not <picture>?

A <picture> element with <source type="image/jxl"> and <source type="image/webp"> also works, and it does not depend on server configuration. The cost is that every image in every template, block, widget and plugin output has to be rewritten, and CSS background images cannot use it at all. Content negotiation covers all of them at once, including images in cached HTML and in third-party plugin output, without touching the markup.

The folder library

The second half of Alemedia is unrelated to formats: a folder tree for the media library. Folders are a hierarchical taxonomy on attachments (alemedia_folder), exposed through the REST API, and the plugin adds a folder filter to the media modal, so you can browse by folder wherever you pick an image. They are organizational only; the file paths on disk do not change, so nothing you link to breaks when you move an image between folders.

Checking that it works

Open a page in a browser, open the Network panel, and click an image request. The URL will end in .jpg, and the response Content-Type will say image/webp or image/jxl. If it still says image/jpeg, check that the .htaccess block is present and that the converted sibling exists next to the original in wp-content/uploads.

Share
Keep reading

More from the resources

Ready to build your site?

Pick a kit and host it for $12 a month, or send the form and we set it up for a custom quote. Either way the site is yours.

Get a quote