Skip to content

Fix: Qwik Not Working — Components Not Rendering, useSignal Not Reactive, or Serialization Errors

FixDevs · (Updated: )

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 nothing

Or 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 defined

Why 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 boundariescomponent$(), 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 SSRwindow, 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@latest
import { 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 array

Fix 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 objectsuseStore 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 navigationrouteLoader$ 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 emptyonStaticGenerate 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles