routeLoader$()

Route Loaders load data on the server so it becomes available inside Qwik Components during rendering. They load on every navigation that hits their route.

src/routes/product/[productId]/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeLoader$ } from '@qwik.dev/router';
 
export const useProductDetails = routeLoader$(async ({ params }) => {
  // Runs only on the server, on this route, on every navigation
  const res = await fetch(`https://.../products/${params.productId}`);
  return (await res.json()) as Product;
});
 
export default component$(() => {
  // AsyncSignal<Product> — read .value, watch .loading, check .error
  const product = useProductDetails();
  return <p>Product name: {product.value.name}</p>;
});

routeLoader$()s must be exported from a layout.tsx or index.tsx file. To share one across routes, define it in a separate file and re-export it from each index.tsx / layout.tsx that needs it.

NOTEDon't use routeLoader$() to build a REST API — use Endpoints for that. Loaders are for data your own Qwik Components consume.

How it works

A few properties of route loaders:

  • Server-only. The loader function only runs on the server; the browser never sees the closure body.
  • Keyed by route, not by call site. Two visits to the same URL share one logical "result" per "route + url params + search params" set, not one per useProductDetails() call.
  • Result is an AsyncSignal. Components read .value, watch .loading, and check .error. Reading .value while the signal is in error state re-throws the error.
  • Each loader has its own JSON endpoint. The client fetches q-loader-{id}.{hash}.json for navigation, polling, and post-action invalidation. Browser HTTP cache, ETags, and the expires option all apply.

This means a routeLoader$() should be a pure function of the URL: same params and search → same response. Mixing in any other input (action state, request bodies, one-shot tokens) makes the cached responses inconsistent — see Loaders cannot read action state.

Reading the result in a component

Hooks return an AsyncSignal<T>. Branch on .loading and .error before reading .value:

src/routes/product/[productId]/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeLoader$ } from '@qwik.dev/router';
 
export const useProductDetails = routeLoader$(async ({ params, fail }) => {
  const product = await db.products.findById(params.productId);
  if (!product) {
    return fail(404, { message: 'Product not found' });
  }
  return product;
});
 
export default component$(() => {
  const product = useProductDetails();
 
  if (product.loading) {
    return <div>Loading…</div>;
  }
  if (product.error) {
    // ServerError — read .status and .data
    return <div>{product.error.status}: {product.error.data.message}</div>;
  }
  return <div>Product name: {product.value.name}</div>;
});
NOTEDon't read signal.value before checking signal.error. While the signal is in error state, accessing .value re-throws the error.

Signaling failure from a loader

Loaders signal failure two ways. Both put the signal into error state with signal.error set to a ServerError carrying .status (HTTP status) and .data (the payload):

  • return requestEvent.fail(status, data) — return a tagged failure. signal.error.data is { failed: true, ...data }.
  • throw requestEvent.error(status, data) — throw a ServerError directly. signal.error.data is data.

Multiple routeLoader$s

Multiple loaders are allowed in any combination, including in the same file. They execute in parallel — declaring more does not slow rendering down by itself.

src/routes/admin/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeLoader$ } from '@qwik.dev/router';
 
export const useLoginStatus = routeLoader$(async ({ cookie }) => ({
  isUserLoggedIn: checkCookie(cookie),
}));
 
export const useCurrentUser = routeLoader$(async ({ cookie }) => ({
  user: currentUserFromCookie(cookie),
}));
 
export default component$(() => {
  const loginStatus = useLoginStatus();
  const currentUser = useCurrentUser();
  return (
    <section>
      <h1>Admin</h1>
      {loginStatus.value.isUserLoggedIn ? (
        <p>Welcome {currentUser.value.user.name}</p>
      ) : (
        <p>You are not logged in</p>
      )}
    </section>
  );
});

When does the loader re-run?

A loader is re-fetched when:

  • the route path changes to one where the loader is declared (SPA navigation, MPA load) and it hasn't already run for this URL (and hasn't expired),
  • a search param the loader subscribed to changes (see Search params),
  • it's invalidated by an action (see routeAction$ and the action's invalidate option),
  • its data has expired and poll is enabled (see Cache & freshness).

A loader in src/routes/product/[productId]/index.tsx runs when the user navigates to /product/1 or /product/2, but not when they navigate to /about.

NOTEReading a loader hook from a component on a route where that loader is not declared returns undefined at runtime. The TypeScript signature does not reflect this — it's intentional, because route loaders that redirect can transiently be in this state during navigation. Treat it as an error on your part: if a component uses a loader, that loader's route must be the active route.

