We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide25 min read

Qwik, a no hydration framework with resumability

Qwik replaces hydration with resumability, so the browser runs no code on load. Version 2 in beta, the syntax, the trade offs, and the ecosystem.

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:

Code
TEXT
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 interactive

Problem: 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

Code
TEXT
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

MetricQwikReactVueAngular
Initial JS~1KB50-100KB40-80KB80-150KB
Time to InteractiveInstant1-5s1-4s2-6s
HydrationNone (resume)YesYesYes
Lazy loadingPer-listenerPer-routePer-routePer-route
Bundle growthLinearExponentialExponentialExponential
SSR overheadMinimalHighMediumHigh

Installation and configuration

Creating a new project

Code
Bash
# 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

Code
TEXT
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.json

Basic configuration

TSvite.config.ts
TypeScript
// 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$

TScomponents/counter.tsx
TypeScript
// 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:

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TEXT
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 routes

Page with routeLoader$

TSsrc/routes/blog/[slug]/index.tsx
TypeScript
// 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

TSsrc/routes/contact/index.tsx
TypeScript
// 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

Code
TypeScript
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

TSsrc/routes/layout.tsx
TypeScript
// 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>&copy; 2024 My App</p>
      </footer>
    </div>
  )
})
TSsrc/routes/dashboard/layout.tsx
TypeScript
// 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

TSsrc/routes/api/users/index.ts
TypeScript
// 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)
}
TSsrc/routes/api/users/[id]/index.ts
TypeScript
// 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

Code
Bash
npm run qwik add tailwind
Code
TypeScript
// 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

Code
Bash
npm install prisma @prisma/client
npx prisma init
TSsrc/lib/prisma.ts
TypeScript
// 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
}
TSsrc/routes/users/index.tsx
TypeScript
// 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)

TSsrc/lib/auth.ts
TypeScript
// 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,
  }),
})
TSsrc/routes/login/index.tsx
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm run qwik add vercel-edge
# or
npm run qwik add vercel-serverless

Cloudflare Pages

Code
Bash
npm run qwik add cloudflare-pages

Node.js server

Code
Bash
npm run qwik add express
# or
npm run qwik add fastify

Static Site Generation

Code
Bash
npm run qwik add static
npm run build.client
npm run build.server
npm run ssg

Performance comparison

ApplicationQwikNext.jsSvelteKit
E-commerce (50 products)3KB JS180KB JS45KB JS
Dashboard (10 widgets)2KB JS250KB JS60KB JS
Blog (10 posts)1.5KB JS120KB JS35KB JS
TTI (3G mobile)0.3s4.2s1.8s
LCP0.8s1.5s1.1s
CLS00.050.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.