Qwik, a no hydration framework with resumability
Qwik is a web framework built around a single idea: the browser should not execute code the user does not need. Instead of reconstructing application state on the client, it resumes that state from information recorded in the document and fetches code only at the moment of interaction. The project is MIT licensed, and the repository carries over twenty two thousand stars.
How it works and why that is unusual
To grasp the point of this approach, the problem it solves has to be named, because it stays invisible until you know about it.
A conventional framework renders a page on the server and ships finished HTML, which gives a fast first paint. The browser then has to download the whole application, execute it, and reconstruct state before the page responds to clicks. That step is called hydration and happens every time, whether or not the user clicks anything. You pay for it with processor time on a device you do not control.
Resumability inverts that arrangement. The server records in the document where state lives and which code handles which event, and the browser executes nothing on load. Only a click causes the one fragment of code handling that particular event to be fetched.
The consequence is measurable: the amount of JavaScript executed on arrival barely depends on application size. A page with one button and a page with an elaborate dashboard start similarly, because in both cases nothing runs at load.
That is also why the syntax looks unusual. The dollar sign at the end of function names is not decoration but an instruction to the build tool: cut the code here into a separate fragment that can be fetched independently. Without those boundaries, fetching code piecemeal would be impossible.
That construction carries a limitation which surprises people arriving from other frameworks. A function marked with a dollar sign lands in a separate file, so it cannot freely reach for variables from the scope it was written in. Everything it needs must be serialisable and restorable, which rules out passing it functions created on the fly or objects referencing browser elements. The error message in such cases speaks plainly about a serialisation problem, and it is the single most common thing people learning this framework trip over.
In practice that means thinking differently about code boundaries. Rather than writing a component as a whole and then looking for what can be extracted, you arrange it from the start so that the fragments reacting to events are self contained. After a few days that becomes reflex, but the first week can be frustrating for precisely this reason.
Version 2, what changed and under which name
This is the most important thing when reading older material, because it concerns even the package name.
The stable version is 1.20.0 in the @builder.io/qwik package. Version 2 has been in beta for some time, at 2.0.0-beta.38 as of writing, released on 16 July 2026, and its most consequential change is a move to an entirely new namespace. The packages are now @qwik.dev/core and @qwik.dev/router, so every import in a project needs updating. A migration tool has been announced.
Beyond the name, changes with practical weight arrived. Shipped HTML is lighter, since the comment nodes previously used to mark boundaries are gone. Task scheduling is faster and internal logic simpler. Among the new capabilities, an asynchronously computed signal and a mechanism letting third party libraries decide how their data travels from server to browser are worth knowing.
The practical conclusion when planning: start a new project deliberately, knowing the stable and the actively developed versions are today two different packages. Migrating from one to two is not a version bump but a rewrite of every import, so it is worth waiting for the migration tool or scheduling it as its own task.
When this is the right choice
Choosing a niche framework needs a justification stronger than curiosity, so it is worth naming the situations where that justification genuinely exists.
The first is a site people reach from search and usually do not return to. A shop, a content site, a product catalogue. There what counts is how quickly the page becomes useful to somebody seeing it for the first time, and there the difference is largest, because the conventional approach pays the full startup cost on every visit.
The second is traffic from devices with weak processors. Executing code on a mid range phone takes several times longer than on a developer's laptop, so a saving at startup translates into a real difference in perception rather than a better score in a measurement tool.
The third is having measured the problem and knowing it lies precisely in startup cost. That condition is the easiest to forget: if your site is slow because of unoptimised images or database queries, changing framework achieves nothing and adds risk.
Conversely, there are situations where the choice is hard to defend. An application behind a login where users work for hours, an internal panel, a tool for a team. There the cost of first entry spreads across a long session and stops mattering, while a narrow ecosystem and a smaller pool of people able to maintain the code remain. In that case reaching for Next.js or another framework with a large support base makes more sense.
What is Qwik?
Qwik is a revolutionary JavaScript framework created by Misko Hevery (the creator of Angular) and the Builder.io team. It introduces a completely new approach to web application rendering through the concept of "resumability" - instead of traditional hydration, a Qwik application is interactive immediately after loading the HTML, without needing to download and execute all the JavaScript upfront.
The framework solves a fundamental problem of modern SPA applications: even with SSR, the user has to wait for the entire JS bundle to be downloaded and executed before the page becomes interactive. Qwik eliminates this problem by serializing the application state directly in the HTML and lazy-loading JavaScript at the level of individual event handlers.
Problem: hydration tax
How traditional SSR works
In traditional frameworks (React, Vue, Svelte), Server-Side Rendering works as follows:
1. Server β Renders HTML
2. Browser β Downloads HTML, displays a static page
3. Browser β Downloads the ENTIRE JavaScript bundle (50-500KB+)
4. Browser β Executes JavaScript
5. Framework β "Hydrates" the page (re-renders everything in memory)
6. Page β Becomes interactiveProblem: Steps 3-5 are the "hydration tax" - the time the user has to wait for interactivity. On slow mobile devices, this can take several seconds.
How Qwik resumability works
1. Server β Renders HTML + serializes state in attributes
2. Browser β Downloads HTML, displays the page
3. Page β Is IMMEDIATELY interactive
4. Browser β Downloads JS only for clicked elements (lazy)Benefit: No hydration = instant interactivity.
Qwik vs React/Vue - performance comparison
| Metric | Qwik | React | Vue | Angular |
|---|---|---|---|---|
| Initial JS | ~1KB | 50-100KB | 40-80KB | 80-150KB |
| Time to Interactive | Instant | 1-5s | 1-4s | 2-6s |
| Hydration | None (resume) | Yes | Yes | Yes |
| Lazy loading | Per-listener | Per-route | Per-route | Per-route |
| Bundle growth | Linear | Exponential | Exponential | Exponential |
| SSR overhead | Minimal | High | Medium | High |
Installation and configuration
Creating a new project
# Initialize a project with Qwik CLI
npm create qwik@latest
# Or with pnpm
pnpm create qwik@latest
# The interactive wizard will ask about:
# - Project name
# - Starter template (basic, with integrations)
# - Whether to add Qwik City (routing/meta-framework)Qwik City project structure
my-qwik-app/
βββ src/
β βββ components/ # Qwik components
β β βββ header/
β β β βββ header.tsx
β β βββ footer/
β β βββ footer.tsx
β βββ routes/ # File-based routing
β β βββ index.tsx # / (home page)
β β βββ about/
β β β βββ index.tsx # /about
β β βββ blog/
β β β βββ index.tsx # /blog
β β β βββ [slug]/
β β β βββ index.tsx # /blog/:slug
β β βββ layout.tsx # Shared layout
β βββ entry.ssr.tsx # SSR entry point
β βββ root.tsx # Root component
βββ public/ # Static assets
βββ vite.config.ts # Vite configuration
βββ qwik.config.ts # Qwik configuration
βββ package.jsonBasic configuration
// vite.config.ts
import { defineConfig } from 'vite'
import { qwikVite } from '@builder.io/qwik/optimizer'
import { qwikCity } from '@builder.io/qwik-city/vite'
export default defineConfig(() => {
return {
plugins: [qwikCity(), qwikVite()],
server: {
port: 5173,
},
preview: {
port: 4173,
},
}
})Qwik basics - syntax
Components with component$
// components/counter.tsx
import { component$, useSignal } from '@builder.io/qwik'
// The $ suffix means the function is lazy-loaded
export const Counter = component$(() => {
// useSignal - reactive state (fine-grained reactivity)
const count = useSignal(0)
return (
<div class="counter">
<p>Count: {count.value}</p>
{/* onClick$ - handler is lazy-loaded on first click */}
<button onClick$={() => count.value++}>
Increment
</button>
<button onClick$={() => count.value--}>
Decrement
</button>
</div>
)
})The meaning of the $ symbol (dollar sign)
The $ symbol in Qwik marks a lazy-loading boundary. Everything after $ is serialized and loaded only when needed:
import { component$, $, useSignal } from '@builder.io/qwik'
export const Example = component$(() => {
const message = useSignal('')
// $ creates a lazy-loaded function
const handleClick = $(() => {
// This code is in a separate chunk and loaded on click
message.value = 'Button clicked!'
console.log('This code was lazy-loaded')
})
// onClick$ automatically wraps in $
return (
<div>
<button onClick$={handleClick}>Click me</button>
<p>{message.value}</p>
</div>
)
})useSignal - reactive state
import { component$, useSignal } from '@builder.io/qwik'
export const SignalExample = component$(() => {
// Primitive values
const count = useSignal(0)
const name = useSignal('John')
const isActive = useSignal(true)
// Changing values
const increment = $(() => {
count.value++
})
const updateName = $((newName: string) => {
name.value = newName
})
return (
<div>
<p>Count: {count.value}</p>
<p>Name: {name.value}</p>
<p>Active: {isActive.value ? 'Yes' : 'No'}</p>
<button onClick$={increment}>+1</button>
<input
value={name.value}
onInput$={(e) => name.value = (e.target as HTMLInputElement).value}
/>
<button onClick$={() => isActive.value = !isActive.value}>
Toggle
</button>
</div>
)
})useStore - reactive objects
import { component$, useStore } from '@builder.io/qwik'
interface TodoItem {
id: number
text: string
completed: boolean
}
interface State {
todos: TodoItem[]
filter: 'all' | 'active' | 'completed'
newTodo: string
}
export const TodoApp = component$(() => {
// useStore for complex objects - deep reactivity
const state = useStore<State>({
todos: [],
filter: 'all',
newTodo: '',
})
const addTodo = $(() => {
if (state.newTodo.trim()) {
// Direct mutation - reactive!
state.todos.push({
id: Date.now(),
text: state.newTodo,
completed: false,
})
state.newTodo = ''
}
})
const toggleTodo = $((id: number) => {
const todo = state.todos.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
})
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed
if (state.filter === 'completed') return todo.completed
return true
})
return (
<div class="todo-app">
<input
value={state.newTodo}
onInput$={(e) => state.newTodo = (e.target as HTMLInputElement).value}
onKeyDown$={(e) => e.key === 'Enter' && addTodo()}
placeholder="Add todo..."
/>
<button onClick$={addTodo}>Add</button>
<div class="filters">
{(['all', 'active', 'completed'] as const).map(filter => (
<button
key={filter}
class={{ active: state.filter === filter }}
onClick$={() => state.filter = filter}
>
{filter}
</button>
))}
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange$={() => toggleTodo(todo.id)}
/>
<span class={{ completed: todo.completed }}>{todo.text}</span>
</li>
))}
</ul>
</div>
)
})useComputed$ - computed values
import { component$, useSignal, useComputed$ } from '@builder.io/qwik'
export const ComputedExample = component$(() => {
const firstName = useSignal('John')
const lastName = useSignal('Doe')
const items = useSignal([10, 20, 30, 40, 50])
// Computed - automatically recalculated when dependencies change
const fullName = useComputed$(() => {
return `${firstName.value} ${lastName.value}`
})
const total = useComputed$(() => {
return items.value.reduce((sum, item) => sum + item, 0)
})
const average = useComputed$(() => {
const sum = items.value.reduce((s, i) => s + i, 0)
return items.value.length > 0 ? sum / items.value.length : 0
})
return (
<div>
<p>Full Name: {fullName.value}</p>
<p>Total: {total.value}</p>
<p>Average: {average.value.toFixed(2)}</p>
</div>
)
})useTask$ - side effects
import { component$, useSignal, useTask$ } from '@builder.io/qwik'
export const TaskExample = component$(() => {
const searchQuery = useSignal('')
const results = useSignal<string[]>([])
const isLoading = useSignal(false)
// useTask$ - runs when tracked signals change
useTask$(async ({ track, cleanup }) => {
// track() registers a dependency
const query = track(() => searchQuery.value)
if (!query || query.length < 3) {
results.value = []
return
}
isLoading.value = true
// Debounce
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${query}`)
results.value = await response.json()
} finally {
isLoading.value = false
}
}, 300)
// cleanup - called before the next execution
cleanup(() => clearTimeout(timeoutId))
})
return (
<div>
<input
value={searchQuery.value}
onInput$={(e) => searchQuery.value = (e.target as HTMLInputElement).value}
placeholder="Search..."
/>
{isLoading.value && <p>Searching...</p>}
<ul>
{results.value.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
)
})useVisibleTask$ - client-only effects
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'
export const ClientOnlyExample = component$(() => {
const windowWidth = useSignal(0)
const mousePosition = useSignal({ x: 0, y: 0 })
// useVisibleTask$ - runs ONLY on the client
// Use when you need access to DOM/Browser APIs
useVisibleTask$(() => {
// This code will never run on the server
windowWidth.value = window.innerWidth
const handleResize = () => {
windowWidth.value = window.innerWidth
}
const handleMouseMove = (e: MouseEvent) => {
mousePosition.value = { x: e.clientX, y: e.clientY }
}
window.addEventListener('resize', handleResize)
window.addEventListener('mousemove', handleMouseMove)
// Cleanup
return () => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('mousemove', handleMouseMove)
}
})
return (
<div>
<p>Window width: {windowWidth.value}px</p>
<p>Mouse: ({mousePosition.value.x}, {mousePosition.value.y})</p>
</div>
)
})Qwik City - meta-framework
File-based routing
src/routes/
βββ index.tsx # β /
βββ about/
β βββ index.tsx # β /about
βββ blog/
β βββ index.tsx # β /blog
β βββ [slug]/
β βββ index.tsx # β /blog/:slug
βββ api/
β βββ users/
β βββ index.ts # β /api/users (server endpoint)
βββ (auth)/ # Route group (doesn't add to URL)
β βββ login/
β β βββ index.tsx # β /login
β βββ register/
β βββ index.tsx # β /register
βββ layout.tsx # Shared layout for all routesPage with routeLoader$
// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$, DocumentHead } from '@builder.io/qwik-city'
// routeLoader$ - server-side data fetching
export const usePost = routeLoader$(async ({ params, status }) => {
const response = await fetch(`https://api.example.com/posts/${params.slug}`)
if (!response.ok) {
status(404)
return null
}
return response.json() as Promise<{
title: string
content: string
author: string
date: string
}>
})
export default component$(() => {
// Data is already loaded on the server
const post = usePost()
if (!post.value) {
return <div>Post not found</div>
}
return (
<article class="blog-post">
<h1>{post.value.title}</h1>
<p class="meta">
By {post.value.author} on {new Date(post.value.date).toLocaleDateString()}
</p>
<div class="content" dangerouslySetInnerHTML={post.value.content} />
</article>
)
})
// Dynamic head/meta
export const head: DocumentHead = ({ resolveValue }) => {
const post = resolveValue(usePost)
return {
title: post?.title || 'Blog Post',
meta: [
{ name: 'description', content: post?.content?.slice(0, 160) || '' },
{ property: 'og:title', content: post?.title || 'Blog' },
],
}
}routeAction$ - server actions
// src/routes/contact/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city'
// Validation with Zod
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
// routeAction$ - server mutation
export const useContactForm = routeAction$(
async (data, { fail }) => {
try {
// Send to API
const response = await fetch('https://api.example.com/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
return fail(500, { message: 'Failed to send message' })
}
return { success: true, message: 'Message sent successfully!' }
} catch (error) {
return fail(500, { message: 'Server error' })
}
},
zod$(contactSchema)
)
export default component$(() => {
const action = useContactForm()
return (
<div class="contact-form">
<h1>Contact Us</h1>
{/* Form - progressive enhancement, works without JS */}
<Form action={action}>
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
{action.value?.fieldErrors?.name && (
<span class="error">{action.value.fieldErrors.name}</span>
)}
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
{action.value?.fieldErrors?.email && (
<span class="error">{action.value.fieldErrors.email}</span>
)}
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" rows={5} required />
{action.value?.fieldErrors?.message && (
<span class="error">{action.value.fieldErrors.message}</span>
)}
</div>
<button type="submit" disabled={action.isRunning}>
{action.isRunning ? 'Sending...' : 'Send Message'}
</button>
{action.value?.success && (
<p class="success">{action.value.message}</p>
)}
{action.value?.failed && (
<p class="error">{action.value.message}</p>
)}
</Form>
</div>
)
})server$ - server functions
import { component$, useSignal } from '@builder.io/qwik'
import { server$ } from '@builder.io/qwik-city'
// server$ - function executed on the server, called from the client
const serverGreet = server$(async function (name: string) {
// This code ALWAYS runs on the server
// You have access to env, database, secrets
const apiKey = this.env.get('API_KEY')
console.log('Server log:', name, apiKey)
// Simulating a server operation
await new Promise(resolve => setTimeout(resolve, 100))
return {
greeting: `Hello ${name} from server!`,
timestamp: new Date().toISOString(),
}
})
const fetchUserFromDB = server$(async function (userId: string) {
// Safe database operations
const db = await connectToDatabase()
const user = await db.users.findById(userId)
return user
})
export const ServerFunctionExample = component$(() => {
const result = useSignal<{ greeting: string; timestamp: string } | null>(null)
const isLoading = useSignal(false)
const callServer = $(async () => {
isLoading.value = true
try {
// Calling a server function from the client
result.value = await serverGreet('World')
} finally {
isLoading.value = false
}
})
return (
<div>
<button onClick$={callServer} disabled={isLoading.value}>
{isLoading.value ? 'Loading...' : 'Call Server'}
</button>
{result.value && (
<div>
<p>{result.value.greeting}</p>
<small>At: {result.value.timestamp}</small>
</div>
)}
</div>
)
})Layout and nested routing
// src/routes/layout.tsx
import { component$, Slot } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
// Global data loader
export const useCurrentUser = routeLoader$(async ({ cookie }) => {
const token = cookie.get('auth-token')?.value
if (!token) return null
const response = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) return null
return response.json()
})
export default component$(() => {
const user = useCurrentUser()
return (
<div class="app">
<header>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/about">About</a>
{user.value ? (
<span>Welcome, {user.value.name}</span>
) : (
<a href="/login">Login</a>
)}
</nav>
</header>
<main>
{/* Slot renders child routes */}
<Slot />
</main>
<footer>
<p>© 2024 My App</p>
</footer>
</div>
)
})// src/routes/dashboard/layout.tsx - Nested layout
import { component$, Slot } from '@builder.io/qwik'
import { routeLoader$, useLocation } from '@builder.io/qwik-city'
// Middleware - redirect if not authenticated
export const onRequest: RequestHandler = async ({ redirect, cookie }) => {
const token = cookie.get('auth-token')?.value
if (!token) {
throw redirect(302, '/login')
}
}
export default component$(() => {
const location = useLocation()
const navItems = [
{ href: '/dashboard', label: 'Overview' },
{ href: '/dashboard/projects', label: 'Projects' },
{ href: '/dashboard/settings', label: 'Settings' },
]
return (
<div class="dashboard-layout">
<aside class="sidebar">
<nav>
{navItems.map(item => (
<a
key={item.href}
href={item.href}
class={{ active: location.url.pathname === item.href }}
>
{item.label}
</a>
))}
</nav>
</aside>
<div class="dashboard-content">
<Slot />
</div>
</div>
)
})API routes
// src/routes/api/users/index.ts
import type { RequestHandler } from '@builder.io/qwik-city'
export const onGet: RequestHandler = async ({ json, query }) => {
const page = parseInt(query.get('page') || '1')
const limit = parseInt(query.get('limit') || '10')
const users = await db.users.findMany({
skip: (page - 1) * limit,
take: limit,
})
json(200, {
users,
pagination: { page, limit },
})
}
export const onPost: RequestHandler = async ({ json, parseBody, status }) => {
const body = await parseBody()
if (!body?.email || !body?.name) {
status(400)
return json(400, { error: 'Missing required fields' })
}
const user = await db.users.create({
data: { email: body.email, name: body.name },
})
json(201, user)
}// src/routes/api/users/[id]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city'
export const onGet: RequestHandler = async ({ params, json, status }) => {
const user = await db.users.findById(params.id)
if (!user) {
status(404)
return json(404, { error: 'User not found' })
}
json(200, user)
}
export const onPut: RequestHandler = async ({ params, parseBody, json, status }) => {
const body = await parseBody()
const user = await db.users.update({
where: { id: params.id },
data: body,
})
if (!user) {
status(404)
return json(404, { error: 'User not found' })
}
json(200, user)
}
export const onDelete: RequestHandler = async ({ params, json, status }) => {
try {
await db.users.delete({ where: { id: params.id } })
json(200, { success: true })
} catch {
status(404)
json(404, { error: 'User not found' })
}
}Integrations
Tailwind CSS
npm run qwik add tailwind// Using Tailwind with Qwik
export const Button = component$<{
variant?: 'primary' | 'secondary'
}>((props) => {
const baseClasses = 'px-4 py-2 rounded-lg font-medium transition-colors'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
}
return (
<button class={`${baseClasses} ${variants[props.variant || 'primary']}`}>
<Slot />
</button>
)
})Prisma
npm install prisma @prisma/client
npx prisma init// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
declare global {
var prisma: PrismaClient | undefined
}
export const prisma = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma
}// src/routes/users/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
import { prisma } from '~/lib/prisma'
export const useUsers = routeLoader$(async () => {
return prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 20,
})
})
export default component$(() => {
const users = useUsers()
return (
<div>
<h1>Users</h1>
<ul>
{users.value.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
)
})Auth (with Lucia)
// src/lib/auth.ts
import { Lucia } from 'lucia'
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
import { prisma } from './prisma'
const adapter = new PrismaAdapter(prisma.session, prisma.user)
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => ({
email: attributes.email,
name: attributes.name,
}),
})// src/routes/login/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city'
import { lucia } from '~/lib/auth'
import { prisma } from '~/lib/prisma'
import { verifyPassword } from '~/lib/password'
export const useLogin = routeAction$(
async (data, { cookie, redirect, fail }) => {
const user = await prisma.user.findUnique({
where: { email: data.email },
})
if (!user || !await verifyPassword(data.password, user.passwordHash)) {
return fail(401, { message: 'Invalid credentials' })
}
const session = await lucia.createSession(user.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
cookie.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes)
throw redirect(302, '/dashboard')
},
zod$(z.object({
email: z.string().email(),
password: z.string().min(8),
}))
)
export default component$(() => {
const login = useLogin()
return (
<Form action={login}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Login</button>
{login.value?.failed && <p class="error">{login.value.message}</p>}
</Form>
)
})Advanced patterns
Context
import { component$, createContextId, useContextProvider, useContext, Slot } from '@builder.io/qwik'
// Context definition
interface ThemeContext {
theme: 'light' | 'dark'
toggle: () => void
}
export const ThemeContextId = createContextId<ThemeContext>('theme')
// Provider
export const ThemeProvider = component$(() => {
const themeStore = useStore<ThemeContext>({
theme: 'light',
toggle: $(() => {
themeStore.theme = themeStore.theme === 'light' ? 'dark' : 'light'
}),
})
useContextProvider(ThemeContextId, themeStore)
return <Slot />
})
// Consumer
export const ThemeToggle = component$(() => {
const theme = useContext(ThemeContextId)
return (
<button onClick$={theme.toggle}>
Current: {theme.theme}
</button>
)
})Resource and Suspense
import { component$, useResource$, Resource } from '@builder.io/qwik'
export const AsyncDataExample = component$(() => {
const userId = useSignal('1')
// useResource$ - async data with automatic SSR
const userResource = useResource$(async ({ track, cleanup }) => {
const id = track(() => userId.value)
const controller = new AbortController()
cleanup(() => controller.abort())
const response = await fetch(`/api/users/${id}`, {
signal: controller.signal,
})
return response.json()
})
return (
<div>
<select
value={userId.value}
onChange$={(e) => userId.value = (e.target as HTMLSelectElement).value}
>
<option value="1">User 1</option>
<option value="2">User 2</option>
<option value="3">User 3</option>
</select>
{/* Resource automatically handles loading/error/success */}
<Resource
value={userResource}
onPending={() => <p>Loading user...</p>}
onRejected={(error) => <p>Error: {error.message}</p>}
onResolved={(user) => (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)}
/>
</div>
)
})Deployment
Vercel
npm run qwik add vercel-edge
# or
npm run qwik add vercel-serverlessCloudflare Pages
npm run qwik add cloudflare-pagesNode.js server
npm run qwik add express
# or
npm run qwik add fastifyStatic Site Generation
npm run qwik add static
npm run build.client
npm run build.server
npm run ssgPerformance comparison
| Application | Qwik | Next.js | SvelteKit |
|---|---|---|---|
| E-commerce (50 products) | 3KB JS | 180KB JS | 45KB JS |
| Dashboard (10 widgets) | 2KB JS | 250KB JS | 60KB JS |
| Blog (10 posts) | 1.5KB JS | 120KB JS | 35KB JS |
| TTI (3G mobile) | 0.3s | 4.2s | 1.8s |
| LCP | 0.8s | 1.5s | 1.1s |
| CLS | 0 | 0.05 | 0.02 |
Pricing
- The framework itself is free - MIT licensed, with no paid edition and no account anywhere
- Builder.io, the company where Qwik started, now sells two separate products, and neither is needed to work with the framework:
- Fusion (the visual workspace): a free plan for up to five users with sixty monthly credits and a fifteen a day cap, Pro at 30 USD per user a month, Team at 50 USD, Enterprise on request
- Publish (the visual CMS, described in older material as Visual CMS): sales quote only, with no free plan and no self serve subscription
- Mind the two rates on Fusion: 30 and 50 USD are the monthly billing prices, and annual billing drops them to 24 and 40 USD per user
FAQ - frequently asked questions
Is Qwik production-ready?
Yes. Qwik is in a stable version (v1.0+) and is used by Builder.io and other companies in production. It has a growing community and active development.
How does Qwik scale with large applications?
Qwik scales better than traditional frameworks because the initial JS remains constant (~1KB) regardless of application size. JavaScript is lazily loaded as the user interacts with the page.
Can I use React libraries with Qwik?
Qwik has @builder.io/qwik-react which allows you to use React components in Qwik applications, but you lose the resumability benefits for those components.
What is the learning curve for Qwik?
If you know React or other modern frameworks, Qwik is easy to learn. The JSX syntax is familiar. The main difference is understanding the $ concept (lazy loading boundaries) and resumability.
Why the dollar sign on functions?
It marks a lazy loading boundary. The build tool extracts such a function into a separate fragment, fetched only when needed. Without those boundaries resumability could not work, because code could not be fetched piecemeal.
How does version 2 differ from version 1?
Chiefly in the package name. Version 2 lives in the @qwik.dev namespace rather than @builder.io, so migration means updating every import. Beyond that, shipped HTML is lighter and task scheduling faster.
What this does not solve
This section matters more than a list of advantages, because it decides whether the entry cost of a niche ecosystem is worth paying.
Resumability addresses startup cost, not running cost. If your application is slow because it performs heavy computation or renders a thousand rows at once, this framework will not fix that. It improves the moment a page becomes interactive, not what happens afterwards.
Nor does it help where the user is about to click anyway. An admin panel where the first action follows a second after arrival will fetch that code immediately, making the saving illusory. The greatest value sits where most visitors read and leave: content sites, shops, landing pages.
The third matter is latency on first interaction. Since code is fetched only after a click, that first click waits on the network. Background prefetching softens this, but on a poor connection the difference is noticeable and is the inverse of what the framework advertises.
The fourth is the ecosystem. Few libraries are written for this framework, and integration with React components works at the cost of losing resumability for exactly those components. Adding one component library can undo the main advantage the framework was chosen for.
The fifth is availability of people. Hiring a developer, you will find dozens of candidates who know the popular frameworks and a handful who know this one. The syntax resembles React, so the basics come quickly, but understanding why code must be written with lazy loading boundaries takes longer than a week.
The sixth, worth knowing over a longer horizon, is the durability of the approach itself. Resumability is an original and technically interesting idea, but it did not catch on widely: the other frameworks went towards server components and towards limiting code shipped to the browser by other means. The same problem was therefore solved differently and at less cost to the person writing. That does not make this approach worse, but it does mean betting on a solution whose future depends on one relatively small team rather than on the momentum of the whole industry.
The source and releases live in the project repository, and documentation on the qwik.dev site.