← All posts
·15 min read

Claude Code with Qwik: Resumable Apps, Zero Hydration

Claude CodeQwikFrontendSSR
Claude Code with Qwik: Resumable Apps, Zero Hydration

Why Qwik without CLAUDE.md skips $ markers and loses resumability

Qwik's value proposition is resumability. The server renders full HTML and serializes application state into the page. When the browser receives it, no JavaScript runs until the user interacts with something. At that point, Qwik loads only the code for that specific interaction and resumes execution exactly where the server left off. There is no hydration pass, no replay of component tree construction, and no JavaScript waterfall before the page becomes interactive.

That architecture is enforced by a single convention: the dollar sign at the end of identifiers. component$, onClick$, useTask$, server$ are not naming preferences. They are boundary markers. Qwik's optimizer splits the bundle at every $ boundary and generates a separate lazy-loadable chunk for each one. A component without $ is not resumable. It is synchronously loaded, defeating the entire model.

Claude Code does not know this by default. Without explicit guidance, Claude reaches for what it knows: React patterns. It writes export const Counter = () => { ... } instead of export const Counter = component$(() => { ... }). It uses useState and useEffect imports. It writes count() to read a signal value, which is the SolidJS convention, instead of count.value, which is Qwik's. It puts state initialization outside a signal or store, making it non-reactive. It imports server-side database logic directly in a component file rather than isolating it behind server$. None of these mistakes produce a build error. They produce an application that silently loses resumability or ships excessive JavaScript to the client.

This guide covers the CLAUDE.md configuration that anchors Claude Code to Qwik 2's actual model. For React-specific CLAUDE.md configuration, Claude Code with React covers the hooks and state patterns that Claude needs to unlearn when switching to Qwik. For the Next.js server component model, which shares some server-first thinking with QwikCity, Claude Code with Next.js is a useful contrast.

The Qwik CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Qwik project it needs to declare the framework version, the $ lazy-boundary rule (the single most important constraint), the correct signal and store APIs, the server$ isolation policy, QwikCity routing conventions, and the hard rules that block the React patterns Claude generates by default.

# Qwik project rules

## Stack
- Qwik 2.x, QwikCity 2.x, TypeScript 5.x strict
- Vite 5.x build
- Node 20.x runtime

## Project structure
- src/routes/          , QwikCity file-based routes (index.tsx per route)
- src/components/      , Shared UI components
- src/lib/             , Shared utilities (non-component)
- src/entry.ssr.tsx    , SSR entry point (do not modify without cause)
- public/              , Static assets
- adapters/            , Deployment adapter configs (Vercel, Cloudflare, etc.)

## The $ lazy-boundary rule (MOST IMPORTANT)
- EVERY component MUST use component$(): export const MyComp = component$(() => { ... })
- NEVER write: export const MyComp = () => { ... }  (this is React, NOT Qwik)
- EVERY event handler MUST use a $ suffix: onClick$, onInput$, onChange$, onSubmit$
- NEVER write: onClick={() => ...}  (React style, drops the lazy boundary)
- EVERY task MUST use useTask$(): useTask$(({ track }) => { ... })
- NEVER write: useEffect(() => ..., [dep])  (React, does not exist in Qwik)
- useVisibleTask$() is available but use it sparingly (runs client-only, blocks resumability)
- The $ is not cosmetic. Dropping it produces a component that is not lazy-loaded.

## Signals and state
- Primitive values: const count = useSignal(0)
- Read the value: count.value  (NOT count(), NOT count, NOT count.get())
- Write the value: count.value = 5  (NOT setCount(5), NOT count.set(5))
- Object/array state: const state = useStore({ items: [], loading: false })
- useStore is deeply reactive: mutate state.items.push(x) directly
- NEVER use useState() or useRef(). These are React APIs and do not exist in Qwik
- NEVER destructure signals: const { value } = count  (loses reactivity)
- Context: useContextProvider(MyCtx, state) + useContext(MyCtx) in children

