Next.js 16 Image remotePatterns Wildcard Not Working

Next.js 16 next/image throwing 400 on subdomain wildcards in remotePatterns? Use the new array syntax and explicit pathnames to unblock builds today.
Next.jsnext/imageReact
May 12, 20265 min read950 words

The Problem

Upgraded a client SaaS dashboard from Next.js 15.4 to 16.2 last week and every avatar on the team page broke. next/image was returning 400 Bad Request with the body "url" parameter is not allowed, even though the same remotePatterns config worked fine on 15.

The pattern that broke looked like this:

// next.config.ts — worked on Next.js 15
images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: '**.amazonaws.com',
    },
  ],
}

On 15 this matched team-uploads.s3.us-east-2.amazonaws.com. On 16 it matched nothing. CDN-served Storybook docs broke the same way, so did a Cloudinary integration that had been live for two years.

If your next/image calls started returning 400 after upgrading to 16, and you are using a wildcard hostname or omitting pathname, this is the same bug. Here is what changed and how I fixed it.

Why It Happens

Next.js 16 tightened the remotePatterns matcher in two ways that the upgrade guide mentions in passing but does not call out as breaking:

  1. hostname no longer accepts ** as a multi-segment wildcard. The double-asterisk used to match team.s3.us-east-2.amazonaws.com in one go. In 16, ** was removed entirely. You now use * for a single segment, and you cannot use a leading wildcard to match any number of subdomains. The matcher silently treats **.amazonaws.com as a literal hostname and never finds a match.
  2. pathname is no longer optional when hostname contains a wildcard. If you wildcard the hostname, you must also restrict the path, otherwise the image optimizer refuses the request as a safety measure against open-proxy abuse. Skipping pathname worked on 15 and now throws 400.

There is a third subtlety I only spotted on a Cloudinary integration: the new localPatterns field, introduced for next/image self-hosted images, defaults to deny any local path with a query string. If you were passing ?v=2026-05 to bust CDN cache, the optimizer rejects it even though the file is local.

The Fix

Step 1: Replace ** with explicit subdomain patterns. The new matcher only understands * as a single-segment wildcard, so an S3 region pattern becomes two entries, not one:

// next.config.ts — Next.js 16
import type { NextConfig } from 'next';

const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.s3.us-east-2.amazonaws.com',
        pathname: '/team-uploads/**',
      },
      {
        protocol: 'https',
        hostname: '*.s3.amazonaws.com',
        pathname: '/team-uploads/**',
      },
    ],
  },
};

export default config;

Notice the pathname is now required because the hostname contains a wildcard. The /** at the end of pathname is a different beast: that is the path-segment wildcard from path-to-regexp, and it is still allowed.

Step 2: For Cloudinary, Imgix, and other CDNs with a fixed host, drop the wildcard entirely. Pinning the hostname makes the matcher faster and removes any ambiguity:

remotePatterns: [
  {
    protocol: 'https',
    hostname: 'res.cloudinary.com',
    pathname: '/your-cloud-name/image/upload/**',
    search: '', // disallow query strings, optional but recommended
  },
]

The search field is new in 16. Set it to '' to require an empty query string, or omit it to allow anything. Adding search: '' blocks the open-proxy attack vector where someone passes ?invalidate=1 to bust your CDN cache and rack up your transformation bill.

Step 3: If you need to allow many subdomains, generate the array at build time. I do this for a multi-tenant client where each tenant gets a *.tenants.app.com subdomain. Hardcoding 200 entries is a non-starter, so I generate them from the tenant table during build:

// next.config.ts
const tenants = process.env.TENANT_HOSTS?.split(',') ?? [];

const config: NextConfig = {
  images: {
    remotePatterns: [
      ...tenants.map( ( host ) => ( {
        protocol: 'https' as const,
        hostname: host,
        pathname: '/uploads/**',
      } ) ),
    ],
  },
};

The tenant list comes from a CI step that hits the internal API before next build runs. This avoids the wildcard restriction entirely and gives the image optimizer an exact host list to match against.

Step 4: Allow query-stringed local paths if you actually need them. Less common but easy to miss. If you are loading /og/cover.png?v=2026-05 from public/, configure localPatterns:

images: {
  localPatterns: [
    {
      pathname: '/og/**',
      search: '?**', // allow any query string
    },
  ],
}

Without this entry the optimizer rejects any local URL that includes a ?.

Step 5: Verify in dev before deploying. The 400 response is silent in production logs unless you have an onError handler on every <Image>. Add a temporary one while testing:

<Image
  src={user.avatar}
  alt={`${user.name} avatar`}
  width={64}
  height={64}
  onError={ ( e ) => console.error( 'Image rejected:', user.avatar, e ) }
/>

When I have hit this before, around 30 percent of <Image> usages had silent fallbacks the design team had not noticed because the placeholder ratio was right.

The Lesson

Next.js 16's stricter remotePatterns is good news — **.anything.com was always a dangerous open-host config — but the migration is not painless. Replace ** with explicit * entries, add pathname whenever the hostname is wildcarded, and pin the host whenever you can. For another silent 16 upgrade gotcha I have written up, see Next.js 16 async params TypeError in dynamic routes.

If your Next.js 16 upgrade is producing this kind of silent regression, this is the kind of audit work I do — see my services for a structured upgrade pass.

Back to blogStart a project