All posts
11 October 2026

Gov Design System in Nuxt and the hydration that fails silently

I built a demo application on Nuxt 4 with the components of Gov Design System CE, the Czech government design system. It looked fine and the console was clean. Yet on every load Vue threw away a large part of the page the server had sent and built it again. Here is why, and what to do about it.

What happens

The gov-* components are web components written in Stencil, and they do not use shadow DOM. As soon as a component is defined, it takes its children and moves them into its own markup.

Meanwhile Nuxt does this:

  1. the server sends finished HTML,
  2. the browser shows it,
  3. Vue “hydrates” the page: it walks the DOM and compares it with what it would have rendered.

If the components are defined before step 3, Vue finds a tree it does not recognise. Children are elsewhere, wrapper elements have appeared. Vue treats it as a mismatch, discards the components and renders them again. The user sees the content jump, and form fields lose their state for a moment.

Why you do not see it

In a production build Vue does not print mismatch details. And if you use nuxt-security, its removeLoggers option strips console.* calls from production code altogether. The console stays clean while the page is built twice on every load.

To measure it, turn the reporting on in a production build:

// nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true' },
  },
  security: { removeLoggers: false },
})

Then open the page and count the Hydration ... mismatch warnings. Make sure you measure a fresh build: when the build fails, the old output stays in .output and you measure something that no longer exists. It happened to me three times.

The fix: define the components after hydration

Wait until Vue has taken over the page, and only then define the components:

// app/plugins/design-system.client.ts
import { ComponentLibrary } from '@gov-design-system-ce/vue/plugin'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('app:suspense:resolve', async () => {
    await ComponentLibrary.install?.(nuxtApp.vueApp)
  })
})

The mismatches are gone. Three side effects appear instead.

1. Attributes written under the wrong name

Vue cannot set a property on an element that is not defined yet, so it writes an attribute under the property’s name: nativeType becomes the attribute nativetype. The component reads native-type, so a submit button stays an ordinary button.

Once defined, a component knows which attributes it watches (observedAttributes), so they can be put right:

function passOnEarlyAttributes(): void {
  for (const element of document.querySelectorAll('*')) {
    if (!element.localName.startsWith('gov-')) continue

    const component = customElements.get(element.localName) as { observedAttributes?: string[] } | undefined
    for (const name of component?.observedAttributes ?? []) {
      const written = name.replaceAll('-', '')
      if (written !== name && element.hasAttribute(written) && !element.hasAttribute(name)) {
        element.setAttribute(name, element.getAttribute(written) ?? '')
      }
    }
  }
}

Call it right after ComponentLibrary.install.

2. Layout shift

Until the components are defined they have no height, or show only their text. Then they grow and the page jumps. Reserve their space in advance. The :not(:defined) selector is not enough, because an element is “defined” before it has rendered. Stencil, however, adds the class hydrated once it has:

gov-form-input:not(.hydrated),
gov-form-select:not(.hydrated) {
  display: block;
  min-height: 2.5rem;
}

3. v-if inside a component

A v-if directly inside a gov-* component is a trap. Vue remembers where its placeholder node should be, but the component has moved it. When the condition flips, Vue fails on removeChild. Use v-show inside these components:

<GovFormControl>
  <GovFormInput :invalid="!!error" />
  <GovFormMessage v-show="error" variant="error">{{ error }}</GovFormMessage>
</GovFormControl>

Result

After the changes a production build reports 0 mismatches and the cumulative layout shift (CLS) is at most 0.02. The whole demo, tests included, is on GitHub.

Measured with Gov Design System CE 4.7, Nuxt 4.6 and Vue 3.5.

© 2026 Myrha Software