Skip to main content
Advertisement

19.4 Qwik City

QwikCity is the official meta-framework built on top of Qwik. Just as Next.js is the meta-framework for React, QwikCity adds file-based routing, SSR, API routes, middleware, and more to Qwik.


What Is QwikCity?

QwikCity provides everything needed to build a full-stack web application with Qwik.

Qwik       = reactive UI framework (components, Signals, serialization)
QwikCity = Qwik + routing + SSR + API + middleware + optimization

What QwikCity Provides

1. File-based routing (src/routes/)
2. Nested layout system
3. routeLoader$ (server-side data loading)
4. routeAction$ (form handling / mutations)
5. API endpoints (REST, RPC)
6. Middleware (auth, logging, etc.)
7. server$ (server-only functions)
8. Link component (with prefetching)
9. Head management (SEO, Open Graph)
10. Service worker-based prefetching

File-Based Routing

The src/routes/ Structure

src/routes/
├── index.tsx → /
├── layout.tsx → root layout
├── about/
│ └── index.tsx → /about
├── blog/
│ ├── index.tsx → /blog (list)
│ ├── layout.tsx → /blog/* layout
│ └── [slug]/
│ └── index.tsx → /blog/:slug (detail)
├── products/
│ ├── index.tsx → /products
│ └── [id]/
│ ├── index.tsx → /products/:id
│ └── reviews/
│ └── index.tsx → /products/:id/reviews
└── [...catchAll]/
└── index.tsx → all unmatched routes

Basic Route Component

// src/routes/index.tsx
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';

export default component$(() => {
return (
<main>
<h1>Home Page</h1>
</main>
);
});

// page metadata
export const head: DocumentHead = {
title: 'Home - My App',
meta: [
{ name: 'description', content: 'Home page description' },
{ property: 'og:title', content: 'Home - My App' },
{ property: 'og:description', content: 'Home page description' },
{ property: 'og:image', content: 'https://myapp.com/og-image.jpg' },
],
links: [
{ rel: 'canonical', href: 'https://myapp.com' },
],
};

Special Filename Rules

index.tsx        → default page component (export default)
layout.tsx → layout component (contains <Slot />)
plugin.ts → plugin middleware
service-worker.ts → service worker
middleware.ts → middleware (root level only)

Dynamic Routes

[param] — Single Parameter

// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import { useLocation, routeLoader$ } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async ({ params, status }) => {
const { slug } = params; // extract [slug] parameter from URL

const res = await fetch(`https://api.example.com/posts/${slug}`);

if (!res.ok) {
status(404); // set HTTP status code
return null;
}

return res.json();
});

export default component$(() => {
const post = usePost();
const location = useLocation();

if (!post.value) {
return <h1>Post not found</h1>;
}

return (
<article>
<h1>{post.value.title}</h1>
<p>Slug: {location.params.slug}</p>
<div dangerouslySetInnerHTML={post.value.content} />
</article>
);
});

[...catchAll] — Catch-All Route

// src/routes/[...path]/index.tsx
import { component$ } from '@builder.io/qwik';
import { useLocation } from '@builder.io/qwik-city';

export default component$(() => {
const location = useLocation();

return (
<div>
<h1>404 - Page Not Found</h1>
<p>Requested path: {location.url.pathname}</p>
<a href="/">Back to home</a>
</div>
);
});

Nested Dynamic Routes

// src/routes/shop/[category]/[productId]/index.tsx
// URL: /shop/electronics/phone-123

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

export const useProduct = routeLoader$(async ({ params }) => {
const { category, productId } = params;
// category = 'electronics', productId = 'phone-123'

return fetchProduct(category, productId);
});

export default component$(() => {
const product = useProduct();
return <div>{product.value?.name}</div>;
});

Layouts

Root Layout

// src/routes/layout.tsx
import { component$, Slot } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

// routeLoader$ can also be used in layouts
export const useCurrentUser = routeLoader$(async ({ cookie }) => {
const token = cookie.get('auth_token');
if (!token) return null;

const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token.value}` }
});
if (!res.ok) return null;
return res.json();
});

export default component$(() => {
const user = useCurrentUser();

return (
<>
<header class="site-header">
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/products">Products</a>
{user.value
? <a href="/dashboard">{user.value.name}</a>
: <a href="/login">Login</a>
}
</nav>
</header>

<main>
<Slot /> {/* child route component renders here */}
</main>

