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
  • useAsync$ — Replaces useResource$ with 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. 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$ is sync-only

useComputed$ rejects async functions in v2. Error Q29 fires at runtime.

// v1 (no longer supported in v2)
const data = useComputed$(async () => {
  const result = await fetch('/api/data');
  return result.json();
});
// v2
const data = useAsync$(async ({ abortSignal }) => {
  const result = await fetch('/api/data', { signal: abortSignal });
  return result.json();
});
READING ASYNCSIGNAL.VALUE

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

let content: JSXOutput;
 
if (data.loading) {
  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.

Action and loader failures: value.failederror

The producer side is unchanged: keep using return requestEvent.fail(status, data) for expected failures (form validation, domain rules), and throw requestEvent.error(status, data) still aborts to the nearest error page, exactly like v1.

What moved is the consumer side. In v1, fail() results landed on action.value with a failed: true marker, so .value was a union of the success and failure shapes. In v2, failures surface on the reactive action.error / loader.error as a ServerError.status is the HTTP status, .data is the canonical payload, and the payload's fields are also exposed flat (e.g. error.fieldErrors). .value is now the success type only and is undefined on failure; .value and .error are never both set.

// v1
{action.value?.failed && <p>{action.value.fieldErrors?.name}</p>}
{action.value?.success && <p>Done!</p>}
 
// v2
{action.error && <p>{action.error.fieldErrors?.name}</p>}
{action.value?.success && <p>Done!</p>}
  • The StrictUnion success/failure value unions are gone — no more action.value?.failed narrowing. FailReturn<T> still exists but is symbol-branded; it no longer has a failed: true property and never appears in .value's type. fail() payloads are type-inferred into .error instead.
  • Loaders that used throw error(status, data) to render error pages keep that behavior — thrown error()s abort to the error page and never land on .error.
  • On loaders, .error can also hold a plain Error on the client (the loader's fetch itself failed). Its status and payload fields are typed optional across the union, so a plain property check discriminates (if (loader.error?.status)). Reading loader.value while the signal is in error state throws — check .error first. In catch blocks (around submit() or resolveValue()), check errors structurally (typeof err.status === 'number') — instanceof ServerError is false on the client after deserialization.

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$ → useAsync$

Aspectv1 useResource$v2 useAsync$
Return type.value: Promise<T>.value: T
Track depsctx.track(() => sig.value)ctx.track(sig) (both forms work)
AbortManual AbortControllerctx.abortSignal
Previous value-ctx.previous
Polling-options.interval
Initial value-options.initial
Rendering<Resource onResolved={} />.loading/.error branching or <Suspense>

useAsync$() 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)track(signal) (shorthand, old form still works)
  • 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 }

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()

Troubleshooting

Find your error message below.

useComputed$ QRL ... cannot return a Promise

Replace with useAsync$. See useComputed$ is sync-only.

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).

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