Qwik City provides a sophisticated data fetching system that leverages server-side rendering and resumability for optimal performance. This comprehensive guide covers all aspects of data fetching in Qwik City applications.
Understanding Loaders
Loaders are server-side functions that run before a route renders, perfect for fetching data.
Basic Loader
// src/routes/products/index.tsx
import { loader$ } from '@builder.io/qwik-city';
import { component$ } from '@builder.io/qwik';
export const useProducts = loader$(async ({ env, url }) => {
const products = await db.product.findMany();
return products;
});
export default component$(() => {
const products = useProducts();
return (
<div>
<h1>Products</h1>
<ul>
{products.value.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
});Loader Context
Loaders receive context about the request.
Accessing Request Data
export const useUserLoader = loader$(async ({ env, cookie, url, request }) => {
// Get query parameters
const search = url.searchParams.get('search');
// Access cookies
const sessionId = cookie.get('session')?.value;
// Read headers
const userAgent = request.headers.get('user-agent');
// Access environment variables
const apiKey = env.get('API_KEY');
return { search, sessionId, userAgent };
});Data Caching
Implement caching strategies to improve performance.
HTTP Caching
export const useProducts = loader$(async () => {
const products = await db.product.findMany();
// Cache for 5 minutes
return json(products, {
headers: {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
},
});
});Custom Cache Keys
export const useProduct = loader$(async ({ params }) => {
const product = await db.product.findUnique({
where: { id: params.id },
});
return json(product, {
headers: {
'Cache-Control': 'public, max-age=3600',
'Cache-Tag': `product-${product.id}`,
},
});
});Parallel Data Loading
Load multiple data sources in parallel.
Multiple Loaders
export const useDashboard = loader$(async ({ params }) => {
const [user, orders, analytics] = await Promise.all([
db.user.findUnique({ where: { id: params.userId } }),
db.order.findMany({ where: { userId: params.userId } }),
getAnalytics(params.userId),
]);
return { user, orders, analytics };
});Streaming Data
Implement streaming for large datasets.
Progressive Loading
export const useProducts = loader$(async () => {
const products = db.product.findMany();
const stream = new ReadableStream({
async start(controller) {
for await (const product of products) {
controller.enqueue(`data: ${JSON.stringify(product)}\n\n`);
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
});Error Handling
Handle errors gracefully in loaders.
Try-Catch Pattern
export const useProduct = loader$(async ({ params, fail }) => {
try {
const product = await db.product.findUnique({
where: { id: params.id },
});
if (!product) {
throw fail(404, 'Product not found');
}
return product;
} catch (error) {
if (error instanceof Error) {
throw fail(500, error.message);
}
throw fail(500, 'An error occurred');
}
});Pagination
Implement efficient pagination.
Offset-Based Pagination
export const useProducts = loader$(async ({ url }) => {
const page = parseInt(url.searchParams.get('page') || '1');
const limit = 20;
const skip = (page - 1) * limit;
const [products, total] = await Promise.all([
db.product.findMany({ skip, take: limit }),
db.product.count(),
]);
return {
products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
});Cursor-Based Pagination
export const useProducts = loader$(async ({ url }) => {
const cursor = url.searchParams.get('cursor');
const limit = 20;
const products = await db.product.findMany({
take: limit + 1,
...(cursor && {
skip: 1,
cursor: { id: cursor },
}),
});
const hasMore = products.length > limit;
const items = hasMore ? products.slice(0, -1) : products;
return {
items,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
});Filtering and Sorting
Implement dynamic filtering and sorting.
Query Parameters
export const useProducts = loader$(async ({ url }) => {
const search = url.searchParams.get('search') || '';
const category = url.searchParams.get('category');
const sort = url.searchParams.get('sort') || 'name';
const order = url.searchParams.get('order') || 'asc';
const products = await db.product.findMany({
where: {
...(search && {
name: { contains: search, mode: 'insensitive' },
}),
...(category && { category }),
},
orderBy: { [sort]: order },
});
return products;
});Authentication and Authorization
Secure your loaders with authentication.
Protected Routes
export const useUserProfile = loader$(async ({ cookie, fail }) => {
const sessionToken = cookie.get('session')?.value;
if (!sessionToken) {
throw fail(401, 'Unauthorized');
}
const session = await db.session.findUnique({
where: { token: sessionToken },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
throw fail(401, 'Session expired');
}
return session.user;
});Optimistic UI Updates
Update UI immediately while server processes.
Client-Side Optimistic Updates
export const useLikePost = action$(async ({ postId }) => {
await db.postLike.create({ data: { postId } });
return { liked: true };
});
export default component$(() => {
const action = useLikePost();
const likes = useSignal(0);
return (
<button
onClick$={() => {
likes.value++;
action.submit({ postId: '123' });
}}
>
❤️ {likes.value}
</button>
);
});Prefetching Data
Prefetch data for better UX.
Link Prefetching
import { Link } from '@builder.io/qwik-city';
export default component$(() => {
return (
<Link href="/dashboard" prefetch="true">
Dashboard
</Link>
);
});Real-Time Data
Implement real-time data updates.
Server-Sent Events
export const usePrices = loader$(async () => {
const stream = new ReadableStream({
async start(controller) {
const interval = setInterval(async () => {
const prices = await getCurrentPrices();
controller.enqueue(`data: ${JSON.stringify(prices)}\n\n`);
}, 1000);
// Cleanup on connection close
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
});Data Transformation
Transform data before sending to client.
Selective Fields
export const useUsers = loader$(async () => {
const users = await db.user.findMany({
select: {
id: true,
name: true,
avatar: true,
// Don't include sensitive fields
email: false,
password: false,
},
});
return users;
});Data Formatting
export const useProducts = loader$(async () => {
const products = await db.product.findMany();
return products.map(product => ({
...product,
formattedPrice: new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(product.price),
relativeDate: formatDistanceToNow(product.createdAt),
}));
});Nested Data Loading
Handle complex data relationships.
Eager Loading
export const useOrders = loader$(async ({ params }) => {
const orders = await db.order.findMany({
where: { userId: params.userId },
include: {
items: {
include: {
product: true,
},
},
shipping: true,
payment: true,
},
});
return orders;
});Best Practices
1. Always Type Your Data
interface Product {
id: string;
name: string;
price: number;
}
export const useProducts = loader$(async (): Promise<Product[]> => {
return db.product.findMany();
});2. Use Specific Select Queries
// Good: Select only needed fields
const user = await db.user.findUnique({
select: { id: true, name: true },
});
// Bad: Fetch everything
const user = await db.user.findUnique();3. Implement Proper Caching
export const useProduct = loader$(async ({ params }) => {
const product = await db.product.findUnique({
where: { id: params.id },
});
return json(product, {
headers: {
'Cache-Control': 'public, max-age=3600',
'CDN-Cache-Control': 'public, max-age=86400',
},
});
});4. Handle Errors Gracefully
export const useProduct = loader$(async ({ params, fail }) => {
try {
const product = await db.product.findUnique({
where: { id: params.id },
});
if (!product) {
throw fail(404, 'Product not found');
}
return product;
} catch (error) {
throw fail(500, 'Internal server error');
}
});Conclusion
Qwik City's data fetching system provides a powerful, flexible way to load and manage data while maintaining excellent performance through server-side rendering and resumability. The combination of loaders, caching, and streaming makes it easy to build fast, data-driven applications.
Key takeaways:
- 1Loaders run on the server before rendering
- 2Full access to request context and environment
- 3Built-in caching and HTTP optimization
- 4Type-safe data handling
- 5Parallel data loading support
- 6Comprehensive error handling
- 7Real-time data capabilities
Master these patterns to build highly performant, data-rich applications with Qwik City.