<footer>
<p>© 2024 My App</p>
</footer>
</>
);
});

Nested Layouts

// src/routes/blog/layout.tsx — applies to all /blog/* routes
import { component$, Slot } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

export const useBlogCategories = routeLoader$(async () => {
const res = await fetch('https://api.example.com/categories');
return res.json();
});

export default component$(() => {
const categories = useBlogCategories();

return (
<div class="blog-layout">
<aside class="sidebar">
<h3>Categories</h3>
<ul>
{categories.value.map((cat: { id: number; name: string; slug: string }) => (
<li key={cat.id}>
<a href={`/blog/category/${cat.slug}`}>{cat.name}</a>
</li>
))}
</ul>
</aside>

<div class="content">
<Slot /> {/* blog page content */}
</div>
</div>
);
});

Named Slots (Named Outlets)

// define named slots in the layout
export default component$(() => {
return (
<div class="page">
{/* default unnamed slot */}
<main>
<Slot />
</main>

{/* named slot */}
<aside>
<Slot name="sidebar" />
</aside>

<div class="actions">
<Slot name="actions" />
</div>
</div>
);
});

// fill named slots from a child component
export const Page = component$(() => {
return (
<>
{/* default slot */}
<h1>Main content</h1>

{/* fill the sidebar slot */}
<div q:slot="sidebar">
<p>Sidebar content</p>
</div>

{/* fill the actions slot */}
<div q:slot="actions">
<button>Save</button>
<button>Cancel</button>
</div>
</>
);
});

routeLoader$ — Server-Side Data Loading

Basic Usage

// src/routes/products/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import type { DocumentHead } from '@builder.io/qwik-city';

interface Product {
id: number;
name: string;
price: number;
stock: number;
category: string;
}

// routeLoader$: runs on the server before the page renders
export const useProducts = routeLoader$(async ({ query, env }) => {
const page = Number(query.get('page') || '1');
const category = query.get('category') || '';
const apiKey = env.get('API_KEY'); // access server environment variables

const url = new URL('https://api.example.com/products');
url.searchParams.set('page', String(page));
url.searchParams.set('limit', '10');
if (category) url.searchParams.set('category', category);

const res = await fetch(url.toString(), {
headers: { 'X-API-Key': apiKey }
});

if (!res.ok) throw new Error('Failed to fetch product list');

return {
products: await res.json() as Product[],
currentPage: page,
totalPages: Number(res.headers.get('X-Total-Pages') || '1'),
};
});

export default component$(() => {
const data = useProducts(); // data loaded on the server

return (
<div>
<h1>Product List</h1>

<div class="product-grid">
{data.value.products.map(product => (
<div key={product.id} class="product-card">
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
<p>Stock: {product.stock}</p>
<a href={`/products/${product.id}`}>View details</a>
</div>
))}
</div>

<div class="pagination">
{data.value.currentPage > 1 && (
<a href={`?page=${data.value.currentPage - 1}`}>Previous</a>
)}
<span>{data.value.currentPage} / {data.value.totalPages}</span>
{data.value.currentPage < data.value.totalPages && (
<a href={`?page=${data.value.currentPage + 1}`}>Next</a>
)}
</div>
</div>
);
});

export const head: DocumentHead = ({ resolveValue }) => {
const data = resolveValue(useProducts);
return {
title: `Products (${data.products.length} items)`,
};
};

The routeLoader$ Context Object

