Skip to content
All essays
WebMarch 27, 202613 min

Qwik City Data Fetching and Server-Side Rendering

Master data fetching in Qwik City with loaders, server-side rendering, streaming, and caching strategies for optimal performance.

Ü
Ümit Uz
Mobile & Full Stack Developer

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

typescript
// 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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
interface Product {
  id: string;
  name: string;
  price: number;
}

export const useProducts = loader$(async (): Promise<Product[]> => {
  return db.product.findMany();
});

2. Use Specific Select Queries

typescript
// 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

typescript
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

typescript
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:

  1. 1Loaders run on the server before rendering
  2. 2Full access to request context and environment
  3. 3Built-in caching and HTTP optimization
  4. 4Type-safe data handling
  5. 5Parallel data loading support
  6. 6Comprehensive error handling
  7. 7Real-time data capabilities

Master these patterns to build highly performant, data-rich applications with Qwik City.

Enjoyed this essay? Share it.

Related essays

Next essay
Qwik City Routing and Layouts: Building Nested Applications