Qwik: Building Resumable Web Apps
You server-render a React page, it shows up fast, and then nothing happens when you click a button for a second or two. The HTML is there, but the framework is still downloading and re-running all your components in the browser so it can attach event handlers. That step is called hydration, and on a cheap Android phone it's often the slowest part of the whole page load.
Qwik takes a different route. It skips hydration completely and uses something it calls resumability: the server writes enough information into the HTML that the browser can pick up where the server stopped, and JavaScript for a handler is only downloaded when someone actually triggers it. Qwik comes from Miško Hevery (who created AngularJS) and the Builder.io team, and it hit 1.0 in 2023. Below I'll walk through how it works, what the code looks like, and where I think it's worth using.
The JavaScript needed to make a Qwik page interactive doesn't grow with the app. A page with 10 components and one with 1,000 both start with the same tiny loader (around 1KB). A typical React app ships a few hundred KB before the first click does anything.
Resumability vs Hydration: The Core Difference
Next.js, Nuxt and SvelteKit all hydrate. Astro reduces it with islands, but each interactive island still hydrates. So it's worth looking at what that actually costs.
How Traditional Hydration Works
When a user visits a server-rendered React or Vue page, the following sequence happens before the page is usable:
- Server renders HTML: the page is sent to the browser and painted
- Browser downloads the full JS bundle: could be 100KB to 1MB+
- Framework boots: parses and executes all component code
- Framework re-runs rendering: reconstructs the virtual DOM in memory
- Event listeners are attached: page finally becomes interactive
Steps 2 to 4 repeat work the server already did. On a fast laptop you barely notice. On a slow phone over a bad connection it can add several seconds, and it gets worse as the app grows.
Qwik's Resumability Model
Qwik serializes the application state (component boundaries, references to event handlers, data) into the HTML. The browser doesn't rebuild anything. It just resumes:
- Server renders HTML with serialized state in
q:*attributes - A tiny ~1KB loader registers one global event listener, and that's all it does up front
- Page is immediately visible and appears interactive
- User clicks a button, and Qwik fetches only that button's handler
- The handler runs, and later interactions load their own code the same way
There's a trade-off hiding here. The first click on something new needs a network request for its code. Qwik prefetches likely chunks in the background (via a service worker), so in practice it's usually fine, but on a very flaky connection you can feel it.
Hydration vs Resumability: how page interactivity is achieved
Setting Up Your First Qwik Project
You'll almost never use bare Qwik. You use Qwik City, which is to Qwik what Next.js is to React: file-based routing, layouts, server data loading and form actions.
Prerequisites
- Node.js 16.8 or later
- npm, pnpm, or yarn
- Basic knowledge of TypeScript and JSX
Create a new Qwik City app
Use the official scaffolding CLI to bootstrap a new project with TypeScript support.
npm create qwik@latest
# Interactive prompts will ask:
# ? Where would you like to create your new project? › ./my-qwik-app
# ? Select a starter › Empty App (Qwik City)
# ? Would you like to install npm dependencies now? › Yes
cd my-qwik-app
npm start # Starts dev server at http://localhost:5173
Explore the project structure
Qwik City follows a convention-based structure under src/routes/ for routing.
my-qwik-app/
├── public/ # Static assets
├── src/
│ ├── components/ # Reusable components
│ │ └── router-head/ # <head> management
│ ├── routes/ # File-based routing (Qwik City)
│ │ ├── index.tsx # → / (home page)
│ │ ├── layout.tsx # Root layout wrapping all routes
│ │ └── about/
│ │ └── index.tsx # → /about
│ ├── root.tsx # Root component (html, head, body)
│ └── entry.ssr.tsx # Server entry point
├── vite.config.ts
├── tsconfig.json
└── package.json
Run the development server
npm start starts Vite with Qwik's dev mode, including hot module replacement and server-side rendering in development.
Writing Qwik Components
Qwik components look familiar if you know React, but the $ suffix is the key innovation. Every function ending in $ marks a lazy-loadable boundary. Qwik's optimizer splits each one into its own chunk, fetched only when needed.
Your First Component
import { component$, useSignal } from '@builder.io/qwik';
// component$() wraps all Qwik components
// The $ marks this as a lazy-loadable chunk
export const Counter = component$(() => {
// useSignal creates reactive state (like useState in React)
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
{/* onClick$ marks this handler as lazy-loadable */}
<button onClick$={() => count.value++}>
Increment
</button>
<button onClick$={() => count.value--}>
Decrement
</button>
</div>
);
});
A few things to notice:
component$(): wraps every Qwik component, and the body itself can be lazy-loadeduseSignal(0): creates a reactive signal; read via.value, write by assigning to.valueonClick$: all DOM event handlers use the$suffix so the handler code is only fetched on first click- The JSX is the same as in React
The $ isn't just a naming style. Qwik's Vite optimizer statically analyzes your code and splits everything marked with $ into separate lazy-loaded chunks. Think of it as a marker that says "this can be loaded separately". It shows up on component$, onClick$, useTask$, useComputed$, and a few others. The catch: anything a $ function closes over has to be serializable. If you capture a class instance or a function that isn't wrapped in $(), the optimizer throws an error at build time, and that's the first wall most people hit.
Passing Props
import { component$, Slot } from '@builder.io/qwik';
interface ButtonProps {
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
// Props are typed just like React
export const Button = component$<ButtonProps>(({ variant = 'primary', disabled = false }) => {
return (
<button
class={`btn btn-${variant}`}
disabled={disabled}
>
{/* Slot is the equivalent of children in React */}
<Slot />
</button>
);
});
// Usage
export const App = component$(() => (
<Button variant="primary">Click Me</Button>
));
State Management with Signals
Qwik's reactivity system is built on fine-grained signals. In React a state change re-runs the component (and often its children). In Qwik a signal change updates only the DOM nodes that read that signal. There's no virtual DOM diff.
useSignal: reactive primitive for a single value (string, number, boolean, or object reference):
import { component$, useSignal } from '@builder.io/qwik';
export const ThemeToggle = component$(() => {
const isDark = useSignal(true);
const username = useSignal('');
return (
<div>
{/* Reading: access .value */}
<p>Theme: {isDark.value ? 'Dark' : 'Light'}</p>
{/* Writing: assign to .value */}
<button onClick$={() => (isDark.value = !isDark.value)}>
Toggle Theme
</button>
<input
value={username.value}
onInput$={(e) => (username.value = (e.target as HTMLInputElement).value)}
placeholder="Enter name"
/>
<p>Hello, {username.value || 'stranger'}!</p>
</div>
);
});
useStore: reactive proxy for complex object state (like useReducer or Zustand):
import { component$, useStore } from '@builder.io/qwik';
interface CartState {
items: { id: number; name: string; qty: number }[];
total: number;
}
export const ShoppingCart = component$(() => {
const cart = useStore<CartState>({
items: [],
total: 0,
});
const addItem = $((item: { id: number; name: string; price: number }) => {
const existing = cart.items.find(i => i.id === item.id);
if (existing) {
existing.qty++;
} else {
cart.items.push({ id: item.id, name: item.name, qty: 1 });
}
cart.total += item.price;
});
return (
<div>
<p>{cart.items.length} items, ${cart.total.toFixed(2)}</p>
{cart.items.map(item => (
<div key={item.id}>{item.name} × {item.qty}</div>
))}
</div>
);
});
useComputed$: derived state that automatically recalculates when dependencies change:
import { component$, useSignal, useComputed$ } from '@builder.io/qwik';
export const PriceCalculator = component$(() => {
const price = useSignal(100);
const quantity = useSignal(3);
const discount = useSignal(10); // percentage
// Automatically recomputes when price, quantity, or discount change
const total = useComputed$(() => {
const subtotal = price.value * quantity.value;
const discountAmount = subtotal * (discount.value / 100);
return subtotal - discountAmount;
});
return (
<div>
<input type="number" bind:value={price} /> × {quantity.value}
<p>After {discount.value}% discount: ${total.value.toFixed(2)}</p>
</div>
);
});
useTask$: side effects that run when tracked signals change (like useEffect):
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
import { isServer } from '@builder.io/qwik/build';
export const DataFetcher = component$(() => {
const userId = useSignal(1);
const userData = useSignal<{ name: string } | null>(null);
const loading = useSignal(false);
// Runs on server AND client when userId changes
useTask$(async ({ track, cleanup }) => {
track(() => userId.value); // track this signal
if (isServer) return; // skip on server-side render
loading.value = true;
const controller = new AbortController();
cleanup(() => controller.abort()); // cleanup on re-run
const res = await fetch(`/api/users/${userId.value}`, {
signal: controller.signal,
});
userData.value = await res.json();
loading.value = false;
});
return (
<div>
{loading.value ? <p>Loading...</p> : <p>{userData.value?.name}</p>}
<button onClick$={() => userId.value++}>Next user</button>
</div>
);
});
Routing with Qwik City
Qwik City uses file-based routing under src/routes/. Every index.tsx becomes a route, and folders represent URL segments. If you've used the Next.js App Router or SvelteKit, this will feel familiar.
Route Files and Conventions
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';
// Default export is the page component
export default component$(() => {
return (
<main>
<h1>Welcome to Qwik!</h1>
<p>Built with resumability for instant performance.</p>
</main>
);
});
// Export head for <title> and meta tags
export const head: DocumentHead = {
title: 'Home | My Qwik App',
meta: [
{ name: 'description', content: 'A fast Qwik application' },
],
};
import { component$ } from '@builder.io/qwik';
import { useLocation } from '@builder.io/qwik-city';
export default component$(() => {
// Access route params and URL info
const loc = useLocation();
const slug = loc.params.slug;
return (
<article>
<h1>Post: {slug}</h1>
<p>URL: {loc.url.pathname}</p>
</article>
);
});
// Route file naming conventions:
// src/routes/index.tsx → /
// src/routes/about/index.tsx → /about
// src/routes/blog/[slug]/index.tsx → /blog/:slug
// src/routes/(auth)/login/index.tsx → /login (grouped, no URL impact)
// src/routes/blog/[...path]/index.tsx → /blog/*
Layouts
A layout.tsx file in any route folder wraps every route below it, so it's where navigation, sidebars and footers go. Layouts can be nested.
import { component$, Slot } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';
export default component$(() => {
return (
<>
<header>
<nav>
{/* Link uses client-side navigation */}
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog">Blog</Link>
</nav>
</header>
<main>
{/* Slot renders the matched child route */}
<Slot />
</main>
<footer>
<p>© 2026 My Qwik App</p>
</footer>
</>
);
});
Server-Side Data Loading
Two functions cover most server work: routeLoader$ to fetch data before the page renders, and routeAction$ for form submissions and other writes. Both always run on the server, so database calls and secrets stay there.
Loading data with routeLoader$
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
// Runs on the server before the component renders
// Safe place for DB queries and secrets, none of this is sent to the client
export const usePost = routeLoader$(async ({ params, status }) => {
const post = await db.posts.findUnique({
where: { slug: params.slug },
});
if (!post) {
status(404); // Set HTTP status code
return null;
}
return {
id: post.id,
title: post.title,
content: post.content,
publishedAt: post.publishedAt.toISOString(),
};
});
export default component$(() => {
// usePost() returns a Signal containing the loader's data
const post = usePost();
if (!post.value) {
return <p>Post not found</p>;
}
return (
<article>
<h1>{post.value.title}</h1>
<time>{post.value.publishedAt}</time>
<div dangerouslySetInnerHTML={post.value.content} />
</article>
);
});
Handling forms with routeAction$
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city';
// Server action with Zod validation
export const useContactForm = routeAction$(
async (data, { redirect }) => {
// data is already validated here, and typed from the zod schema
await sendEmail({
to: 'owner@example.com',
subject: `Message from ${data.name}`,
body: data.message,
});
throw redirect(302, '/contact/thanks');
},
zod$({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
})
);
export default component$(() => {
const action = useContactForm();
return (
{/* Form automatically POSTs to the action */}
<Form action={action}>
<input name="name" placeholder="Your name" />
{action.value?.fieldErrors?.name && (
<p class="error">{action.value.fieldErrors.name}</p>
)}
<input name="email" type="email" placeholder="Email" />
<textarea name="message" placeholder="Message" />
<button type="submit" disabled={action.isRunning}>
{action.isRunning ? 'Sending...' : 'Send Message'}
</button>
</Form>
);
});
The ecosystem is small compared to React. If your project depends on MUI, Chakra, Radix or a pile of React-only integrations, Next.js is the safer choice. qwik-react lets you render React components inside Qwik, but those components hydrate the normal way, so you lose the main benefit for those parts of the page.
How it compares to Next.js, Nuxt and SvelteKit
The payload numbers below are rough ranges for typical apps, not benchmarks. Your real numbers depend far more on what you put in the page than on the framework.
| Feature | Qwik City | Next.js 14 | Nuxt 3 | SvelteKit |
|---|---|---|---|---|
| Hydration Required | No (resumable) | Yes | Yes | Yes |
| Initial JS payload | ~1KB fixed | 70-400KB+ | 60-350KB+ | 30-200KB+ |
| Interactive on slow devices | Almost immediately | After hydration | After hydration | After hydration (lighter) |
| Reactivity model | Fine-grained signals | Virtual DOM | Virtual DOM | Compiled (no VDOM) |
| Language | TypeScript / JSX | TypeScript / JSX | TypeScript / Vue SFC | TypeScript / Svelte |
| Server functions | routeLoader$, routeAction$ | Server Actions, RSC | useFetch, server routes | load(), actions |
| Ecosystem size | Growing | Very large | Large | Medium |
| Learning curve | Medium ($ syntax) | Medium to high | Medium | Low to medium |
Qwik City architecture: the server renders, the browser resumes
Deploying Qwik City
Deployment works through adapters. You run one command and it adds the config for that platform:
# Vercel (recommended for most projects)
npm run qwik add vercel-edge
# Cloudflare Workers / Pages (best for global edge performance)
npm run qwik add cloudflare-pages
# Netlify
npm run qwik add netlify-edge
# Node.js / Express (self-hosted)
npm run qwik add express
# AWS Lambda (serverless)
npm run qwik add aws-lambda
# Static Site Generation (no server)
npm run qwik add static
# After adding an adapter, build for production:
npm run build
Qwik works well on edge platforms like Cloudflare Pages, because the HTML is rendered close to the user and there's no big bundle to wait for afterwards. If your data lives in a single database region, though, the edge function still has to call back to it, so don't expect miracles for data-heavy pages.
Qwik API Quick Reference
| API | Purpose | React Equivalent |
|---|---|---|
component$() |
Define a lazy component | function Component() |
useSignal() |
Reactive primitive value | useState() |
useStore() |
Reactive object state | useReducer() |
useComputed$() |
Derived reactive value | useMemo() |
useTask$() |
Side effects | useEffect() |
useResource$() |
Async data with loading state | use(promise) |
routeLoader$() |
Server data loading | getServerSideProps |
routeAction$() |
Server mutations | Server Actions |
<Slot /> |
Render child content | {children} |
<Link /> |
Client-side navigation | <Link /> (Next.js) |
When I'd pick Qwik (and when I wouldn't)
Qwik makes the most sense when first-load speed directly affects money: landing pages, e-commerce product pages, content sites with a lot of mobile traffic from slower networks. For those, removing hydration is a real, measurable win in Core Web Vitals.
I'd skip it for an internal dashboard where users sit on fast machines and keep the tab open all day. Hydration cost is paid once and then forgotten, and you'd be giving up React's ecosystem for a benefit nobody notices. Same if your team leans heavily on React component libraries.
If you want to try it, build one real page with it: something with a form, a list and a bit of client state. You'll run into the $ serialization rules within an hour, and that tells you quickly whether the model fits how you think. After that, read up on useContext() for shared state and useVisibleTask$() for code that must run in the browser (use it sparingly, since it runs eagerly and works against the whole point).