## Server-only code: server$
- server$(async () => { ... }) runs exclusively on the server, never ships to the client
- Use server$ for: database queries, secret API calls, file system access
- Return plain serializable values (no class instances, no Promises with closures)
- NEVER import ORM/DB clients at the top level of a component file
- DB imports belong only inside server$ functions or in routeLoader$/routeAction$ files

## Component anatomy
export const Counter = component$(() => {
  const count = useSignal(0);

  return (
    <div>
      <p>Count: {count.value}</p>
      <button onClick$={() => count.value++}>Increment</button>
    </div>
  );
});

## Hard rules
- NEVER: export const X = () => (React arrow component, no $ boundary)
- NEVER: useState, useEffect, useRef, useMemo, useCallback (React hooks)
- NEVER: count() to read a signal (SolidJS convention, wrong here)
- NEVER: import server-side code (db, fs, env secrets) in component files
- NEVER: useVisibleTask$ as a default. Prefer useTask$ with track()
- ALWAYS: component$, onClick$, onInput$, onChange$ with $ suffix
- ALWAYS: count.value to read and write signals
- ALWAYS: useStore for objects with multiple reactive fields

The dollar-sign rule is non-negotiable and must appear first, because it is the pattern Claude violates most frequently and most invisibly. A component written without component$ still renders. It still accepts props. It still produces HTML. The optimizer silently treats it as a non-resumable chunk and includes it in the initial bundle. The page works, but the performance model breaks. Catching this requires either a build-manifest inspection or a Lighthouse trace, not a runtime error.

The $ syntax: component$, handler$, onClick$

The $ suffix tells Qwik's Vite plugin to extract the function body into a separate chunk. At build time, the optimizer replaces the function body with a lazy import. At runtime, Qwik serializes a reference to that chunk into the HTML. When the user triggers an interaction, the browser fetches only that chunk and executes only that function.

This is why every boundary must be explicit. Consider the difference:

// Wrong: no lazy boundary, full component code in initial bundle
export const Toggle = () => {
  const [open, setOpen] = useState(false);
  return <button onClick={() => setOpen(!open)}>{open ? 'Close' : 'Open'}</button>;
};

// Correct: component body is lazy, click handler is its own chunk
export const Toggle = component$(() => {
  const open = useSignal(false);
  return (
    <button onClick$={() => { open.value = !open.value; }}>
      {open.value ? 'Close' : 'Open'}
    </button>
  );
});

Claude generates the first form by default, particularly when given a prompt like "write a toggle component." The CLAUDE.md rule forces the second form by making component$ the only allowed export pattern.

For nested handlers, the rule extends to every function that touches reactive state:

// Each handler is its own lazy chunk
export const Form = component$(() => {
  const name = useSignal('');
  const submitted = useSignal(false);

  const handleSubmit = $((event: SubmitEvent) => {
    event.preventDefault();
    submitted.value = true;
  });

  return (
    <form onSubmit$={handleSubmit}>
      <input
        value={name.value}
        onInput$={(e: InputEvent) => {
          name.value = (e.target as HTMLInputElement).value;
        }}
      />
      {submitted.value && <p>Submitted: {name.value}</p>}
    </form>
  );
});

The $() wrapper on handleSubmit makes it a separately-lazied function that can be referenced by the form's onSubmit$. Claude will collapse this into an inline arrow without the $() wrapper if the CLAUDE.md rule does not specify the pattern.

Add this to your CLAUDE.md under the handler section:

## Extracting handlers to variables
- When a handler is more than one line, extract it with $():
  const handleClick = $(() => { /* multi-line logic */ });
  <button onClick$={handleClick}>...</button>
- $ wrapping a function reference: onClick$={myHandler} works if myHandler is already $-wrapped
- NEVER pass a plain function reference: onClick$={plainFunction} loses the lazy boundary

useSignal vs useStore

Qwik provides two reactive primitives. Choosing the wrong one does not break the application, but it produces either unnecessary re-renders or missing reactivity.

useSignal holds a single value. It is the right choice for a counter, a boolean toggle, a string input value, or any scalar. Reading and writing go through .value:

