Qwik: Building Resumable Web Apps
Qwik is a revolutionary JavaScript framework that fundamentally rethinks how web applications deliver interactivity. While React, Vue, Angular, and virtually every other modern framework require a process called "hydration" — where the browser re-executes your server-rendered JavaScript before the page becomes interactive — Qwik eliminates hydration entirely with a concept called resumability. The result is near-instant Time to Interactive (TTI) regardless of application complexity, because Qwik lazy-loads JavaScript only at the precise moment a user triggers an interaction. Created by Miško Hevery (the architect of AngularJS) and the team at Builder.io, Qwik reached its stable 1.0 release in 2023 and has since become one of the most exciting frameworks in the JavaScript ecosystem.
Qwik achieves O(1) JavaScript loading — the amount of JS required to make your page interactive stays constant regardless of app size. A 10-component app and a 1000-component app both deliver the same sub-1KB bootstrap payload. Traditional React apps routinely ship 200–500KB before any button click is possible.
Resumability vs Hydration: The Core Difference
To truly appreciate Qwik, you need to understand the problem it solves. Every server-rendered framework today — Next.js, Nuxt, SvelteKit, Astro with islands — still faces the hydration bottleneck when using component frameworks.
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–4 are pure overhead — work the server already did. On slow connections or low-end devices, this hydration penalty can take 3–10 seconds. The larger the app, the worse it gets.
Qwik's Resumability Model
Qwik serializes the entire application state — component tree, event handlers, data — into the HTML as attributes. When the browser receives this HTML, it can resume from exactly where the server left off:
- Server renders HTML with serialized state in
q:*attributes - A tiny ~1KB loader registers a global event listener — that's it
- Page is immediately visible and appears interactive
- User clicks a button — Qwik fetches only that button's handler code
- Interaction executes — subsequent lazy loads happen as needed
No boot. No re-rendering. No wasted work. Just execution on demand.
Hydration vs Resumability: how page interactivity is achieved
Setting Up Your First Qwik Project
Qwik uses Qwik City as its meta-framework — think of it as the equivalent of Next.js for React or SvelteKit for Svelte. Qwik City adds file-based routing, layouts, server functions, and full-stack capabilities on top of Qwik's core.
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 $-suffixed function marks a lazy-loadable boundary — Qwik's optimizer splits these into separate chunks that are 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; the function body is also lazy-loadableuseSignal(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- JSX is identical to React — same syntax, same conventions
The $ suffix isn't just convention — Qwik's Vite optimizer statically analyzes your code and splits everything marked with $ into separate lazy-loaded chunks. It's a visible contract: "this boundary can be loaded separately." You'll see it on component$, onClick$, useTask$, useComputed$, and more.
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. Unlike React's model where component re-renders ripple up the tree, Qwik signals update only the specific DOM nodes that depend on them — no virtual DOM diffing required.
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. This is identical in concept to Next.js App Router or SvelteKit.
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 all child routes with shared UI — navigation, sidebars, footers. Nested layouts are fully supported.
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
Qwik City provides two primary mechanisms for server-side data: routeLoader$ for fetching data before rendering, and routeAction$ for handling form submissions and mutations.
routeLoader$ — Fetching Data
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
// Runs on the server before the component renders
// Can access databases, secrets, file system — never exposed to 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>
);
});
routeAction$ — Handling Mutations
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 validated — TypeScript knows the shape
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>
);
});
Qwik's ecosystem is still growing. If your project requires specific React component libraries (MUI, Chakra UI, Radix) or a large collection of third-party integrations, React/Next.js still has the edge. Qwik does support rendering React components via qwik-react as an escape hatch for using existing React libraries inside a Qwik app.
Performance Comparison
How does Qwik stack up against the modern meta-framework landscape? Here's an honest comparison across the dimensions that matter most:
| 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+ |
| TTI on slow 3G | Near-instant | 3–12 seconds | 3–10 seconds | 1–6 seconds |
| 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–High | Medium | Low–Medium |
Qwik City full-stack architecture — server renders, browser resumes
Deploying Qwik City
Qwik City ships with official adapters for every major deployment platform. You add an adapter package and it handles the platform-specific configuration:
# 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's resumability model makes it exceptionally well-suited for edge deployments. Since there's no hydration step, the browser doesn't need to wait for JavaScript to download before the page responds to interactions — ideal for users in regions far from your origin server.
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) |
Key Takeaways and Next Steps
Qwik represents the most significant architectural shift in JavaScript frameworks since React introduced the virtual DOM. By replacing hydration with resumability, it solves the fundamental scaling problem that plagues every other framework: the more complex your app, the more JavaScript must run before it's interactive.
What You've Learned
- Resumability vs Hydration: Qwik serializes state into HTML; browsers resume instead of replaying
- O(1) JS Loading: Application complexity doesn't increase initial JavaScript payload
- The $ Convention: Marks lazy-loadable boundaries; optimizer splits code automatically
- Signals: Fine-grained reactivity with useSignal, useStore, useComputed$, useTask$
- Qwik City: Full-stack meta-framework with file routing, routeLoader$, routeAction$
- Edge-first: Resumability pairs perfectly with edge deployments for global performance
To continue your Qwik journey, explore the official documentation for topics like useContext() for cross-tree state sharing, useVisibleTask$() for browser-only effects, and the qwik-react integration for using existing React component libraries inside your Qwik app.
"The web's performance problem isn't that developers write slow code — it's that every framework forces you to ship all your code upfront. Qwik flips that assumption entirely."
— Miško Hevery, Creator of Qwik and AngularJS
Whether you're building a content-heavy marketing site, a complex SaaS dashboard, or a high-traffic e-commerce platform, Qwik's resumability model ensures your users experience instant interactivity regardless of device capability or network speed. That's a promise no hydration-based framework can match.