The Dev Server That Didn't Trust Me — Unlearning a PHP Reflex on the Way to Next.js
A diagram that wouldn't render, a server log that held the answer from the start, and the PHP reflex that cost two hours before anyone read it.
A locally-served app that works perfectly at localhost but breaks at a different hostname — and breaks silently, from the browser's perspective, with no console error, no network failure, nothing. That was the puzzle.
The culprit, it turned out, was a single missing config line. But finding it took two hours of rewriting code, swapping libraries, and questioning basic assumptions — work that was entirely unnecessary because the answer was being written to a log file from the very first request.
This article is about that two-hour detour, what caused it, and why it's the kind of bug a PHP developer would never make in their own world but hits immediately when moving to Next.js.
The symptom: blank diagrams
The blog runs on Next.js 16 with the App Router. Mermaid diagrams, rendered client-side via dynamic import, are a feature. During development, the site is accessed at blog.delaa.test:38090 — a custom local hostname, not localhost.
When visiting any article containing a diagram, the diagram area showed the raw chart source code:
graph TD
A[Start] --> B{Choice}
B -->|Yes| C[End]
B -->|No| D[Retry]
C --> D
No JavaScript error. No 404 in the network tab. The rest of the page worked perfectly — layout, styling, navigation. Just the diagram was broken, and it broke without saying a word.
The same page, served at http://localhost:38090, rendered the diagram correctly.
The detour: two hours of wrong hypotheses
The diagram component is a Client Component that uses useEffect to dynamically import('mermaid'):
useEffect(() => {
import('mermaid').then(async ({ default: mermaid }) => {
const { svg } = await mermaid.render(id, chart)
setSvg(svg)
})
}, [chart])
The .then() call is missing a .catch() — a dynamic import that fails will not throw an error the browser surfaces; the promise just never resolves, and the component stays on its fallback state. From the developer's perspective, the diagram "doesn't render" with zero diagnostics in the browser.
That's the shape of a rabbit hole: a real issue (the missing .catch()) that is also not the root cause. Here are the things tried before the actual cause was found:
- Rewrote the Mermaid component to use a module-level counter for render IDs, because React Strict Mode double-invokes
useEffectand the second invocation could produce a stale SVG. (A legitimate fix for a real race condition — but the wrong fix for this bug.) - Uninstalled
rehype-mermaid, suspected it was interfering with the client-side import. - Tried an alternative Mermaid integration library.
- Rebuilt the component to pre-render diagrams at build time instead of client-side.
All of this produced the same result: diagrams rendered on localhost, broke on the custom hostname. The symptom was consistent, reproducible, and pointed in the wrong direction every time.
The actual root cause
The answer was in the Next.js dev server's own log file, inside .next/dev/logs/next-development.log:
Blocked cross-origin request to Next.js dev resource
/_next/static/chunks/webpack.js from "blog.delaa.test".
Cross-origin access to Next.js dev resources is blocked by
default for safety.
To allow this host in development, add it to
"allowedDevOrigins" in next.config.js:
allowedDevOrigins: ['blog.delaa.test']
Starting with Next.js 15, the dev server blocks cross-origin requests to development-only assets — the same dynamic chunks that Mermaid's import() tries to fetch — unless the requesting origin is explicitly listed in allowedDevOrigins. The protection is a security measure: it prevents a malicious site on the same network from loading Next.js dev resources. But it also blocks your own site when served from a hostname the server wasn't told to trust.
The blocking is triggered by the browser's Origin header. A plain HTTP GET (curl without -H "Origin: ...") returns the chunk normally. A request with a non-localhost Origin gets a 403 with the body Unauthorized and nothing else. In a browser, that means the dynamic fetch for the Mermaid chunk fails silently — the promise chain in import('mermaid').then(...) has no .catch, so the rejection goes unobserved, and the diagram stays on its <pre> fallback.
The fix is a single line:
// next.config.ts
const nextConfig = {
allowedDevOrigins: ['blog.delaa.test'],
// ...
}
Why this is a PHP reflex
The mental model that made this bug expensive is straightforward: in PHP, your dev server trusts everything on the local network. php -S 0.0.0.0:8000 serves anyone who asks. Symfony's symfony server:start does the same. Laravel Valet serves whatever hostname points to the project directory. The idea that a development server would refuse its own content based on the hostname in the URL — that doesn't exist in the PHP world. It's not a better or worse model; it's a different assumption that you don't know you're making until it bites you.
When the symptom appears ("diagram broken on custom hostname, fine on localhost"), a PHP developer's instinct is to look at the application code: the component, the library, the build pipeline. The idea of checking the server's own security logs for a warning that says "I'm blocking you" doesn't occur, because in the PHP world, the server never blocks you.
The method lesson
The server log entry was generated on the very first page load after the site was opened at blog.delaa.test. Two hours of work went past it because nobody looked at it. The browser console showed nothing, so the browser seemed like the wrong place to check — but the error was logged before the browser even received the response.
If I could go back, the first thing I would do is:
- Open the dev server log (
.next/dev/logs/next-development.logby default in Next.js). - Look for the exact resource that failed to load.
- Read the startup warnings printed when the dev server started — they are visible in the terminal on launch and include a reminder to configure
allowedDevOriginsfor non-localhost access.
The data was there, structured, and human-readable. The failure was process, not signal.
Trade-offs
The allowedDevOrigins check is a security feature that prevents a real attack vector: a malicious page on the same network fetching Next.js dev resources that may contain source maps, environment variables, or hot-module replacement endpoints. Blocking by default is the right default. The pain here wasn't the existence of the check — it was that the failure mode produced zero diagnostics in the browser, and the diagnostic that did exist was in a place most developers don't instinctively look.
One improvement worth considering: the error message could suggest checking the dev server log when a cross-origin request to a dynamic chunk fails invisibly. The 403 body currently just says "Unauthorized" — a one-line pointer to allowedDevOrigins in the response body (beyond what the server log already says) would save the next developer the detour.
Delaa