The search option declares which URL search parameters (not the route params) the loader actually depends on:

  • What gets sent. The loader's JSON request URL only includes the listed params, sorted into a stable order. Other params are stripped, so unrelated query strings don't bust the cache.
  • When it re-fetches. The signal only re-fetches when a listed search param (or the route path) changes.
src/routes/products/index.tsx
export const useFilteredProducts = routeLoader$(
  async ({ url }) => {
    const category = url.searchParams.get('category');
    const sort = url.searchParams.get('sort') ?? 'newest';
    return await db.products.list({ category, sort });
  },
  {
    // Only `?category=` and `?sort=` matter; `?utm_source=` etc. are ignored.
    search: ['category', 'sort'],
  }
);

search: [] means the loader doesn't depend on any search params — pure path-keyed cache, best reuse across visits. When search is omitted, all search params are forwarded and any change re-fetches (subject to strictLoaders, below).

NOTEThe strictLoaders Vite plugin option (default true in v2) applies search: [] to every loader that doesn't set it explicitly. This is the better default: each unique URL produces at most one cache entry per loader, so unrelated tracking params (?utm_source=…, ?fbclid=…) don't generate fresh, never-reused cache entries for an identical response. To opt a loader into reacting to a query string, set search explicitly. Pass strictLoaders: false to qwikRouter() to restore "all params, all re-fetches" globally.

Cache & freshness

Loader responses come from per-loader JSON endpoints, so ordinary HTTP caching applies. The options below tune that behavior.

expires

Time in milliseconds before the data is considered stale. Sets Cache-Control on the JSON response and the AsyncSignal's expiry. When the data expires, the next read triggers a refetch and components update reactively when the new data arrives.

src/routes/product/[productId]/index.tsx
export const useProductDetails = routeLoader$(
  async ({ params }) => fetchProduct(params.productId),
  { expires: 60 * 1000 } // 1 minute
);
NOTEexpires: 0 is special. It means "never expires" — the data is only refetched when the application is rebuilt (the manifest hash busts the cache). For SSG, loaders with expires: 0 are pre-generated as static .json files on disk, so route loaders work even on fully static sites.

poll

When poll: true and expires is set, the loader auto-refetches as soon as data expires — but only while components are reading the loader. Without poll, expired data is just marked stale; the refetch waits until something reads it.

allowStale

By default expired data stays readable while a fresh value loads in the background, so the UI doesn't blank out. Set allowStale: false to clear the value on expiry instead, forcing readers to suspend until fresh data arrives.

eTag

Adds an ETag header on the loader response and honors If-None-Match. Three modes:

  • eTag: true — auto-hash the serialized response. The loader still runs; only the response body is suppressed on a 304.
  • eTag: 'some-string' — static eTag. The loader is skipped entirely on a 304.
  • eTag: (requestEvent) => string | null — compute the eTag from request context (params, headers, cookies). The loader is skipped entirely on a 304. Return null to disable for this request.
src/routes/subscription/index.tsx
export const useSubscription = routeLoader$(
  async (ev) => {
    const user = ev.sharedMap.get('user')!;
    return getSubscription(user.id);
  },
  {
    expires: 30 * 1000,
    eTag: (ev) => {
      const { id } = ev.sharedMap.get('user')!;
      const day = new Date().getDate();
      return `${id}-${day}`; // valid for the rest of the day, per user
    },
  }
);

serializationStrategy

By default the HTML response does not embed loader data. On hydration, the loader signal is empty until the browser fetches the JSON endpoint — meaning fast first paint, but a brief loading state for code that reads .value immediately.

ValueBehavior
'never'Default. Data is discarded after SSR; the client refetches lazily.
'always'Embed data in the HTML. Available immediately, no refetch, bigger HTML.
'auto'Embed when small, lazy-load when large.
src/routes/product/[productId]/index.tsx
export const useProductDetails = routeLoader$(
  async ({ params }) => fetchProduct(params.productId),
  { serializationStrategy: 'always' }
);

To set the default for the whole app, configure the Vite plugin:

vite.config.ts
import { qwikRouter } from '@qwik.dev/router/vite';
 
export default () => ({
  plugins: [
    qwikRouter({
      defaultLoadersSerializationStrategy: 'always',
    }),
  ],
});

When using lazy-loaded data, branch on .loading to show a placeholder until it arrives — see Reading the result in a component.

