Skip to content

Custom elements in MDX bypass the renderer pipeline — no SSR output #16273

@piotrekwitkowski

Description

@piotrekwitkowski

Summary

Custom elements (tags containing hyphens like <my-element>) in .astro files are routed through the renderer pipeline, allowing any registered renderer to SSR them. The same elements in .mdx files are treated as raw HTML strings — renderers are never consulted.

This affects any web component library (Lit, Stencil, custom implementations) that registers a renderer for custom elements.

Expected behavior

<my-element> in an MDX file should produce the same SSR output as <my-element> in an .astro file when a renderer claims it.

Actual behavior

  • .astro: <my-element>renderComponent() → renderer check() → SSR output ✅
  • .mdx: <my-element>renderElement() → raw HTML string concatenation ❌

Root cause

The .astro compiler (Go) explicitly detects custom elements and routes them through renderComponent:

// parser.go:414
func isCustomElement(data string) bool {
    return strings.Contains(data, "-")
}

// print-to-js.go:381
isComponent := isFragment || n.Component || n.CustomElement

The JSX runtime (jsx.ts:96) has no equivalent — all string-typed vnodes go to renderElement():

case typeof vnode.type === 'string' && vnode.type !== ClientOnlyPlaceholder:
    return markHTMLString(await renderElement(result, vnode.type, vnode.props ?? {}));

renderElement() is pure string concatenation. No renderer is ever consulted.

Proposed fix

Add a hyphen check so custom elements fall through to the existing renderComponentToString() path (which already handles string-typed components correctly at line 147–153):

case typeof vnode.type === 'string' && vnode.type !== ClientOnlyPlaceholder && !vnode.type.includes('-'):
    return markHTMLString(await renderElement(result, vnode.type, vnode.props ?? {}));

Custom elements then reach renderComponentToString()renderComponent()renderFrameworkComponent() → renderer check() — the same path the .astro compiler uses. If no renderer claims the element, the existing fallback in renderComponent() renders it as raw HTML, so there's no regression for projects without a matching renderer.

Impact

Tested across a production site with 80+ web components used in MDX content pages:

Metric Before After
SSR'd custom elements 77% 99%
Build regressions 0 0

Reproduction

  1. Create an Astro 6 project with @astrojs/mdx and a custom element renderer
  2. Register a web component: my-element
  3. Use <my-element> in a .astro file → inspect output, SSR'd ✅
  4. Use <my-element> in an .mdx file → inspect output, raw HTML ❌

The change is in packages/astro/src/runtime/server/jsx.ts, line 96.

Metadata

Metadata

Assignees

No one assigned

    Labels

    - P4: importantViolate documented behavior or significantly impacts performance (priority)pkg: astroRelated to the core `astro` package (scope)pkg: mdxIssues pertaining to `@astrojs/mdx` integration

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions