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.
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.