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
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
- 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.
- 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.
- 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 behindrouteLoader$. - 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.
Frequently Asked Questions
Do I convert every Astro component to component$?
Yes — Qwik has a single component model, so both static .astro components and interactive islands become component$ components. Static ones simply contain no useSignal state or $() handlers, so Qwik ships no interactive code for them, mirroring Astro's server-only default.
How do Astro's client:* directives map to Qwik?
They mostly disappear. Because Qwik defers all handler execution via resumability, you do not annotate when to hydrate — Qwik loads a handler's chunk on first interaction automatically. Where Astro used client:visible or client:idle purely to delay work, Qwik's default behaviour already achieves that deferral.
Can I reuse my React islands in Qwik City?
Partially. Qwik offers a React interop layer that can host existing React components, but it reintroduces React's runtime for those subtrees and loses resumability there. For a clean migration, rewrite hot-path islands as native Qwik component$ components and reserve interop for large third-party widgets you cannot rewrite immediately.
Related
- Astro vs Qwik: Partial Hydration Trade-offs — the conceptual comparison that motivates and scopes this migration.
- Qwik Resumable Architecture — the resumability internals your migrated app now depends on.