const count = useSignal(0);
const label = useSignal('');
const visible = useSignal(false);

// Read
<p>{count.value}</p>

// Write
count.value += 1;
label.value = 'updated';
visible.value = !visible.value;

useStore holds an object with deep reactivity. It is the right choice for a form with multiple fields, a list that can be mutated, or any structure where multiple properties change together. Mutations are direct and do not require setter functions:

const form = useStore({
  firstName: '',
  lastName: '',
  email: '',
  loading: false,
  errors: {} as Record<string, string>,
});

// Direct mutation, Qwik tracks the change
form.firstName = 'Alice';
form.errors['email'] = 'Invalid format';
form.loading = true;

// Array mutation is also tracked
const list = useStore({ items: [] as string[] });
list.items.push('new item');
list.items = list.items.filter(i => i !== 'old item');

Add the decision rule to CLAUDE.md:

## useSignal vs useStore decision rule
- Single primitive value (number, string, boolean, null): useSignal
- Object with multiple fields or array that is mutated: useStore
- NEVER useSignal for objects when multiple properties change (causes coarse re-render)
- NEVER useStore for a single primitive (overengineered, use useSignal)
- NEVER wrap a useStore value in another useSignal

Claude's default mistake is using useSignal for objects: const form = useSignal({ name: '', email: '' }). Writing form.value = { ...form.value, name: 'Alice' } is equivalent to React's setState spread pattern. It works, but it is not idiomatic Qwik and produces a full object replacement instead of a fine-grained property update. The CLAUDE.md rule steers Claude to useStore for these cases.

Server-only functions with server$

server$ is Qwik's mechanism for code that must never ship to the browser. It wraps an async function and guarantees the optimizer excludes it from any client bundle. The use cases are database queries, secret API calls, reading environment variables that should not be exposed, and any Node.js API like fs or crypto.

import { component$, useSignal } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';

// This function is compiled out of the client bundle entirely
const fetchUserData = server$(async function(userId: string) {
  // Safe to import db client here. This code never reaches the browser
  const { db } = await import('../lib/db');
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
  });
  return user ?? null;
});

export const UserProfile = component$(() => {
  const user = useSignal<User | null>(null);
  const loading = useSignal(false);

  return (
    <div>
      <button
        onClick$={async () => {
          loading.value = true;
          user.value = await fetchUserData('user-123');
          loading.value = false;
        }}
      >
        Load Profile
      </button>
      {loading.value && <p>Loading...</p>}
      {user.value && <p>{user.value.name}</p>}
    </div>
  );
});

The critical rule Claude misses: server$ functions must return plain serializable values. The return value is serialized by Qwik's serializer and sent to the client as part of the HTML state. Class instances, functions, and Promises with closures cannot be serialized and will throw at runtime.

Add to CLAUDE.md:

## server$ rules
- server$ functions return plain objects, arrays, strings, numbers, booleans, or null
- NEVER return class instances, functions, or non-serializable structures
- NEVER import db/ORM clients at the top level of a component file
- DB and secret imports live inside server$ or inside routeLoader$/routeAction$ modules
- server$ can read process.env safely (env vars are never serialized to the client)
- For form mutations, prefer routeAction$ over server$ (better error handling + progressive enhancement)

QwikCity routing: routes/ structure

QwikCity is Qwik's meta-framework, analogous to Next.js for React or SvelteKit for Svelte. Routing is file-based. Every index.tsx inside src/routes/ is a route. The URL path mirrors the directory path.

src/routes/
  index.tsx               -> /
  about/
    index.tsx             -> /about
  blog/
    index.tsx             -> /blog
    [slug]/
      index.tsx           -> /blog/:slug
  api/
    users/
      index.ts            -> /api/users  (API endpoint, no JSX)

Every route file exports a default component. It can also export routeLoader$ for server-side data loading and routeAction$ for form mutations. Claude's mistake is placing data fetching inside the component body using server$ when routeLoader$ is the correct pattern for route-level data.

