Migrating to Qwik v2

Qwik v2 is a ground-up rewrite of the framework. This guide covers what's new, how to run the automated migration, and what to verify afterward.

What's New in v2

  • Vite environment API — Better monorepo and adapter support
  • Vite 8 / Rolldown — Out of the box compatibility
  • Smaller serialized state — Up to 30% smaller HTML
  • HMR — Instant browser updates without losing state
  • Async useComputed$ — Replaces useResource$ with auto-tracking, polling, concurrency control, and abort
  • <Suspense> — Shows fallback UI while part of the page waits

Quick Start

  1. Run the CLI
  2. Handle third-party libraries
  3. Check behavioral changes
  4. Migrate deprecated APIs
  5. Update your tests
  6. Run the checklist

Run the Migration CLI

pnpm qwik migrate-v2

The CLI handles package renames, identifier changes, config updates, and dependency migration automatically.


Third-Party Libraries

If you use third-party libraries that depend on @builder.io/qwik, you may need to configure overrides and SSR bundling.

Package manager overrides

Redirect the old package name so your package manager doesn't install v1 alongside v2:

{
  "pnpm": {
    "overrides": {
      "@builder.io/qwik": "npm:@qwik.dev/core@^2",
      "@builder.io/qwik-city": "npm:@qwik.dev/router@^2"
    }
  }
}

ssr.noExternal

Qwik libraries must be bundled into the server build for the optimizer to process them:

// vite.config.ts
export default defineConfig({
  ssr: {
    noExternal: ['some-qwik-library'],
  },
  optimizeDeps: {
    exclude: ['some-qwik-library'],
  },
});
NOTEWithout this, you'll see Code(Q30) duplicate runtime errors or "external dependency" warnings.

Behavioral Changes

These won't cause compile errors but will break runtime behavior if not addressed.

useComputed$ now supports async

In v1 useComputed$ rejected async functions. In v2 it accepts them: pass an async function (or return a Promise) and the signal exposes .pending, .error, and .value. Qwik auto-tracks the signals and stores the function reads before the first await; reads after an await must use the track() provided on the context argument.

// v2 — useComputed$ handles async directly
const data = useComputed$(async ({ abortSignal }) => {
  const result = await fetch('/api/data', { signal: abortSignal });
  return result.json();
});
READING COMPUTEDSIGNAL.VALUE

.value throws while unresolved. Always branch on .pending / .error first, or provide an initial value.

let content: JSXOutput;
 
if (data.pending) {
  content = <p>Loading...</p>;
} else if (data.error) {
  content = <p>Error: {data.error.message}</p>;
} else {
  content = <p>{JSON.stringify(data.value)}</p>;
}
 
return <div>{content}</div>;

useVisibleTask$ eagerness removed

The eagerness option ('load' / 'idle') was removed in v2. Delete it if present.

QwikCityProvider → useQwikRouter

Replace the <QwikCityProvider> wrapper with the useQwikRouter() hook:

// v1
import { QwikCityProvider, RouterOutlet } from '@builder.io/qwik-city';
 
export default component$(() => {
  return (
    <QwikCityProvider>
      <head>
        <meta charset="utf-8" />
      </head>
      <body>
        <RouterOutlet />
      </body>
    </QwikCityProvider>
  );
});
// v2
import { RouterOutlet, useQwikRouter } from '@qwik.dev/router';
 
export default component$(() => {
  useQwikRouter();
 
  return (
    <>
      <head>
        <meta charset="utf-8" />
      </head>
      <body>
        <RouterOutlet />
      </body>
    </>
  );
});
NOTEIf your root component is reactive (reads signals), use <QwikRouterProvider> instead. useQwikRouter() only runs once during SSR.

Event attribute names changed

The attributes Qwik writes into the HTML for listeners were renamed. This only matters if you inspect, query, or assert on them — JSX (onClick$, document:onKeyDown$) and useOn*() are unchanged.

Scopev1 attributev2 attributev2 passive
Elementon:clickq-e:clickq-ep:click
Documenton-document:clickq-d:clickq-dp:click
Windowon-window:clickq-w:clickq-wp:click

Passive listeners ({ passive: true }) are new in v2 and get their own prefix, so a query for q-e:scroll will not match a passive scroll listener.

The event name itself is still lowercased unless you opt into case sensitivity with a leading -, but v2 adds two rules: -- is a literal - (dbl--clickdbl-click), and DOMContentLoaded is stored as -d-o-m-content-loaded.

Signal is a wider type

In v1 Signal<T> was { value: T }. In v2 it also has untrackedValue and trigger():

export interface Signal<T = any> {
  value: T;
  untrackedValue: T;
  trigger(): void;
}

Hand-rolled { value } objects therefore no longer satisfy Signal<T>. This shows up most in tests and in helpers that fake a signal. Use the now-public createSignal() instead of a literal:

// v1 — no longer type-checks in v2
const fake: Signal<number> = { value: 0 };
 
// v2
import { createSignal } from '@qwik.dev/core';
const fake = createSignal(0);

client.devInput removed

The client.devInput option of qwikVite() (v1 default src/entry.dev.tsx) is gone. Remove it from vite.config.ts. For SSR apps the dev server renders through ssr.input; for CSR apps (csr: true) it uses Vite's index.html.

Module-level variable mutated inside a closure no longer builds

The v2 Optimizer extracts closures more aggressively than v1. If an extracted closure mutates an exported module-level let, the Optimizer rewrites the reference as an import, and assigning to an import binding fails at build time — a path v1's less aggressive extraction didn't reach.

