Qwik and Resumability: Instant-Loading Apps with routeLoader$, routeAction$ and Qwik City
Key takeaways
Qwik serializes component state and event bindings into the server-rendered HTML, so the browser resumes where the server stopped instead of re-running components to hydrate. This guide explains the $ optimizer boundary, signals and stores, routeLoader$ and routeAction$, the useVisibleTask$ trade-off, and adapters for deployment.
Introduction
Qwik is a new kind of web framework that solves the hydration problem. Instead of shipping JavaScript to re-create the app state on the client, Qwik serializes the state on the server and resumes execution on the client.
Created by Miško Hevery (creator of Angular and AngularJS) and backed by Builder.io, Qwik represents a fundamental rethinking of how web frameworks should work. The core innovation — resumability — eliminates hydration entirely.
Why Qwik Matters
The hydration cost. In a server-rendered React, Vue or Svelte app, the HTML arrives quickly, but it is not interactive until the framework has downloaded the code for the components on the page and re-executed them in the browser to rebuild its internal state and attach event listeners. That work grows with the size of the page, not with how much of it the user touches, and on a slow phone it is the gap between “I can see the button” and “the button does something”.
Qwik’s approach: the server serializes what the client would need (component state, which handler belongs to which element, which signals feed which parts of the DOM) into the HTML. The browser starts with a tiny loader that listens for events globally. When the user clicks, Qwik looks up the handler for that element, downloads just that chunk, and runs it against the serialized state. Nothing is re-executed up front, so startup cost stays roughly constant as the page grows.
Trade-offs worth knowing before choosing it:
- The first interaction on a given handler can trigger a network request for its code. Qwik prefetches likely chunks (through a service worker and speculative module fetching) to hide that, but on a poor connection the first click can still wait.
- Everything captured by a
$function must be serializable, which constrains how you write components (see the FAQ). - The ecosystem is much smaller than React’s. React component libraries do not run natively;
qwikify$()can wrap React components, but they then hydrate like React islands and lose the resumability benefit.
When to use Qwik:
- Content-heavy and marketing sites with scattered interactivity
- E-commerce pages where fast startup on mobile matters
- Sites with large pages where hydration cost would grow with the page
When to use Next.js/Remix instead:
- Complex dashboards with lots of client-side state
- Need larger ecosystem and more libraries
- Team already expert in React patterns
- Using React Native (Qwik is web-only)
The Hydration Problem
Traditional SSR (React/Next.js):
- Server renders HTML
- Browser displays HTML (fast!)
- JavaScript downloads
- Hydration: React re-runs all components to attach listeners
- App becomes interactive (slow!)
Qwik’s Resumability:
- Server renders HTML + serialized state
- Browser displays HTML (fast!)
- App is immediately interactive
- JavaScript loads only when needed
The honest comparison is not “Qwik ships less code” but “Qwik defers code until it is needed”. A counter still needs its click handler and the code that updates the text; the difference is that it is fetched on first interaction (or prefetched in the background) rather than executed before the page becomes usable. Measure your own pages with Lighthouse or WebPageTest on a throttled mobile profile; the difference is largest for big pages with little interactivity and smallest for app-like pages where most code runs anyway.
Installation
npm create qwik@latest
cd my-qwik-app
npm install
npm run dev
Basic Concepts
Components
import { component$ } from '@builder.io/qwik';
export const Counter = component$(() => {
return <div>Hello, Qwik!</div>;
});
Note the $ suffix: It tells Qwik optimizer to lazy-load this code.
The optimizer is a build step (a Vite plugin) that splits your source at every $. The function passed to component$, onClick$, useTask$ and so on becomes its own small module, referenced from the HTML by a URL-like pointer called a QRL. That is why the $ is not decoration: without it, the code stays in the parent chunk and cannot be loaded independently. It is also why functions inside $ cannot freely close over anything: the extracted module runs later, possibly in a different environment, and only receives what could be serialized.
State with useSignal
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>Increment</button>
</div>
);
});
Key differences from React:
useSignalinstead ofuseState- Access with
.value onClick$instead ofonClick(lazy-loaded!)- The component function does not re-run on every change
The last point is the biggest mental shift for React developers. When count.value++ runs, Qwik does not call Counter again. It knows from the server render that the text node inside <p> depends on count, and updates only that node. Code you put in the component body therefore runs once per render on the server (and again on the client only when the component actually needs re-rendering), so it is the wrong place for “run this whenever count changes” logic; that is what useTask$ with track is for.
Event Handling
onClick$
import { $, component$ } from '@builder.io/qwik';
export const Button = component$(() => {
const handleClick$ = $(() => {
console.log('Clicked!');
});
return <button onClick$={handleClick$}>Click me</button>;
});
$() turns an ordinary function into a QRL so it can be passed to an on*$ prop or stored and called later. Passing a plain function (const handleClick = () => ...; onClick$={handleClick}) fails at build time, because the optimizer cannot extract a function it did not see wrapped. Note that the console.log output appears in the browser console, not in the dev server’s terminal, since the handler only ever runs on the client.
Inline Handlers
export const NameInput = component$(() => {
const name = useSignal('');
return (
<input
value={name.value}
onInput$={(_, el) => (name.value = el.value)}
/>
);
});
Qwik event handlers receive the event and the element as two arguments. Using the second one (el, already typed as HTMLInputElement) avoids the TypeScript error you get from e.target.value, since e.target is typed as a generic EventTarget. Another difference from React: because handlers load asynchronously, event.preventDefault() inside the handler is too late to stop the browser’s default action. Declare it on the element instead, with the preventdefault:click or preventdefault:submit attribute.
useStore (Objects)
import { component$, useStore } from '@builder.io/qwik';
export const UserProfile = component$(() => {
const user = useStore({
name: 'John',
age: 30,
email: '[email protected]',
});
return (
<div>
<input
value={user.name}
onInput$={(_, el) => (user.name = el.value)}
/>
<p>Age: {user.age}</p>
</div>
);
});
useStore wraps an object in a proxy that tracks reads and writes per property, so changing user.name updates only the parts of the DOM that read user.name. It is deep by default: nested objects and arrays are tracked too. Replacing the whole object (user = {...}) does not work, because user is a const binding to the proxy; mutate properties instead, or use a signal that holds an object and assign to .value when you want replace-the-whole-thing semantics.
Async Data with routeLoader$
// routes/users/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const useUsers = routeLoader$(async () => {
const res = await fetch('https://api.example.com/users');
return res.json();
});
export default component$(() => {
const users = useUsers();
return (
<ul>
{users.value.map((user: any) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
});
Key points:
routeLoader$runs on the server- Data is serialized and sent to client
- No hydration needed!
A route loader runs on the server before the page renders, for the initial request and again (as a JSON request) when the user navigates to this route client-side. Because it always runs on the server, it can use secrets and database clients directly. It must be exported from a route file (src/routes/.../index.tsx or a layout.tsx); a routeLoader$ defined in a component file outside src/routes is never invoked, and calling it produces an error that the loader was not found. Loaders run in parallel, so one loader cannot use another’s result unless it calls await requestEvent.resolveValue(otherLoader).
The return value becomes part of the serialized page state, so keep it to what the page shows. Returning an entire API response with hundreds of fields makes every page’s HTML larger. Also check res.ok before res.json(): a failing upstream API otherwise surfaces as a confusing JSON parse error during rendering.
Forms with routeAction$
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form } from '@builder.io/qwik-city';
export const useAddUser = routeAction$(async (data) => {
// Runs on server
const res = await fetch('https://api.example.com/users', {
method: 'POST',
body: JSON.stringify(data),
});
return await res.json();
});
export default component$(() => {
const action = useAddUser();
return (
<Form action={action}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Add User</button>
{action.value && <p>User added!</p>}
</Form>
);
});
<Form> renders a real HTML <form> that posts to the current route. With JavaScript available, Qwik intercepts the submit, sends it with fetch, and updates action.value without a full reload; without JavaScript, the browser submits normally and the server re-renders the page with the result. That progressive-enhancement fallback is free as long as the form uses name attributes rather than signals for its fields. action.isRunning is true while the request is in flight, which is handy for disabling the button. In real code, validate the input on the server by passing a schema as the second argument, routeAction$(handler, zod$({ name: z.string().min(1), email: z.string().email() })), which rejects invalid data before the handler runs and exposes the errors on action.value.fieldErrors.
Routing with Qwik City
File-based Routing
src/routes/
├── index.tsx → /
├── about.tsx → /about
├── blog/
│ ├── index.tsx → /blog
│ └── [slug].tsx → /blog/:slug
└── users/
└── [id]/
└── index.tsx → /users/:id
Dynamic Routes
// routes/blog/[slug].tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const usePost = routeLoader$(async ({ params, url, status }) => {
// Server-side fetch needs an absolute URL
const res = await fetch(new URL(`/api/posts/${params.slug}`, url));
if (!res.ok) {
status(404);
return null;
}
return res.json();
});
export default component$(() => {
const post = usePost();
if (!post.value) return <p>Post not found</p>;
return (
<article>
<h1>{post.value.title}</h1>
<div>{post.value.content}</div>
</article>
);
});
Two fixes in this loader are worth calling out. On the server there is no “current page” to resolve a relative URL against, so fetch('/api/posts/...') throws TypeError: Failed to parse URL; building the URL from the request’s url fixes it. And a missing post should produce a real 404 status, not a 200 page that says “not found”, so that search engines do not index empty pages. params.slug comes from the [slug] folder or file name, and it is always a string.
Navigation
import { component$ } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';
export const Nav = component$(() => {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog">Blog</Link>
</nav>
);
});
<Link> performs client-side navigation (fetching only the new route’s loader data) and prefetches on hover; a plain <a> triggers a full page load, which still works and is sometimes what you want for pages outside the app.
Lifecycle Hooks
useVisibleTask$
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';
export const Chart = component$(() => {
const chartRef = useSignal<HTMLDivElement>();
useVisibleTask$(({ track }) => {
track(() => chartRef.value);
// Runs when component becomes visible
if (chartRef.value) {
// Initialize chart library
initChart(chartRef.value);
}
});
return <div ref={chartRef}></div>;
});
useVisibleTask$ is the escape hatch for code that needs the real DOM, such as initializing a chart or map library (initChart stands for that library call). It runs only in the browser, when the component enters the viewport, which means it forces that component’s code to download eagerly at that point. That is why Qwik’s ESLint plugin warns about it (qwik/no-use-visible-task): used on many components, it quietly turns a resumable app back into one that executes code on load. Prefer event handlers or useTask$ when the work does not need the DOM, and return a cleanup function (via cleanup() from the task context) for libraries that hold resources.
useTask$
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
export const AutoSave = component$(() => {
const text = useSignal('');
useTask$(({ track }) => {
track(() => text.value);
// Runs on server AND client when text changes
console.log('Text changed:', text.value);
});
return <input value={text.value} onInput$={(_, el) => (text.value = el.value)} />;
});
useTask$ runs once during the server render, and then again in the browser whenever a tracked value changes. track(() => text.value) declares the dependency explicitly, which is different from React’s dependency arrays: there is no stale-closure problem, and only what you track triggers a re-run. For an actual auto-save you would debounce inside the task and call a server function (server$) to persist the text; logging here just shows when the task runs. Use isServer / isBrowser from @builder.io/qwik/build when a task must behave differently on each side.
Context API
import { component$, createContextId, useContextProvider, useContext, useStore } from '@builder.io/qwik';
// Create context
export const UserContext = createContextId<{ name: string }>('user');
// Provider
export const App = component$(() => {
const user = useStore({ name: 'John' });
useContextProvider(UserContext, user);
return <UserProfile />;
});
// Consumer
export const UserProfile = component$(() => {
const user = useContext(UserContext);
return <div>{user.name}</div>;
});
Context in Qwik carries a store or signal down the tree without prop drilling, like React context, with one resumability-specific detail: the provided value must be serializable, and the context ID string ('user') must be unique in the app because Qwik uses it to reconnect providers and consumers after resuming. Providing a store rather than a plain object is what makes consumers update when it changes.
Real-World Example: Todo App
import { component$ } from '@builder.io/qwik';
import { routeLoader$, routeAction$, Form } from '@builder.io/qwik-city';
export const useTodos = routeLoader$(async () => {
// Load from database
return [
{ id: 1, text: 'Learn Qwik', done: false },
{ id: 2, text: 'Build app', done: false },
];
});
export const useAddTodo = routeAction$(async (data) => {
// Save to database
return { success: true };
});
export const useToggleTodo = routeAction$(async (data) => {
// Update in database
return { success: true };
});
export default component$(() => {
const todos = useTodos();
const addAction = useAddTodo();
const toggleAction = useToggleTodo();
return (
<div>
<h1>Todo App</h1>
<Form action={addAction}>
<input name="text" required />
<button type="submit">Add</button>
</Form>
<ul>
{todos.value.map((todo) => (
<li key={todo.id}>
<Form action={toggleAction}>
<input type="hidden" name="id" value={todo.id} />
<input
type="checkbox"
checked={todo.done}
onChange$={(_, el) => el.form?.requestSubmit()}
/>
<span>{todo.text}</span>
</Form>
</li>
))}
</ul>
</div>
);
});
The part that makes this work without any client-side list state: after an action completes, Qwik City re-runs the route’s loaders, so todos.value is refreshed from the server and the list re-renders with the new item. You do not have to push the new todo into a local store by hand, and the server remains the single source of truth. requestSubmit() on the checkbox’s form triggers the same submit path as a button would, including the <Form> interception. With JavaScript disabled, the checkbox cannot submit on its own; adding a small submit button inside each form keeps the page usable in that case.
Performance Benefits
Zero JavaScript by Default
// This page ships ZERO JavaScript
export default component$(() => {
return (
<div>
<h1>Hello, World!</h1>
<p>Static content needs no JS!</p>
</div>
);
});
Fine-grained Lazy Loading
// Only this button's handler is loaded when clicked
export default component$(() => {
return (
<div>
<h1>Page content (no JS)</h1>
<button onClick$={() => console.log('Clicked')}>
Click me (handler loaded on first click)
</button>
</div>
);
});
“Zero JavaScript” here means zero application JavaScript executed on load. The page still includes the small Qwik loader inline, which registers global event listeners, and the service worker may prefetch chunks in the background. Open the Network tab, click the button, and you can watch the handler’s chunk being requested at that moment (or served from the prefetch cache).
Deployment
A Qwik City app needs an adapter for its target, because loaders and actions run on a server. Add one with the Qwik CLI; it installs the adapter, adds an entry file and updates the build scripts:
Cloudflare Pages
npm run qwik add cloudflare-pages
npm run build
npx wrangler pages deploy dist
Vercel
npm run qwik add vercel-edge
npm run build
vercel deploy
Node.js
npm run qwik add express
npm run build
npm run serve
Skipping the adapter step is the usual reason a Qwik build “works locally but 404s in production”: without it, the build output has no server entry for the host to run. wrangler pages publish from older tutorials was renamed to wrangler pages deploy. For purely static sites, the static adapter (npm run qwik add static) pre-renders pages at build time, but then loaders run only once during the build and actions are unavailable.
$ boundaries and where page data should load
Wrap functions you pass around in $()
// Good: a QRL that can be passed to onClick$ or called later
const handleClick$ = $(() => console.log('Clicked'));
// Won't work as a handler: onClick$={handleClick} fails to build
const handleClick = () => console.log('Clicked');
Inline arrow functions written directly in onClick$={...} are already extracted by the optimizer, so you only need $() when you define a function separately and pass it somewhere.
Load route data with routeLoader$, not useTask$
// Good: runs on the server before the route renders
export const useData = routeLoader$(async () => {
return await fetchData();
});
// Works, but ties the fetch to one component's render
useTask$(async () => {
const data = await fetchData();
});
Without track, the useTask$ version also runs only once during the server render, so the difference is not that it fetches twice. It is where the data lives and what happens next: a routeLoader$ runs before rendering starts, can read cookies and the request, can redirect or return a 404 status, and every component in the route can read its result. A useTask$ fetch runs inside one component, blocks that component’s rendering while it awaits, and, once someone adds track() to it, re-runs in the browser, where server-only code such as database clients or secret API keys is not available. Keep useTask$ for reacting to state changes, and put the data a page needs to render in a loader.
Frequently Asked Questions (FAQ)
Q. Why does Qwik throw a serialization error when my onClick$ handler uses a variable from the component?
A. Everything a $ function captures has to be serialized into the HTML so the handler can resume on the client without re-running the component. Plain data, signals and stores serialize fine, but class instances, DOM nodes and ordinary functions that are not wrapped in $() cannot. Keep captured state serializable, wrap helper functions with $(), or mark values that are only needed on one side with noSerialize(), knowing they will be undefined after resuming.
Related Articles
- Alpine.js: Lightweight Interactivity
- Next.js: App Router Internals, RSC, Caching
- Solid.js: Fine-Grained Reactivity