Accessing RequestEvent

The loader function receives the same RequestEvent as middleware. Use it to read params, headers, cookies, the URL, and so on.

src/routes/recommendations/[user]/index.tsx
export const useRecommendations = routeLoader$(async (requestEvent) => {
  const userId = requestEvent.params.user;
  const lang = requestEvent.request.headers.get('accept-language') ?? 'en';
  const res = await fetch(
    `https://.../recommendations?user=${userId}&lang=${lang}`
  );
  return (await res.json()) as Product[];
});

Composing loaders with resolveValue

A loader can read another loader's result via requestEvent.resolveValue(otherLoader):

src/routes/user/[userId]/index.tsx
export const useUser = routeLoader$(async ({ params }) =>
  fetchUser(params.userId)
);
 
export const useUserPosts = routeLoader$(async ({ resolveValue }) => {
  const user = await resolveValue(useUser);
  return fetchPostsFor(user.id);
});

resolveValue runs the dependency loader once per request and caches the result, so reading the same loader from multiple places doesn't duplicate work.

NOTEresolveValue only resolves other routeLoader$s — actions are not visible to it. See Loaders cannot read action state below.

Loaders cannot read action state

requestEvent.resolveValue(actionQrl) always returns undefined inside a loader, regardless of how the loader was triggered (initial render, navigation, post-action invalidation, polling).

The reason is the same as everywhere else on this page: a loader's output must be a pure function of the URL. Action results are transient — they only exist on the request that handled the submission, and the loader refetch that follows an action is a separate GET with no action context. Letting loaders see action state would silently produce different data on the inline-render path (no JS) than on the JSON refetch path (SPA), so the API does not expose action state to loaders at all. The same rule applies to anything else valid for one request — request bodies, one-shot tokens, Set-Cookie values written by middleware on this request.

Read action state directly from the action signal where you render.

src/routes/[id]/index.tsx
export const useNameAction = routeAction$(async (data) => data);
 
// ❌ `form` is always undefined, regardless of whether an action just ran.
export const useGreetingBroken = routeLoader$(async ({ resolveValue, params }) => {
  const form = await resolveValue(useNameAction);
  return { name: form?.name ?? params.id };
});
 
// ✅ Loader is a pure function of the URL; component blends in transient action state.
export const useGreeting = routeLoader$(({ params }) => ({ name: params.id }));
 
export default component$(() => {
  const greeting = useGreeting();
  const action = useNameAction();
  const name = action.value?.name ?? greeting.value.name;
  return (
    <Form action={action}>
      <p>Hello, {name}!</p>
      <input name="name" />
      <button type="submit">Update</button>
    </Form>
  );
});

What did people use action-state-in-loaders for?

Use caseWorkaround
Echo the just-submitted form value back to the pageLoader returns the URL-derived value; the component overlays action.value on top, e.g. action.value?.name ?? loader.value.name.
Recompute derived data from the submissionPut the inputs in the URL (search params, or goto() after the action) so the loader reads them like any other route input.
Refresh authoritative data after a mutationThe action writes to your store (DB, KV, etc.); the loader reads from that store. The action's invalidate list re-runs the loader and it picks up the new state — no action result needed.
Show success / error feedbackRead the action signal directly: action.value, action.isRunning, action.value?.failed. Ephemeral UI feedback doesn't belong in a loader.

Options reference

OptionDefaultEffect
expires0 (static)Time-to-live in ms. Sets Cache-Control and the AsyncSignal expiry. 0 means never refetched.
pollfalseAuto-refetch when expired, but only while components are reading the loader.
allowStaletrueShow stale data while refetching. false blocks readers until fresh data arrives.
serializationStrategy'never''never' / 'always' / 'auto' — embed loader data in the SSR HTML?
eTagtrue (auto-hash), string, or (ev) => string | null. Returns 304 on match.
search[] *Allowlist of search params the loader depends on. (default [] when strictLoaders is enabled)
validationArray of DataValidators applied before the loader runs.

The Vite plugin qwikRouter() exposes app-wide defaults via defaultLoadersSerializationStrategy and strictLoaders.

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • manucorporat
  • mhevery
  • wtlin1228
  • AnthonyPAlicea
  • the-r3aper7
  • hamatoyogi
  • steve8708
  • iamyuu
  • n8sabes
  • mrhoodz
  • mjschwanitz
  • adamdbradley
  • gioboa
  • Varixo
  • wmertens