D
P
0

Next.js

`next dev` Is Green but `next build` Fails Type-Check: Dynamic Element With `ref` and `children` Typed `never`

July 8, 2026·4 min read
`next dev` Is Green but `next build` Fails Type-Check: Dynamic Element With `ref` and `children` Typed `never`

For weeks next dev ran clean, not a single red squiggle. I was working on a Reveal component for a client's site: an animation wrapper that sweeps its contents in as they enter the viewport. Because I sometimes wanted it to be a section, sometimes a div, sometimes an article, I made it polymorphic via an as prop. Local was smooth, I pushed, and Vercel slapped me with a red build at the type-check step.

The message looked roughly like this:

Type error: Type '{ children: ReactNode; ... }' is not assignable to type 'IntrinsicAttributes'.
  Property 'children' does not exist on type 'IntrinsicAttributes'.

What confused me more: when I hovered children in the editor, TypeScript said its type was never. The children I was passing clearly existed, yet the compiler was convinced that slot could hold nothing at all. And most annoying of all, this only showed up on Vercel, never on my machine.

Why it only breaks at build

This was the first trap I had to digest: next dev does not run a full project type-check. The dev server is deliberately permissive for speed. It compiles per-route as you open them, and does not run a whole-project tsc. So a type error in a file you have not touched during your dev session can sit silently for weeks.

next build is a different story. On build it runs tsc for the entire project. That is where a long-hidden error finally surfaces. So this was not a bug that "appears in production" in a runtime sense, it was there from the start, the dev server simply never showed it. First lesson already clear: if you want to know what CI will see, run tsc --noEmit locally, do not just trust a green dev server.

The root cause: a dynamic element loses its children guarantee

Now to the type itself. My Reveal component looked roughly like this:

type RevealProps = {
  as?: ElementType
  children?: ReactNode
  className?: string
}
 
function Reveal({ as, children, className }: RevealProps) {
  const Tag = as ?? 'div'
  const ref = useRef<HTMLDivElement>(null)
  // ... IntersectionObserver in useEffect ...
  return (
    <Tag ref={ref} className={className}>
      {children}
    </Tag>
  )
}

The problem is const Tag = as. The as prop is typed as ElementType, which means "any element that could possibly exist". Because ElementType is such a wide union, TypeScript cannot guarantee that an arbitrary tag accepts children, let alone ref. Some ElementType values genuinely do not take children. So when I render Tag and pass children plus ref, TS narrows the children type to never, its way of saying "I cannot prove this slot may be filled". The children were not wrong, the compiler simply refused to guarantee that Tag would accept them.

The dev server never executed that check for Reveal.tsx during my session, which is why it stayed quiet. The tsc at build time did execute it, which is why it blew up.

The fix

The fix is not to drop the public as prop, that has to stay typed ElementType so consumers can freely pass any tag. What I did instead was create an internal alias that explicitly declares the props I actually forward to the element, then cast the dynamic tag to that alias:

const TagBase = as ?? 'div'
const Tag = TagBase as unknown as ComponentType<{
  ref?: Ref<HTMLDivElement>
  children?: ReactNode
  className?: string
}>
 
return (
  <Tag ref={ref} className={className}>
    {children}
  </Tag>
)

The as unknown as cast here is deliberate. I am telling TypeScript: "treat this resolved tag as a component that does accept ref, children, and className." The public as prop stays ElementType, so the component API does not change for callers. All that changes is the internal guarantee right at the render point. After that change, tsc --noEmit exits with code 0, and the Vercel build is green again.

Note the double as unknown as, not a direct cast. ElementType and ComponentType do not overlap enough for a direct cast, TS will reject it. Routing through unknown first is the explicit way to say "I know what I am doing at this exact point".

Checklist

  • Do not trust a green next dev as proof the build passes. The dev server skips the whole-project type-check; next build runs a full tsc.
  • Run tsc --noEmit locally before pushing, and ideally make it a CI step, so type errors surface before Vercel finds them for you.
  • For polymorphic components, keep the public as prop as ElementType, but cast the resolved tag to a ComponentType that explicitly declares ref and children before rendering.
  • If children is suddenly typed never even though it clearly exists, suspect a dynamic or polymorphic element: the compiler cannot prove that slot accepts children, it is not you passing the wrong thing.

Since then I stopped treating a green dev server as a green light to push. What CI actually verifies is tsc, so that is what I run first.