Fix: Qwik Not Working — Components Not Rendering, useSignal Not Reactive, or Serialization Errors
Part of: React & Frontend Errors
Quick Answer
How to fix Qwik issues — component$ boundaries, useSignal and useStore reactivity, serialization with dollar signs, useTask$ and useVisibleTask$, Qwik City routing, and integration with React components.
The Problem
A Qwik component renders on the server but doesn’t become interactive in the browser:
import { component$, useSignal } from '@builder.io/qwik';
export default component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
Count: {count.value}
</button>
);
});
// Button renders but clicking does nothingOr a serialization error appears at build time:
Error: Qwik Serialization Error: Cannot serialize class instance
Offending value: [object Date]Or data fetching runs twice — once on the server and once on the client:
export const useUserData = routeLoader$(() => {
console.log('Fetching user'); // Prints twice
return fetch('/api/user').then(r => r.json());
});Or a third-party library doesn’t work because it accesses window during SSR:
ReferenceError: window is not definedWhy This Happens
Qwik uses resumability instead of hydration. The server renders the HTML and serializes the component state into the DOM. The browser does not re-execute component code until user interaction triggers it. This is fundamentally different from React, Vue, or Svelte, where the client re-runs the entire component tree to attach event handlers. Qwik’s optimizer splits each $-suffixed function into its own JavaScript chunk that the browser fetches on demand, which is why an unloaded chunk shows up as a button that renders but does not click.
The $ suffix is the single most important concept. $ marks serialization boundaries — component$(), onClick$(), useTask$(), and other $-suffixed APIs tell the Qwik optimizer where to split code. Anything crossing a $ boundary must be serializable to JSON. Classes, DOM nodes, functions (without $), and closures over non-serializable values break this contract and surface as “Cannot serialize” build errors. State must use useSignal or useStore — plain JavaScript variables are not tracked. let count = 0 inside a component does not trigger re-renders when mutated. Qwik’s reactivity system tracks .value reads on signals and property access on store proxies. Destructuring a signal (const { value } = count) loses reactivity because the JSX no longer reads .value from the signal proxy.
The server/client split creates two more pitfalls. routeLoader$ runs only on the server during SSR and its result is serialized into the HTML. It re-runs on client-side navigations but not on full page loads, so seeing it execute “twice” almost always means a parent layout loader plus the page loader both ran during the same request. Browser APIs are not available during SSR — window, document, localStorage, and other browser globals do not exist on the server. The fix is useVisibleTask$() for code that must run in the browser, which Qwik schedules only after the relevant DOM element becomes visible.
Platform and Environment Differences
Qwik runs on top of Vite, so adapters are how it ships to a specific platform. The official adapters cover Vercel (@builder.io/qwik-city/adapters/vercel-edge/vite), Cloudflare Pages (@builder.io/qwik-city/adapters/cloudflare-pages/vite), Netlify Edge (@builder.io/qwik-city/adapters/netlify-edge/vite), and Node.js (@builder.io/qwik-city/adapters/node/vite). Each adapter generates a different entry file and bundles a different runtime — switching adapters mid-project requires running npm run qwik add again because the build output structure changes.
The Vercel adapter targets Vercel Edge by default, which means your routeLoader$ and server$ functions run on V8 isolates with Web APIs only. Node-specific modules (fs, path, native bindings) fail at runtime. The Node Vercel adapter exists but is opt-in. The Cloudflare Pages adapter is similar: Workers runtime, no Node APIs, but you get Cloudflare KV, R2, and D1 via the platform.env binding. Bun is supported as a build runtime via qwik-bun adapters but the runtime parity is not 100% — some Vite plugins assume Node and break under Bun.
SSR vs SSG is decided per route. By default, Qwik City does SSR on every request. To pre-render at build time, export onStaticGenerate from a route file or add it to the adapter config. Marketing pages typically go SSG; dashboards stay SSR; mixed pages use routeLoader$ with cache-control headers. Resumability is per-request — the server emits a unique resumability map embedded in the HTML, so SSG-rendered pages share the same map across users, which is fine because the resumability data references DOM positions, not user state.
The Qwik City router uses file conventions: index.tsx is the page, layout.tsx wraps children with <Slot />, [param]/index.tsx is a dynamic segment, and (group)/ (parens) creates a route group without affecting the URL. Edge runtime caps apply on Vercel and Cloudflare — bundle size limits and the absence of Node APIs both matter. If a third-party library pulls in fs transitively, the edge build fails; switch the route to the Node runtime via the platform’s per-route config, or move the dependency into a server$ that runs on Node.
Fix 1: Component and Reactivity Basics
npm create qwik@latestimport { component$, useSignal, useStore, $ } from '@builder.io/qwik';
// component$ — marks a component boundary
export const Counter = component$(() => {
// useSignal — for primitive values
const count = useSignal(0);
// useStore — for objects (deeply reactive)
const state = useStore({
items: ['Apple', 'Banana'],
filter: '',
});
// $() wraps functions that cross serialization boundaries
const increment = $(() => {
count.value++;
});
const addItem = $((item: string) => {
state.items.push(item);
});
return (
<div>
{/* Access signal with .value */}
<button onClick$={increment}>Count: {count.value}</button>
{/* Inline handlers also use $() syntax via onClick$ */}
<button onClick$={() => count.value--}>Decrement</button>
{/* Store properties are reactive */}
<input
value={state.filter}
onInput$={(e: Event) => {
state.filter = (e.target as HTMLInputElement).value;
}}
/>
<ul>
{state.items
.filter(item => item.toLowerCase().includes(state.filter.toLowerCase()))
.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<button onClick$={() => addItem('Cherry')}>Add Cherry</button>
</div>
);
});Common reactivity mistakes:
// WRONG — destructuring loses reactivity
const { value } = count; // value is a static number, not reactive
<span>{value}</span> // Never updates
// CORRECT — access .value in JSX
<span>{count.value}</span> // Updates when count changes
// WRONG — plain variable isn't tracked
let name = 'Alice';
<button onClick$={() => { name = 'Bob'; }}>Change</button>
// Button works but UI doesn't update
// CORRECT — use useSignal
const name = useSignal('Alice');
<button onClick$={() => { name.value = 'Bob'; }}>Change</button>
// WRONG — replacing store object
const state = useStore({ items: [] });
state = { items: ['new'] }; // ERROR — can't reassign store
// CORRECT — mutate store properties
state.items = ['new']; // Replace array
state.items.push('new item'); // Mutate arrayFix 2: Fix Serialization Errors
Everything that crosses a $ boundary must be serializable:
// WRONG — Date is not serializable by default
const state = useStore({
createdAt: new Date(), // Serialization error
});
// CORRECT — store dates as strings or timestamps
const state = useStore({
createdAt: new Date().toISOString(), // String is serializable
});
// CORRECT — use noSerialize for non-serializable values
import { noSerialize, type NoSerialize } from '@builder.io/qwik';
const state = useStore<{ chart: NoSerialize<ChartInstance> | undefined }>({
chart: undefined,
});
// Set non-serializable values inside useVisibleTask$ (runs only in browser)
useVisibleTask$(() => {
const chart = new ChartLibrary('#chart');
state.chart = noSerialize(chart);
// Cleanup
return () => chart.destroy();
});
// WRONG — passing a class instance through a $() boundary
class UserService {
getUser() { return fetch('/api/user'); }
}
const service = new UserService();
const handler = $(() => service.getUser()); // Serialization error
// CORRECT — use plain functions or server$
const getUser = server$(async () => {
return fetch('/api/user').then(r => r.json());
});
const handler = $(() => getUser());Fix 3: Data Loading with routeLoader$ and server$
// src/routes/posts/index.tsx — Qwik City route
import { component$ } from '@builder.io/qwik';
import { routeLoader$, routeAction$, Form, z, zod$ } from '@builder.io/qwik-city';
// routeLoader$ — runs on the server during page load
export const usePostsLoader = routeLoader$(async (requestEvent) => {
const response = await fetch('https://api.example.com/posts', {
headers: {
Authorization: `Bearer ${requestEvent.env.get('API_KEY')}`,
},
});
if (!response.ok) {
throw requestEvent.redirect(302, '/error');
}
return response.json() as Promise<Post[]>;
});
// routeAction$ — server-side mutation triggered by form submission
export const useCreatePost = routeAction$(
async (data, requestEvent) => {
const response = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
return requestEvent.fail(400, { message: 'Failed to create post' });
}
return { success: true };
},
// Validate with Zod
zod$({
title: z.string().min(1).max(200),
body: z.string().min(10),
}),
);
export default component$(() => {
const posts = usePostsLoader(); // Access loader data
const createAction = useCreatePost();
return (
<div>
{/* Loader data */}
<ul>
{posts.value.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
{/* Form triggers routeAction$ */}
<Form action={createAction}>
<input name="title" required />
<textarea name="body" required />
{createAction.value?.failed && (
<p>{createAction.value.message}</p>
)}
<button type="submit" disabled={createAction.isRunning}>
{createAction.isRunning ? 'Creating...' : 'Create Post'}
</button>
</Form>
</div>
);
});// server$ — RPC-style server function callable from the client
import { server$ } from '@builder.io/qwik-city';
const searchUsers = server$(async function (query: string) {
// This code runs on the server — access env, DB, etc.
const db = this.env.get('DATABASE_URL');
const results = await dbQuery(`SELECT * FROM users WHERE name LIKE $1`, [`%${query}%`]);
return results;
});
// Call from a component — transparently makes a server request
export const SearchComponent = component$(() => {
const results = useSignal<User[]>([]);
return (
<div>
<input
onInput$={async (e: Event) => {
const query = (e.target as HTMLInputElement).value;
if (query.length > 2) {
results.value = await searchUsers(query);
}
}}
/>
<ul>
{results.value.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
});Fix 4: useTask$ vs useVisibleTask$
Two task types serve different purposes:
import { component$, useSignal, useTask$, useVisibleTask$ } from '@builder.io/qwik';
export default component$(() => {
const data = useSignal<string>('');
const windowWidth = useSignal(0);
// useTask$ — runs on server AND client
// Tracks signal dependencies and re-runs when they change
useTask$(({ track }) => {
// Track a specific signal
const value = track(() => data.value);
console.log('Data changed to:', value);
// This runs on the server during SSR
// and on the client when data.value changes
});
// useTask$ with cleanup
useTask$(({ track, cleanup }) => {
const query = track(() => data.value);
// Debounce — cleanup cancels the previous timer
const timer = setTimeout(() => {
// Fetch search results
}, 300);
cleanup(() => clearTimeout(timer));
});
// useVisibleTask$ — runs ONLY in the browser
// Use for: DOM manipulation, browser APIs, third-party libraries
useVisibleTask$(() => {
// Safe to use window, document, localStorage
windowWidth.value = window.innerWidth;
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
// useVisibleTask$ with strategy
useVisibleTask$(
() => {
// Initialize a chart library
const chart = new Chart('#chart', { data: [] });
return () => chart.destroy();
},
{
strategy: 'intersection-observer', // Run when element is visible
// strategy: 'document-ready', // Run on DOMContentLoaded
// strategy: 'document-idle', // Run when browser is idle (default)
},
);
return (
<div>
<input
value={data.value}
onInput$={(e: Event) => {
data.value = (e.target as HTMLInputElement).value;
}}
/>
<p>Window width: {windowWidth.value}</p>
</div>
);
});Fix 5: Qwik City Routing
src/routes/
├── index.tsx # /
├── about/
│ └── index.tsx # /about
├── posts/
│ ├── index.tsx # /posts
│ ├── [postId]/
│ │ └── index.tsx # /posts/:postId
│ └── layout.tsx # Shared layout for /posts/*
├── layout.tsx # Root layout
└── 404.tsx # Custom 404 page// src/routes/layout.tsx — root layout
import { component$, Slot } from '@builder.io/qwik';
import { Link, useLocation } from '@builder.io/qwik-city';
export default component$(() => {
const loc = useLocation();
return (
<>
<nav>
<Link href="/" class={{ active: loc.url.pathname === '/' }}>
Home
</Link>
<Link href="/posts/" class={{ active: loc.url.pathname.startsWith('/posts') }}>
Posts
</Link>
</nav>
<main>
<Slot /> {/* Child route renders here */}
</main>
</>
);
});
// src/routes/posts/[postId]/index.tsx — dynamic route
import { component$ } from '@builder.io/qwik';
import { routeLoader$, useLocation } from '@builder.io/qwik-city';
export const usePost = routeLoader$(async (requestEvent) => {
const postId = requestEvent.params.postId;
const res = await fetch(`https://api.example.com/posts/${postId}`);
if (!res.ok) throw requestEvent.redirect(302, '/posts/');
return res.json();
});
export default component$(() => {
const post = usePost();
return (
<article>
<h1>{post.value.title}</h1>
<p>{post.value.body}</p>
</article>
);
});Fix 6: Integrate Third-Party Libraries
Non-Qwik libraries need special handling for the $ boundary:
import { component$, useSignal, useVisibleTask$, noSerialize, type NoSerialize } from '@builder.io/qwik';
// Example: Leaflet map
export const MapComponent = component$<{ lat: number; lng: number }>(({ lat, lng }) => {
const mapRef = useSignal<HTMLDivElement>();
const mapInstance = useSignal<NoSerialize<L.Map>>();
useVisibleTask$(async () => {
// Dynamic import — loads only in browser
const L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
if (!mapRef.value) return;
const map = L.map(mapRef.value).setView([lat, lng], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
L.marker([lat, lng]).addTo(map);
mapInstance.value = noSerialize(map);
return () => map.remove();
});
return <div ref={mapRef} style={{ height: '400px', width: '100%' }} />;
});
// Example: Using a React component in Qwik
// Install: npm install @builder.io/qwik-react react react-dom
import { qwikify$ } from '@builder.io/qwik-react';
import { DatePicker } from 'some-react-datepicker';
// Wrap React component for Qwik
const QwikDatePicker = qwikify$(DatePicker, { eagerness: 'hover' });
export const FormWithDatePicker = component$(() => {
const date = useSignal('');
return (
<QwikDatePicker
selected={date.value}
onChange$={(newDate: string) => {
date.value = newDate;
}}
/>
);
});Still Not Working?
Component renders but buttons/inputs are not interactive — Qwik lazy-loads event handlers. If the JavaScript chunk fails to load (network error, incorrect base URL), interactions silently fail. Check the browser Network tab for failed chunk requests. Also verify that onClick$ uses the $ suffix — onClick (without $) binds a regular handler that won’t be serialized and won’t work after SSR.
useStore changes don’t trigger re-renders for nested objects — useStore is deeply reactive by default, but replacing a nested object breaks the proxy chain. Use state.nested.property = newValue (mutate) instead of state.nested = { ...state.nested, property: newValue } (replace). For arrays, push, splice, and index assignment work. Direct reassignment of the array (state.items = newArray) also works.
routeLoader$ data is stale after client-side navigation — routeLoader$ re-runs on each navigation by default. If the data appears stale, the issue is usually caching at the API level. Check if your API endpoint returns cached responses. For real-time data, use server$ with manual refresh instead of routeLoader$.
Build fails with “cannot capture” or “cannot serialize” — a non-serializable value is crossing a $ boundary. The error message usually names the offending value. Wrap it in noSerialize(), move it inside a useVisibleTask$, or restructure so it doesn’t cross the boundary.
server$ calls fail on Cloudflare with “fetch is not defined” or Node module errors — Cloudflare Workers runtime is V8-only. A Node-specific dependency (like fs, crypto.randomBytes, or a native module) was pulled into the server function bundle. Replace with Web APIs or move the offending code into a route handler that runs only on Node deploys. See Fix: Cloudflare R2 Not Working for adjacent Workers-runtime constraints.
Vercel deploy succeeds but routes return 500 with no logs — the adapter you installed targets a runtime your code does not support (edge vs. Node). Check adapters/vercel-edge/vite.config.ts or adapters/vercel/vite.config.ts and align it with the actual runtime your dependencies need. For deeper Vercel build diagnostics, see Fix: Vercel Deployment Failed.
SSG pages skip routeLoader$ and ship empty — onStaticGenerate was added but no params were returned. Static generation needs an explicit list of paths to render. Export onStaticGenerate returning { params: [{ slug: 'a' }, { slug: 'b' }] } so the build knows which dynamic routes to emit.
For related framework issues, see Fix: SolidJS Not Working and Fix: SvelteKit Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working
How to fix ky HTTP client issues — instance creation, hooks (beforeRequest, afterResponse), retry configuration, timeout handling, JSON parsing, error handling, and migration from fetch or axios.