Migrating an Astro Project to Qwik City

Moving from Astro to Qwik City is less a syntax translation than a change of hydration philosophy, as the Astro versus Qwik partial hydration trade-offs comparison lays out: you are trading directive-scheduled island re-execution for resumability. The good news is that the two frameworks share the same core instinct — keep static content free of JavaScript — so most of your .astro templates map cleanly onto Qwik’s component$. This guide walks the migration route-by-route, converts a representative interactive island, and shows where Astro’s client:* directives dissolve into Qwik’s default deferral.

Prerequisites


Astro to Qwik City Migration Mapping Left column lists Astro artefacts: src/pages/*.astro routes, static .astro components, and client:* islands with props. Arrows map each to the right column of Qwik City artefacts: src/routes/index.tsx, component$ components, and component$ with $() handlers plus useSignal state. ASTRO QWIK CITY src/pages/blog/[slug].astro route + frontmatter fetch src/routes/blog/[slug]/index.tsx component$ + routeLoader$ PriceTable.astro (static) no client directive PriceTable = component$(…) no signals → ships no JS <Counter client:visible /> React island + props component$ + useSignal + $() resumes on interaction client:* deferral is Qwik's default — the directive disappears

Implementation Steps

Step 1 — Scaffold Qwik City and map the route tree

Goal: Stand up a Qwik City app whose src/routes mirrors Astro’s src/pages.

# Create the Qwik City app alongside the existing Astro project
npm create qwik@latest qwik-city-app
cd qwik-city-app
# Qwik City is file-system routed like Astro, but routes are folders
# containing index.tsx, and layouts are _layout.tsx (not layouts/*.astro).

Map the structure directly: src/pages/index.astro becomes src/routes/index.tsx; src/pages/blog/[slug].astro becomes src/routes/blog/[slug]/index.tsx; a shared layouts/Base.astro becomes src/routes/_layout.tsx.

Expected output: npm run dev serves the Qwik starter, and your planned route folders exist (empty) under src/routes.

Step 2 — Port routes and layouts

Goal: Convert a .astro page and its layout to Qwik City equivalents.

// src/routes/_layout.tsx — replaces layouts/Base.astro.
// The layout is itself a resumable component; <Slot/> is Astro's <slot/>.
import { component$, Slot } from '@builder.io/qwik';

export default component$(() => {
  return (
    <html lang="en">
      <body>
        <header><nav>{/* static nav — no JS shipped */}</nav></header>
        <main><Slot /></main>{/* child route renders here */}
      </body>
    </html>
  );
});
// src/routes/index.tsx — replaces src/pages/index.astro.
import { component$ } from '@builder.io/qwik';

export default component$(() => {
  // JSX replaces the .astro template body. Static markup ships zero JS,
  // exactly as Astro's server-only default did.
  return <section><h1>Home</h1><p>Static content.</p></section>;
});

Expected output: Visiting / renders the ported home page wrapped in the layout, with no client JavaScript beyond the Qwik loader.

Step 3 — Convert static components

Goal: Translate a static .astro component to component$ with no interactive code.

// src/components/price-table.tsx — was PriceTable.astro.
import { component$ } from '@builder.io/qwik';

interface Row { plan: string; price: number; }

// No useSignal, no $() handlers → Qwik serialises this as static markup
// and ships no interactive JavaScript for it. This is the resumable
// equivalent of Astro's server-only component.
export const PriceTable = component$<{ rows: Row[] }>(({ rows }) => {
  return (
    <table>
      {rows.map((r) => (
        <tr key={r.plan}><td>{r.plan}</td><td>${r.price}</td></tr>
      ))}
    </table>
  );
});

Expected output: The table renders identically, and the Network panel shows no component chunk downloaded for it.

Step 4 — Convert client:* islands to resumable components

Goal: Replace an Astro island and its hydration directive with a component$ using useSignal state and a $() handler.

// src/components/counter.tsx — was <Counter client:visible /> (a React island).
import { component$, useSignal, $ } from '@builder.io/qwik';

// The client:visible directive is GONE. Qwik defers the handler chunk until
// the first click by default, so the deferral Astro achieved with a directive
// is now the framework's baseline behaviour.
export const Counter = component$<{ start?: number; label?: string }>(
  ({ start = 0, label = 'Add' }) => {
    const count = useSignal(start);            // was React useState; serialised state
    const inc = $(() => { count.value++; });   // lazy handler chunk, loaded on click
    return <button onClick$={inc}>{label}: {count.value}</button>;
  }
);

Expected output: The counter behaves the same, but the Network panel shows its handler chunk fetched only on the first click — not at page load.

Step 5 — Move props and data loading to signals and routeLoader$

Goal: Replace Astro’s frontmatter fetches and inline island props with Qwik’s server data loader.

// src/routes/blog/[slug]/index.tsx — replaces the frontmatter fetch in [slug].astro.
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

// routeLoader$ runs on the server (like Astro frontmatter) and exposes data
// to resumable components without a client-side fetch waterfall.
export const usePost = routeLoader$(async ({ params }) => {
  return await fetchPost(params.slug); // server-only; result serialised
});

export default component$(() => {
  const post = usePost(); // reactive, resumable access to the loaded data
  return <article><h1>{post.value.title}</h1><p>{post.value.body}</p></article>;
});

Expected output: The post route renders server-fetched data with no client fetch, and the serialised state appears in the qwik/json script tag rather than as inline island props.


Verification

  1. Resumability check. Load a migrated route and open the Network panel. On first paint you should see only the Qwik loader and no component chunks. Chunks should appear only as you interact — the defining evidence that hydration has been replaced by resumption.
  2. Load-time JS budget. Run the console snippet from the trade-offs comparison to sum transferred JavaScript. It should be a few kilobytes regardless of how many interactive components the route contains.
  3. Serialised state sanity. Inspect the script[type="qwik/json"] payload size. If it is unexpectedly large, a signal is holding data that should have stayed on the server behind routeLoader$.
  4. Parity pass. Diff the rendered HTML of the Astro and Qwik routes. Static output should match; differences should be limited to Qwik’s serialisation attributes (q:*).

Troubleshooting

Build error: "Cannot serialize" or "captured value is not serializable"

Root cause: A $() boundary captured a non-serialisable value — a class instance, a DOM node, or a plain function reference — which Qwik cannot embed into the resumable payload.

Fix: Inside $() handlers, capture only serialisable primitives and signals. Reconstruct rich objects lazily within the handler body rather than closing over them.

// Bad: captures a live Date instance
const onClick = $(() => log(new Date(fixed))); // fixed is a Date → fails
// Good: capture a serialisable primitive, rebuild inside
const ts = fixed.getTime();
const onClick = $(() => log(new Date(ts)));
An interactive component hydrates eagerly / ships JS at load

Root cause: Event work was placed outside a $() boundary, or a React interop wrapper is hosting the component, pulling React’s runtime in at load and defeating resumability.

Fix: Ensure every event handler is wrapped in $() and every component uses component$. If it is a React-interop island, rewrite it as a native Qwik component so its handler is deferred; reserve interop for large third-party widgets only.

Route data is undefined at render time

Root cause: Data was fetched inside the component body (client-side) instead of in a routeLoader$, so the server render had nothing to serialise.

Fix: Move the fetch into routeLoader$ and read it via the generated useX() hook. The loader runs on the server and serialises the result into the resumable payload, matching Astro’s frontmatter fetch semantics.


← Back to Astro vs Qwik: Partial Hydration Trade-offs