export const useData = routeLoader$(async ({
params, // URL parameters { id: '123' }
query, // query string URLSearchParams
request, // Request object (headers, method, etc.)
cookie, // read/write cookies
env, // environment variables (instead of process.env)
platform, // platform-specific features (Cloudflare, etc.)
status, // set HTTP status code
redirect, // redirect function
error, // error response function
headers, // set response headers
locale, // current locale
sharedMap, // Map for sharing data between loaders
}) => {
// example: check auth and redirect
const token = cookie.get('auth_token');
if (!token) {
throw redirect(302, '/login');
}

// example: validate parameters
const id = Number(params.id);
if (isNaN(id)) {
throw error(400, 'Invalid ID format');
}

return { data: 'hello' };
});

Combining Multiple routeLoaders

// multiple loaders can be used on one page
export const useUser = routeLoader$(async ({ params }) => {
return fetchUser(params.id);
});

export const usePosts = routeLoader$(async ({ params }) => {
return fetchUserPosts(params.id);
});

export const useFollowers = routeLoader$(async ({ params }) => {
return fetchFollowers(params.id);
});

// all loaders run in parallel (no waterfall)
export default component$(() => {
const user = useUser();
const posts = usePosts();
const followers = useFollowers();

return (
<div>
<h1>{user.value.name}</h1>
<p>{posts.value.length} posts</p>
<p>{followers.value.length} followers</p>
</div>
);
});

routeAction$ — Form Handling

Basic Form Handling

// src/routes/contact/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city';

// define validation schema with zod$
export const useContactAction = routeAction$(
async (data, { redirect, env }) => {
// runs on the server only
const { name, email, message } = data;

// send email (server logic)
await sendEmail({
to: env.get('CONTACT_EMAIL'),
from: email,
subject: `Inquiry from: ${name}`,
body: message,
});

// redirect on success
throw redirect(302, '/contact/success');
},
// automatic validation with Zod schema
zod$({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Enter a valid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
);

export default component$(() => {
const action = useContactAction();

return (
<div>
<h1>Contact Us</h1>

{/* Form component: handles Progressive Enhancement automatically */}
<Form action={action}>
<div>
<label>Name</label>
<input type="text" name="name" />
{/* show validation errors */}
{action.value?.fieldErrors?.name && (
<span class="error">{action.value.fieldErrors.name}</span>
)}
</div>

<div>
<label>Email</label>
<input type="email" name="email" />
{action.value?.fieldErrors?.email && (
<span class="error">{action.value.fieldErrors.email}</span>
)}
</div>

<div>
<label>Message</label>
<textarea name="message" rows={5} />
{action.value?.fieldErrors?.message && (
<span class="error">{action.value.fieldErrors.message}</span>
)}
</div>

<button type="submit" disabled={action.isRunning}>
{action.isRunning ? 'Sending...' : 'Send message'}
</button>
</Form>
</div>
);
});

Action State Management

export const useCreatePost = routeAction$(
async (data) => {
const post = await createPost(data);
return { success: true, post }; // returned value is stored in action.value
},
zod$({
title: z.string().min(1),
content: z.string().min(1),
})
);

export default component$(() => {
const createPost = useCreatePost();

return (
<div>
<Form action={createPost}>
<input type="text" name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button type="submit">Publish</button>
</Form>

{/* action.value: server return value */}
{createPost.value?.success && (
<p>Post created! ID: {createPost.value.post.id}</p>
)}

{/* action.isRunning: in-progress state */}
{createPost.isRunning && <p>Processing...</p>}

{/* action.value?.fieldErrors: validation errors */}
{createPost.value?.fieldErrors && (
<pre>{JSON.stringify(createPost.value.fieldErrors, null, 2)}</pre>
)}
</div>
);
});

globalAction$ — Shared Actions Across Pages

// src/actions/auth.ts
import { globalAction$, zod$, z } from '@builder.io/qwik-city';

export const useLoginAction = globalAction$(
async (data, { cookie, redirect }) => {
const { email, password } = data;

const user = await authenticateUser(email, password);
if (!user) {
return { error: 'Incorrect email or password' };
}

const token = generateJWT(user.id);
cookie.set('auth_token', token, {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});

throw redirect(302, '/dashboard');
},
zod$({
email: z.string().email(),
password: z.string().min(8),
})
);

// usable in any component
export const LoginForm = component$(() => {
const login = useLoginAction();

return (
<Form action={login}>
<input type="email" name="email" />
<input type="password" name="password" />
{login.value?.error && <p class="error">{login.value.error}</p>}
<button type="submit">Login</button>
</Form>
);
});

API Routes — server$ Functions and REST Endpoints

server$ Functions (RPC Style)

// server$ is called from the client but executes on the server
import { component$, useSignal } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';

// define a server-only function
const getWeather = server$(async function(city: string) {
// this code is never exposed to the client
const API_KEY = process.env.WEATHER_API_KEY;
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}&units=metric`
);
const data = await res.json();
return {
temp: data.main.temp,
description: data.weather[0].description,
humidity: data.main.humidity,
};
});

export const WeatherWidget = component$(() => {
const city = useSignal('New York');
const weather = useSignal<any>(null);
const loading = useSignal(false);

return (
<div>
<input
value={city.value}
onInput$={(e) => city.value = (e.target as HTMLInputElement).value}
/>
<button onClick$={async () => {
loading.value = true;
try {
// called from client → executes on server
weather.value = await getWeather(city.value);
} finally {
loading.value = false;
}
}}>
Get weather
</button>

{loading.value && <p>Loading...</p>}
{weather.value && (
<div>
<p>Temperature: {weather.value.temp}°C</p>
<p>Conditions: {weather.value.description}</p>
<p>Humidity: {weather.value.humidity}%</p>
</div>
)}
</div>
);
});

REST API Endpoints (GET/POST)

// src/routes/api/posts/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';

// GET /api/posts
export const onGet: RequestHandler = async ({ json, query }) => {
const page = Number(query.get('page') || '1');
const posts = await fetchPosts(page);
json(200, { posts, page });
};

// POST /api/posts
export const onPost: RequestHandler = async ({ request, json, cookie }) => {
// check authentication
const token = cookie.get('auth_token');
if (!token) {
json(401, { error: 'Authentication required' });
return;
}

const body = await request.json();
const { title, content } = body;

if (!title || !content) {
json(400, { error: 'Title and content are required' });
return;
}

const post = await createPost({ title, content });
json(201, { post });
};
// src/routes/api/posts/[id]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';

// GET /api/posts/:id
export const onGet: RequestHandler = async ({ params, json, status }) => {
const post = await getPost(Number(params.id));

if (!post) {
status(404);
json(404, { error: 'Post not found' });
return;
}

json(200, post);
};

// PUT /api/posts/:id
export const onPut: RequestHandler = async ({ params, request, json }) => {
const body = await request.json();
const updated = await updatePost(Number(params.id), body);
json(200, updated);
};

// DELETE /api/posts/:id
export const onDelete: RequestHandler = async ({ params, json }) => {
await deletePost(Number(params.id));
json(200, { success: true });
};

Common Response Headers and CORS

// src/routes/api/layout.ts (shared API route middleware)
import type { RequestHandler } from '@builder.io/qwik-city';

export const onRequest: RequestHandler = async ({ headers, next }) => {
// set CORS headers
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');

// default Content-Type
headers.set('Content-Type', 'application/json');

await next();
};

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

export const Navigation = component$(() => {
return (
<nav>
{/* Link automatically prefetches via service worker */}
<Link href="/">Home</Link>

{/* control prefetching */}
<Link href="/about" prefetch={false}>About (no prefetch)</Link>

{/* reload: full page refresh */}
<Link href="/dashboard" reload>Dashboard</Link>
</nav>
);
});

Service Worker-Based Prefetching

// src/routes/service-worker.ts
// QwikCity service worker: pre-downloads JS chunks for links visible in the viewport
import { setupServiceWorker } from '@builder.io/qwik-city/service-worker';

setupServiceWorker();

// push and sync events can be added via addEventListener
self.addEventListener('push', (event) => {
// handle push notifications
});

How Prefetching Works

1. User visits a page
2. Service worker is installed
3. Link components enter the viewport (IntersectionObserver)
4. JS chunks needed by the destination page are downloaded in the background
5. When the user clicks a link, JS is already cached and loads instantly

Middleware (middleware.ts)

Root Middleware

// src/middleware.ts (can only be used at the root level)
import type { RequestHandler } from '@builder.io/qwik-city';

export const onRequest: RequestHandler = async ({
request,
next,
redirect,
cookie,
url,
}) => {
// request logging
const start = Date.now();
console.log(`[${request.method}] ${url.pathname}`);

// authentication check
const publicPaths = ['/', '/login', '/register', '/about'];
if (!publicPaths.some(p => url.pathname.startsWith(p))) {
const token = cookie.get('auth_token');
if (!token) {
throw redirect(302, `/login?redirect=${url.pathname}`);
}
}

// continue processing the request
await next();

// post-response handling
const duration = Date.now() - start;
console.log(`[${request.method}] ${url.pathname} - ${duration}ms`);
};

Per-Route Middleware (plugin.ts)

// src/routes/admin/plugin.ts (applies only to admin/* routes)
import type { RequestHandler } from '@builder.io/qwik-city';

export const onRequest: RequestHandler = async ({ cookie, redirect, url }) => {
const token = cookie.get('auth_token');

if (!token) {
throw redirect(302, '/login');
}

// JWT verification
const user = verifyJWT(token.value);
if (!user || user.role !== 'admin') {
throw redirect(302, '/unauthorized');
}
};

Practical Example: Blog System

Blog List Page

// src/routes/blog/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { Link } from '@builder.io/qwik-city';

interface BlogPost {
id: number;
slug: string;
title: string;
excerpt: string;
publishedAt: string;
author: { name: string; avatar: string };
tags: string[];
readingTime: number;
}

export const useBlogPosts = routeLoader$(async ({ query }) => {
const tag = query.get('tag') || '';
const page = Number(query.get('page') || '1');

const posts: BlogPost[] = await fetchBlogPosts({ tag, page, limit: 10 });
const total = await countBlogPosts({ tag });

return {
posts,
total,
page,
totalPages: Math.ceil(total / 10),
currentTag: tag,
};
});

export default component$(() => {
const data = useBlogPosts();

return (
<div class="blog-list">
<h1>Blog</h1>

{data.value.currentTag && (
<div class="tag-filter">
Tag: <strong>{data.value.currentTag}</strong>
<a href="/blog">View all</a>
</div>
)}

<div class="posts">
{data.value.posts.map(post => (
<article key={post.id} class="post-card">
<div class="post-header">
<h2>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<div class="meta">
<img src={post.author.avatar} alt={post.author.name} width={24} height={24} />
<span>{post.author.name}</span>
<time>{new Date(post.publishedAt).toLocaleDateString('en-US')}</time>
<span>{post.readingTime} min read</span>
</div>
</div>
<p class="excerpt">{post.excerpt}</p>
<div class="tags">
{post.tags.map(tag => (
<Link key={tag} href={`/blog?tag=${tag}`} class="tag">
#{tag}
</Link>
))}
</div>
</article>
))}
</div>

<div class="pagination">
{data.value.page > 1 && (
<Link href={`/blog?page=${data.value.page - 1}${data.value.currentTag ? `&tag=${data.value.currentTag}` : ''}`}>
Previous
</Link>
)}
<span>Page {data.value.page} / {data.value.totalPages}</span>
{data.value.page < data.value.totalPages && (
<Link href={`/blog?page=${data.value.page + 1}${data.value.currentTag ? `&tag=${data.value.currentTag}` : ''}`}>
Next
</Link>
)}
</div>
</div>
);
});

Blog Detail Page with Comments

// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import {
routeLoader$,
routeAction$,
Form,
Link,
zod$,
z
} from '@builder.io/qwik-city';
import type { DocumentHead } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async ({ params, status, error }) => {
const post = await fetchPostBySlug(params.slug);

if (!post) {
throw error(404, 'Post not found');
}

// increment view count (async, don't wait for response)
incrementViews(post.id).catch(console.error);

return post;
});

export const useComments = routeLoader$(async ({ params }) => {
return fetchComments(params.slug);
});

export const useAddComment = routeAction$(
async (data, { params, cookie }) => {
const userId = getUserFromCookie(cookie);

const comment = await createComment({
postSlug: params.slug,
userId,
content: data.content,
});

return { comment };
},
zod$({
content: z.string().min(5, 'Comment must be at least 5 characters'),
})
);

export default component$(() => {
const post = usePost();
const comments = useComments();
const addComment = useAddComment();

return (
<article class="blog-post">
<header>
<h1>{post.value.title}</h1>
<div class="meta">
<time>{new Date(post.value.publishedAt).toLocaleDateString('en-US')}</time>
<span>{post.value.author.name}</span>
<span>{post.value.readingTime} min read</span>
</div>
<div class="tags">
{post.value.tags.map(tag => (
<Link key={tag} href={`/blog?tag=${tag}`} class="tag">#{tag}</Link>
))}
</div>
</header>

<div class="content" dangerouslySetInnerHTML={post.value.htmlContent} />

<section class="comments">
<h2>Comments ({comments.value.length})</h2>

<Form action={addComment} class="comment-form">
<textarea
name="content"
placeholder="Write a comment..."
rows={3}
/>
{addComment.value?.fieldErrors?.content && (
<span class="error">{addComment.value.fieldErrors.content}</span>
)}
<button type="submit" disabled={addComment.isRunning}>
{addComment.isRunning ? 'Submitting...' : 'Post comment'}
</button>
</Form>

<div class="comment-list">
{comments.value.map((comment: any) => (
<div key={comment.id} class="comment">
<div class="comment-header">
<strong>{comment.author.name}</strong>
<time>{new Date(comment.createdAt).toLocaleString('en-US')}</time>
</div>
<p>{comment.content}</p>
</div>
))}
</div>
</section>
</article>
);
});

export const head: DocumentHead = ({ resolveValue }) => {
const post = resolveValue(usePost);
return {
title: post.title,
meta: [
{ name: 'description', content: post.excerpt },
{ property: 'og:title', content: post.title },
{ property: 'og:description', content: post.excerpt },
{ property: 'og:image', content: post.coverImage },
],
};
};

Pro Tips

Tip 1: Isolating Server Code Completely

// server$ isolates DB queries — never exposed to the client
import { server$ } from '@builder.io/qwik-city';
import { db } from '~/server/db'; // server-only module

export const queryUsers = server$(async function(searchTerm: string) {
// this code is placed in the server bundle only at build time
// it is never included in the client bundle
const users = await db.query(
'SELECT * FROM users WHERE name LIKE ?',
[`%${searchTerm}%`]
);
return users;
});

Tip 2: Conditional Rendering Optimization

// conditional rendering optimization
export const Conditional = component$(() => {
const show = useSignal(false);

return (
<div>
<button onClick$={() => show.value = !show.value}>
Toggle
</button>

{/* Option 1: standard conditional rendering */}
{show.value && <HeavyComponent />}

{/* Option 2: keep in DOM, hide with visibility
(no flash but JS always loads) */}
<div style={{ display: show.value ? 'block' : 'none' }}>
<AnotherComponent />
</div>
</div>
);
});

Tip 3: Global State Management Pattern

// src/context/app-context.ts
import { createContextId } from '@builder.io/qwik';

export interface AppContext {
user: { id: string; name: string } | null;
theme: 'light' | 'dark';
notifications: number;
}

export const AppContextId = createContextId<AppContext>('app-context');

// set up Provider in the root layout
export const RootLayout = component$(() => {
const ctx = useStore<AppContext>({
user: null,
theme: 'light',
notifications: 0,
});

useContextProvider(AppContextId, ctx);

return <Slot />;
});

// usable in any child component
export const UserBadge = component$(() => {
const ctx = useContext(AppContextId);

return (
<div>
{ctx.user?.name || 'Login required'}
{ctx.notifications > 0 && (
<span class="badge">{ctx.notifications}</span>
)}
</div>
);
});

Tip 4: Error Boundaries

// src/components/error-boundary/error-boundary.tsx
// Qwik handles error boundaries at the route level
// src/routes/[...path]/index.tsx for 404 handling
// src/routes/error/index.tsx for global error handling

// custom error handling component
export const SafeComponent = component$<{ fallback?: string }>(({ fallback = 'An error occurred' }) => {
const error = useSignal<string | null>(null);

useVisibleTask$(({ cleanup }) => {
const handler = (e: ErrorEvent) => {
error.value = e.message;
};
window.addEventListener('error', handler);
cleanup(() => window.removeEventListener('error', handler));
});

if (error.value) {
return <div class="error-state">{fallback}: {error.value}</div>;
}

return <Slot />;
});

Tip 5: Debugging Tools

// dev environment debug utility
export const DebugSignal = component$<{ label: string; signal: Signal<any> }>(
({ label, signal }) => {
if (!import.meta.env.DEV) return null;

return (
<div style={{
position: 'fixed', bottom: 0, right: 0,
background: 'black', color: 'lime',
padding: '4px 8px', fontSize: '12px'
}}>
{label}: {JSON.stringify(signal.value)}
</div>
);
}
);

// usage
<DebugSignal label="count" signal={count} />

Tip 6: sharedMap for Data Sharing Between Loaders

// sharing data across multiple loaders
export const useAuth = routeLoader$(async ({ cookie, sharedMap }) => {
const token = cookie.get('auth_token');
if (!token) return null;

const user = await verifyToken(token.value);

// store in sharedMap: can be reused by other loaders
sharedMap.set('currentUser', user);
return user;
});

export const useDashboard = routeLoader$(async ({ sharedMap }) => {
// assumes useAuth has already run
const user = sharedMap.get('currentUser');
if (!user) return null;

// no need to fetch the same user again
return fetchDashboardData(user.id);
});

Tip 7: URL-Based State Management

// use URL query parameters as state
export default component$(() => {
const location = useLocation();
const navigate = useNavigate();

// read state from URL
const filter = location.url.searchParams.get('filter') || 'all';
const sort = location.url.searchParams.get('sort') || 'newest';

const updateFilter = $((newFilter: string) => {
const url = new URL(location.url.toString());
url.searchParams.set('filter', newFilter);
navigate(url.toString());
});

return (
<div>
<select
value={filter}
onChange$={(e) => updateFilter((e.target as HTMLSelectElement).value)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="done">Completed</option>
</select>
</div>
);
});

Summary

FeatureFile/FunctionDescription
Pagesrc/routes/*/index.tsxdefault export is the page component
Layoutsrc/routes/*/layout.tsxrenders children via <Slot />
Dynamic routesrc/routes/[param]/URL parameters
Catch-allsrc/routes/[...path]/matches all sub-paths
Server loaderrouteLoader$loads data on the server before page render
Form actionrouteAction$form handling / server mutations
REST APIonGet/onPost/...handlers per HTTP method
Server functionserver$server function called from the client
MiddlewareonRequest / plugin.tsintercept requests
PrefetchingLink componentautomatic service worker-based prefetching

QwikCity is a powerful tool for building full-stack apps entirely through the file system. The next chapter covers advanced pro tips and deployment strategies.

Advertisement