Skip to content

[Start] Dev server breaks on IP/Safari: Nitro intercepts $id route modules when Sec-Fetch-Dest header is missing #7095

@babpulss

Description

@babpulss

Description

When accessing a TanStack Start app via IP address (e.g. http://192.168.0.64:3000) or Safari, route files containing $ (like $id.edit.tsx) fail to load as JS modules. Nitro's dev middleware intercepts these requests and interprets $id as a route parameter instead of letting Vite serve them.

This effectively breaks Vite's server.host: true option for TanStack Start projects.

Reproduction

  1. Create a TanStack Start project with dynamic route files (e.g. routes/_authed/manager/$id.edit.tsx)
  2. Set server.host: true in vite.config.ts
  3. Access the app via IP address or Safari

Expected

Modules load correctly, same as Chrome on localhost.

Actual

GET http://192.168.0.64:3000/app/routes/_authed/manager/$id.edit.tsx
→ 307 redirect to /app/routes/_authed/manager/undefined
→ 404

The app fails to hydrate because the route modules can't be loaded.

Root cause

Nitro's nitroDevMiddlewarePre middleware decides whether to intercept a request based on the Sec-Fetch-Dest header:

// nitro/dist/_chunks/plugin.mjs
if (!fetchDest || /^(document|iframe|frame|empty)$/.test(fetchDest)) {
    nitroDevMiddleware(req, res, next);  // Nitro handles
} else {
    next();  // Vite handles
}

Browsers don't always send this header:

Browser localhost IP address
Chrome script missing
Safari missing missing

Verified by logging incoming headers on a standalone HTTP server.

When the header is missing, Nitro intercepts the module request. Since the URL contains $id (TanStack Router's filename convention for dynamic params), Nitro's routing resolves $id to undefined and returns a 307 redirect.

On localhost with Chrome, Sec-Fetch-Dest: script is sent, so Nitro skips the request and Vite serves the module correctly.

Additional issue: Secure cookie on HTTP

useSession() sets the Secure flag on the session cookie by default. This prevents the cookie from being stored on HTTP connections (IP access). Chrome makes an exception for localhost, but Safari and IP access don't work.

This means even after fixing the module loading issue, login doesn't work on IP/Safari because the session cookie is rejected by the browser.

Workaround:

useSession<SessionData>({
  password: process.env.SESSION_SECRET!,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
  },
})

Workaround for module loading

A Vite plugin that injects the missing header before Nitro's middleware:

import { type Plugin } from 'vite'

function patchSecFetchDest(): Plugin {
  return {
    name: 'patch-sec-fetch-dest',
    configureServer(server) {
      server.middlewares.use((req, _res, next) => {
        if (!req.headers['sec-fetch-dest'] && req.url && /\.[mc]?[jt]sx?(\?|$)/.test(req.url)) {
          req.headers['sec-fetch-dest'] = 'script'
        }
        next()
      })
    },
  }
}

Environment

  • TanStack Start: v1.167+
  • Nitro: v3 (via nitro/vite)
  • Vite: v8.0.3
  • macOS, tested with Chrome, Safari, Playwright (Chromium headless + WebKit)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions