immich-app/immich · warning · NotAcceptableException

The route ${request.path} was requested as ${request.header(

Error message

The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html

What it means

Thrown by the ApiService.ssr Express middleware as NotAcceptableException (HTTP 406) when a non-/api GET/HEAD request (not on an excluded path) does not accept text/html. The middleware powers server-side rendering of the web app and Open Graph tags for share links, so it only serves HTML. Any client that demands JSON (e.g. Accept: application/json) on a share or root route trips this.

Source

Thrown at server/src/services/api.service.ts:61

    try {
      index = readFileSync(resourcePaths.web.indexHtml).toString();
    } catch {
      this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
    }

    return async (request: Request, res: Response, next: NextFunction) => {
      const method = request.method.toLowerCase();
      if (
        request.url.startsWith('/api') ||
        (method !== 'get' && method !== 'head') ||
        excludePaths.some((item) => request.url.startsWith(item))
      ) {
        return next();
      }

      const responseType = request.accepts('text/html');
      if (!responseType) {
        throw new NotAcceptableException(
          `The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html`,
        );
      }

      let status = 200;
      let html = index;

      const defaultDomain = request.host ? `${request.protocol}://${request.host}` : undefined;

      let meta: OpenGraphTags | null = null;

      const shareKey = request.url.match(/^\/share\/(.+)$/);
      if (shareKey) {
        try {
          const key = shareKey[1];
          const auth = await this.authService.validateSharedLinkKey(key);
          meta = await this.sharedLinkService.getMetadataTags(auth, defaultDomain);
        } catch {

View on GitHub (pinned to 199723261c)

Solutions

  1. Send `Accept: text/html` (or `*/*`) when fetching share pages or any non-/api route
  2. Use the /api/* endpoints for machine-readable data instead of the SSR routes
  3. Configure programmatic clients to vary Accept by route family

Example fix

// before
await fetch(`${origin}/share/abc`, { headers: { accept: 'application/json' } }); // 406
// after
await fetch(`${origin}/share/abc`, { headers: { accept: 'text/html' } });
Defensive patterns

Strategy: validation

Validate before calling

// Set Accept correctly for SSR vs API routes
const headers = isShareRoute(url) ? { accept: 'text/html' } : { accept: 'application/json' };
await fetch(url, { headers });

Type guard

function acceptsHtml(accept: string | undefined): boolean {
  if (!accept) return true; // default
  return /text\/html|\*\/\*/i.test(accept);
}

Try / catch

try {
  return await fetch(url, { headers: { accept: 'text/html' } });
} catch (e) {
  if (e instanceof HttpError && e.status === 406) {
    // retry with explicit text/html, or fall back to /api route
  }
  throw e;
}

Prevention

When it happens

Trigger: GET /share/:key, /, or any non-/api GET route with an Accept header that excludes text/html — e.g. `Accept: application/json`, `Accept: image/*`, or a strict `Accept` from a programmatic client/scraper.

Common situations: Curl/HTTPie defaults that send `Accept: */*` work, but clients that pin `Accept: application/json` for all calls get 406; bots/scrapers with restrictive Accept; SDK code reused for share-link fetching that assumes JSON.

Related errors


AI-assisted analysis of immich-app/immich@199723261c (2026-08-12). Data as JSON: /api/errors/c3502b882068f7da. Report an issue: GitHub.