// Fails to build in v2
export let count = 0;
component$(() => {
  return <button onClick$={() => count++}>{count}</button>;
});

Move the mutation into an exported accessor function in a separate module:

// counter.ts
let count = 0;
export const getCount = () => count;
export const incrementCount = () => count++;
import { getCount, incrementCount } from './counter';
 
component$(() => {
  return <button onClick$={() => incrementCount()}>{getCount()}</button>;
});

See Module-level variables cannot be mutated after extraction.

Serialization

v1 serialized state into <script type="qwik/json"> tags. v2 uses <script type="qwik/vnode"> and <script type="qwik/state"> at the end of the document. No code change needed, but tooling that parses the old tags will need updating.


Deprecated APIs

Still compile in v2, removed in v3.

useResource$ → async useComputed$

Aspectv1 useResource$v2 async useComputed$
Return type.value: Promise<T>.value: T
Track depsctx.track(() => sig.value)read sig.value directly before the first await (auto-tracked); use ctx.track() after an await
AbortManual AbortControllerctx.abortSignal
Previous value-ctx.previous
Polling-usePoll(signal, ms) from @qwik.dev/utils
Initial value-options.initial
Rendering<Resource onResolved={} />.pending/.error branching or <Suspense>

useComputed$() owns the work that creates the value. <Suspense> owns the fallback UI if you want to read .value directly while the value may still be pending.

What changed:

  • track(() => signal.value) → read signal.value directly before the first await (auto-tracked); after an await, keep using the provided track()
  • Manual AbortController + cleanup()ctx.abortSignal
  • <Resource onResolved={} />if/else branching
  • .value is T directly, not Promise<T>
  • .error is Error | undefined
  • For unlimited parallel fetches, pass { concurrency: 0 }

ReadonlySignalReadonly<Signal<T>>

In v1 ReadonlySignal<T> was an alias for Readonly<Signal<T>>. In v2 it is a standalone interface exposing only value, so it is no longer interchangeable with a real signal. Replace it:

// v1
const label: ReadonlySignal<string> = useComputed$(() => name.value);
 
// v2
const label: Readonly<Signal<string>> = useComputed$(() => name.value);

qwik-labs

@builder.io/qwik-labs is removed in v2:

Featurev2 Replacement
Insights@qwik.dev/core/insights + @qwik.dev/core/insights/vite
Typed RoutesBuilt into @qwik.dev/router via qwikTypes()

Tests

@qwik.dev/core/testing still exports createDOM() and trigger(), but v2 dispatches closer to what the browser does, which surfaces test code that relied on the old shortcuts.

event.target is not populated

trigger() builds a real Event and invokes each handler as handler(event, element) rather than calling element.dispatchEvent(), so event.target stays null. Read the element from the second argument:

// v1 — target was often good enough
const onInput$ = $((event: Event) => {
  value.value = (event.target as HTMLInputElement).value;
});
 
// v2 — use the element argument
const onInput$ = $((event: Event, element: HTMLInputElement) => {
  value.value = element.value;
});

Rendering over an existing container

v2 refuses to re-render into an element that already has a q:container. Create a fresh DOM per test instead of reusing one across cases.

trigger() returns the event

trigger() now returns the dispatched Event (or null if nothing matched), so you can assert on defaultPrevented or on payload fields you passed in. It also waits for the container to settle by default; pass { waitForIdle: false } to opt out.

Faking signals

Use createSignal() rather than a { value } literal — see Signal is a wider type.


Troubleshooting

Find your error message below.

useComputed$ QRL ... cannot return a Promise

No longer thrown in v2: useComputed$ now supports async functions. See useComputed$ now supports async.

Only primitive and object literals can be serialized

A class instance or plain function in a store/signal/prop (error Q3). Wrap with noSerialize() or convert to a QRL with $().

Qwik version X already imported while importing Y

Two copies of Qwik loaded (error Q30). Add package manager overrides and ssr.noExternal.

IMPORTANT: This dependency was pre-bundled by Vite

Add the library to optimizeDeps.exclude. See ssr.noExternal.

[package] is being treated as an external dependency

Add to both ssr.noExternal and optimizeDeps.exclude. See ssr.noExternal.

Cannot find module '@builder.io/qwik'

Usually a stale jsxImportSource in tsconfig.json. Run the CLI again or check your tsconfig.json.

Cannot find module '@builder.io/qwik-labs'

Package removed in v2. See qwik-labs.

ERR_REQUIRE_ESM / require() of ES Module

Add "type": "module" to package.json. Run the CLI again if this wasn't set automatically.

Calling a 'use*()' method outside 'component$(...)'

Move the hook inside component$ (error Q10).

Property 'untrackedValue' is missing in type

A { value } object literal used where a Signal is expected. See Signal is a wider type.

Cannot read properties of null (reading 'value') in a test

event.target is null under trigger(). See event.target is not populated.

Cannot assign to import ... / Illegal reassignment of import ...

A module-level let mutated inside a $-extracted closure. The exact wording depends on the bundler: esbuild says Cannot assign to import "x", Rollup/Rolldown say Illegal reassignment of import "x". See Module-level variable mutated inside a closure no longer builds.

Move qwik packages [...] to devDependencies

Move all @qwik.dev/* to devDependencies in package.json.


Verification Checklist

Every item should pass before your migration is done.

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • thejackshelton
  • c0deZ3R0