Add to CLAUDE.md:

## QwikCity routing rules
- Route files: src/routes/{path}/index.tsx
- Dynamic segments: src/routes/blog/[slug]/index.tsx (access via useLocation().params.slug)
- API endpoints: src/routes/api/{path}/index.ts (export onGet, onPost, etc., no JSX)
- Default export: the page component (must use component$)
- Named exports: routeLoader$, routeAction$, head (SEO meta)
- NEVER fetch data inside the component body for route-level data. Use routeLoader$
- NEVER use server$ for data that should block the route render. Use routeLoader$

## routeLoader$ pattern (data that the route needs before rendering)
import { routeLoader$ } from '@builder.io/qwik-city';

export const useProductData = routeLoader$(async (requestEvent) => {
  const { params, env } = requestEvent;
  const apiKey = env.get('API_KEY');
  const res = await fetch(`https://api.example.com/products/${params.id}`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!res.ok) throw requestEvent.error(404, 'Not found');
  return res.json() as Promise<Product>;
});

export default component$(() => {
  const product = useProductData();  // Signal<Product>
  return <h1>{product.value.name}</h1>;
});

## routeAction$ pattern (form mutations with progressive enhancement)
import { routeAction$, zod$, z } from '@builder.io/qwik-city';

export const useCreatePost = routeAction$(
  async (data, requestEvent) => {
    const { db } = await import('../../lib/db');
    await db.insert(posts).values(data);
    throw requestEvent.redirect(302, '/blog');
  },
  zod$({ title: z.string().min(1), body: z.string().min(10) })
);

Loaders, actions, and the head export

Three named exports compose a QwikCity route: routeLoader$ for data, routeAction$ for mutations, and head for SEO metadata. Claude commonly omits the head export on content pages and defaults to generic titles.

// src/routes/blog/[slug]/index.tsx

import { component$ } from '@builder.io/qwik';
import { routeLoader$, type DocumentHead } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async ({ params, error }) => {
  const post = await fetchPostBySlug(params.slug);
  if (!post) throw error(404, 'Post not found');
  return post;
});

export default component$(() => {
  const post = usePost();
  return (
    <article>
      <h1>{post.value.title}</h1>
      <p>{post.value.body}</p>
    </article>
  );
});

// head runs on the server, has access to loader data
export const head: DocumentHead = ({ resolveValue }) => {
  const post = resolveValue(usePost);
  return {
    title: `${post.title} | My Blog`,
    meta: [
      { name: 'description', content: post.excerpt },
      { property: 'og:title', content: post.title },
      { property: 'og:description', content: post.excerpt },
    ],
  };
};

Add to CLAUDE.md:

## head export (SEO, required on every content route)
- EVERY non-index route MUST export a head object or head function
- head function receives resolveValue to access loader data for dynamic titles
- Required fields: title (under 60 chars), meta description (150-160 chars)
- og:title and og:description for social sharing
- NEVER hardcode generic titles like "My App | Page"

Deployment adapters

Qwik builds to ESM modules plus a manifest that maps lazy chunks to their URLs. The output is adapter-agnostic by default (static files). Adapters transform the output for a specific runtime: Vercel Edge, Cloudflare Workers, Netlify Edge, AWS Lambda, or a plain Node.js server.

Installing an adapter adds a new npm run build target and an entry.{adapter}.tsx file:

# Vercel Edge (recommended for most projects)
npm run qwik add vercel-edge

# Cloudflare Workers (best cold start performance)
npm run qwik add cloudflare-pages

# Netlify Edge
npm run qwik add netlify-edge

# Node.js Express (self-hosted)
npm run qwik add express

Add to CLAUDE.md:

## Deployment adapter rules
- DO NOT commit adapter files without selecting the correct target platform first
- Vercel Edge: adapters/vercel-edge/ builds to Vercel's edge runtime
- Cloudflare: adapters/cloudflare-pages/ builds to Workers format (no Node.js APIs)
- Cloudflare adapter: NEVER use Node.js built-ins (fs, path, crypto). Use Web APIs
- Node adapter: entry.express.tsx, standard Node.js, all Node APIs available
- Build command: npm run build (adapter-specific entry is selected automatically)
- Preview locally: npm run preview (uses the adapter's local dev server)
- Environment variables: use requestEvent.env.get('KEY') inside loaders/actions (not process.env in components)
- process.env is available only in Node adapter; use requestEvent.env.get() everywhere else for portability

Claude will use process.env.API_KEY in loader code without checking which adapter is active. That works in the Node adapter and fails silently on Cloudflare Workers. The requestEvent.env.get() API works on all adapters and is the portable form.

Common Claude mistakes with Qwik

Six patterns appear consistently when Claude Code writes Qwik without CLAUDE.md. Each one produces code that compiles and often renders correctly, making the mistake hard to spot during development.

Dropping the $ on component definitions. export const MyComp = () => { ... } is the most frequent mistake. The component renders but is not lazy-loaded. The full component code is included in the initial bundle. On a large application this adds kilobytes to the initial JavaScript payload and defeats the resumability model.

Reading signals with count() instead of count.value. Claude has SolidJS in its training data and uses SolidJS's getter-function pattern. count() on a Qwik signal is undefined at runtime. The CLAUDE.md entry for .value access is necessary.

Using useEffect for side effects. useEffect does not exist in Qwik. Claude generates it for cleanup tasks, subscriptions, and data fetching. The correct replacement is useTask$ with a track() call for reactive dependencies, or useVisibleTask$ for browser-only effects.

// Wrong: React pattern, does not exist in Qwik
useEffect(() => {
  document.title = `Count: ${count}`;
}, [count]);

// Correct: tracks count.value, runs on server and client
useTask$(({ track }) => {
  const c = track(() => count.value);
  if (typeof document !== 'undefined') {
    document.title = `Count: ${c}`;
  }
});

Importing server code in component files. import { db } from '../lib/db' at the top of a component$ file includes the database client in the client bundle. Qwik's tree-shaking removes unused exports, but a top-level import forces the module into the bundle graph. The fix is importing inside server$ or routeLoader$.

Using useState and useSignal interchangeably. Claude sometimes generates const [count, setCount] = useState(0) inside a component$ body. This throws at runtime because useState is not imported from any Qwik package. The error message points to the missing import rather than the pattern mismatch, which makes it confusing to debug.

Placing routeLoader$ in component files. routeLoader$ must be exported from a route file (src/routes/**/index.tsx). Claude sometimes generates it in a component file in src/components/. It does not run server-side from that location.

For the server-rendering and data loading patterns that translate across frameworks, Claude Code with Svelte covers SvelteKit's load function, which has a similar server-first philosophy to QwikCity's routeLoader$.

Building resumable apps with Claude Code

The Qwik CLAUDE.md in this guide produces applications where every component is wrapped in component$ and participates in lazy loading, signals and stores are used with their correct access patterns, server-side code is isolated behind server$ or route loaders and never ships to the browser, QwikCity routes export the full triple of loader, component, and head, and deployment adapters use the portable requestEvent.env.get() API instead of process.env.

The underlying principle is that Qwik's architecture is enforced by naming convention, not by a type system or runtime guard. Claude Code generates syntactically valid JavaScript that looks almost correct. The mistakes are in the suffixes and the access patterns. A CLAUDE.md that makes those suffixes non-negotiable and those access patterns explicit removes the entire class of errors.

The same principle applies across every framework integration with Claude Code. The framework has a model. The CLAUDE.md captures that model. Claude generates code that fits the model rather than defaulting to React patterns it has seen more often in training data. For the mechanics of how CLAUDE.md is loaded and versioned alongside your source code, see Claude Code with Next.js for a similar setup in a more familiar framework context.

Claudify includes a Qwik-specific CLAUDE.md template, pre-configured for the dollar-sign boundary rules, signal and store semantics, server$ isolation policy, QwikCity routing conventions, and adapter portability patterns shown in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame