Serve WebP From Your Server Using Content Negotiation
Use HTTP Accept header content negotiation to automatically serve WebP to supporting browsers from Apache, Nginx, or Node.js, with no HTML changes needed.
If you want to serve WebP without modifying your HTML, server-side content negotiation lets you automatically send WebP to browsers that declare support in their Accept header and fall back to JPEG or PNG for the rest — no <picture> element required. This is particularly useful when migrating large existing sites where changing every image tag isn’t practical.
How It Works #
When a browser requests an image, it includes an Accept header listing the MIME types it can display. Modern browsers that support WebP send a header like:
Accept: image/webp,image/*,*/*;q=0.8
Your server reads this header and, if a .webp version of the requested file exists on disk, responds with it instead of the original JPEG or PNG. Browsers that omit image/webp from their Accept header receive the original format unchanged.
Always set the
Vary: Acceptresponse header on images served through content negotiation. Without it, CDNs and reverse proxies cache whichever format they receive first and serve it to every subsequent visitor — including browsers that don’t support WebP. This is one of the most common misconfiguration pitfalls.
Apache Configuration #
Add the following rules to your .htaccess file or virtual host configuration. The RewriteRule only triggers when the browser signals WebP support and a corresponding .webp file exists on disk.
<IfModule mod_rewrite.c>
RewriteEngine On
# Serve WebP if browser supports it and .webp file exists
RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{REQUEST_FILENAME}.webp -f
RewriteRule ^(.+)\.(jpe?g|png)$ $1.webp [T=image/webp,L]
</IfModule>
<IfModule mod_headers.c>
# Vary header for proper CDN caching
Header always append Vary Accept
</IfModule>
<IfModule mod_mime.c>
AddType image/webp .webp
</IfModule>
The T=image/webp flag forces Apache to send the correct Content-Type header when rewriting to .webp, which is necessary because the URL still ends in .jpg or .png after the rewrite. Using %{REQUEST_FILENAME} in the condition (rather than %{DOCUMENT_ROOT}/$1) works correctly in both .htaccess and virtual-host contexts without path-separator issues.
Nginx Configuration #
Use Nginx’s map directive to set a suffix variable based on the Accept header, then use try_files to attempt the WebP variant before falling back to the original.
# Place the map block inside your http {} context, outside any server {} block
map $http_accept $webp_suffix {
default "";
"~*image/webp" ".webp";
}
server {
# root must be set here or inside the location block
location ~* ^(.+)\.(jpe?g|png)$ {
add_header Vary Accept always;
try_files $1$webp_suffix $uri =404;
}
}
The location block captures the path without its extension into $1. When $webp_suffix is .webp, try_files checks for photo.webp first and falls back to photo.jpg if no WebP version exists. Store your converted WebP files without the original extension (e.g. photo.webp, not photo.jpg.webp) to match this convention.
Node.js / Express Middleware #
For Express applications, register a middleware function before your static file handler. The middleware intercepts image requests, checks the Accept header, and serves the .webp variant from disk if it exists.
// Express middleware to serve WebP when supported
const path = require('path');
const fs = require('fs');
function webpMiddleware(req, res, next) {
const accept = req.headers['accept'] || '';
if (accept.includes('image/webp')) {
const webpPath = req.path.replace(/\.(jpe?g|png)$/, '.webp');
const fullPath = path.join(__dirname, 'public', webpPath);
if (fs.existsSync(fullPath)) {
res.set('Vary', 'Accept');
return res.sendFile(fullPath);
}
}
next();
}
app.use(webpMiddleware);
app.use(express.static('public'));
Place webpMiddleware before express.static so it intercepts image requests first. If no .webp file exists, next() passes control to the static file handler which serves the original.
The <picture> element approach is simpler and requires no server configuration at all. Server-side content negotiation is best suited to migrating existing sites where you can’t or don’t want to modify HTML templates. For new projects, consider the <picture> element first.
Testing Your Configuration #
Use curl to verify that your server returns WebP to supporting browsers and the original format to others.
Test WebP Delivery #
# Should return Content-Type: image/webp
curl -H 'Accept: image/webp,image/*,*/*' -I https://example.com/photo.jpg
Test Fallback Delivery #
# Should return Content-Type: image/jpeg
curl -H 'Accept: image/jpeg,image/*,*/*' -I https://example.com/photo.jpg
Verify Vary Header #
# Look for: Vary: Accept in the response headers
curl -I https://example.com/photo.jpg | grep -i vary
Checklist Before Going Live #
- Pre-generate all WebP variants — Run your entire image library through a converter (such as
cwebp,sharp, or our free online batch converter) before deploying. Content negotiation only works if the.webpfile already exists on disk. - Set Vary: Accept on image responses — Confirm the header appears in your curl output. This single header prevents cache poisoning at every layer of your CDN and reverse proxy stack.
- Register the image/webp MIME type — Some server configurations don’t recognise
.webpout of the box. Add the MIME type mapping so theContent-Typeheader is set correctly. - Test with a non-WebP client — Use a browser in a virtual machine, or craft a
curlrequest withoutimage/webpin theAcceptheader, to confirm the fallback path works correctly.
