Server-Side Rendering with Qwik: SSR, SSG, ISR
Qwik is a modern web framework built around the idea of resumability — the ability to resume execution on the client without re-executing the server-side logic. This fundamentally changes how rendering strategies work compared to traditional frameworks like React or Next.js. In this tutorial, we'll explore the three primary rendering strategies in Qwik: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR).
What Is Rendering in Qwik?
Rendering refers to where and when the HTML for a page is produced. Qwik, through its meta-framework Qwik City, supports multiple rendering modes. Unlike traditional frameworks that hydrate the entire application on the client, Qwik serializes the application state into the HTML and lazily downloads only the JavaScript needed when the user interacts. This means SSR, SSG, and ISR all benefit from Qwik's near-zero JavaScript overhead on initial load.
The Three Strategies
- SSR (Server-Side Rendering): HTML is generated on every request on the server.
- SSG (Static Site Generation): HTML is generated at build time and served as static files.
- ISR (Incremental Static Regeneration): Static HTML is generated on the first request and then revalidated in the background at a defined interval.
Why Rendering Strategy Matters
Choosing the right rendering strategy directly impacts performance, SEO, infrastructure cost, and content freshness. SSR gives you dynamic, always-fresh content but requires a running server. SSG gives you the fastest possible delivery and cheapest hosting but requires rebuilds for content changes. ISR bridges the gap by serving static content while periodically refreshing it in the background.
Qwik's unique advantage is that regardless of which strategy you choose, the client receives minimal JavaScript. The HTML contains serialized state and lazy-loaded event handlers, so the page is interactive almost instantly without the hydration cost that plagues other frameworks.
Setting Up a Qwik City Project
Before diving into each strategy, let's set up a Qwik City project. You'll need Node.js 18 or higher installed.
npm create qwik@latest
Follow the prompts to create a basic application. Once created, navigate into the project and install dependencies:
cd my-qwik-app
npm install
The project structure includes a src/routes directory where each folder represents a route. Qwik City uses file-based routing, and rendering behavior is configured per route using a routeLoader$, routeAction$, and export constants.
Server-Side Rendering (SSR) in Qwik
SSR is the default rendering mode in Qwik City. When a request comes in, the server executes the component tree, fetches data, and returns fully formed HTML. This is ideal for pages with personalized or frequently changing content.
Creating an SSR Route
Let's create a route that fetches data from an API on every request:
// src/routes/products/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const useProducts = routeLoader$(async () => {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return products;
});
export default component$(() => {
const products = useProducts();
return (
<div>
<h1>Products</h1>
<ul>
{products.value.map((product) => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
</div>
);
});
The routeLoader$ function runs on the server during SSR. The data is serialized into the HTML, so when the client takes over, it can resume without re-fetching. This is the core of Qwik's resumability — no redundant work happens on the client.
Controlling SSR Behavior
You can fine-tune SSR behavior by exporting configuration from your route file. For example, you can disable SSR for specific routes:
// src/routes/client-only/index.tsx
export const onGet = ({ cacheControl }) => {
cacheControl('no-cache');
};
export default component$(() => {
return <p>This page is rendered on every request.</p>
});
The cacheControl helper sets HTTP caching headers. Using 'no-cache' ensures the browser always fetches fresh HTML from the server, which is appropriate for highly dynamic content.
Static Site Generation (SSG) in Qwik
SSG pre-renders pages at build time. The output is plain HTML files that can be served from any static host — CDN, GitHub Pages, Netlify, Cloudflare Pages, or even an S3 bucket. This is the fastest and cheapest delivery method.
Enabling SSG for a Route
To statically generate a route, you export a Prerender function or set the prerender flag. Here's how to mark a route for static generation:
// src/routes/about/index.tsx
import { component$ } from '@builder.io/qwik';
export const onGet = ({ cacheControl }) => {
// Static content, cache for a long time
cacheControl('public, max-age=31536000, immutable');
};
export default component$(() => {
return (
<div>
<h1>About Us</h1>
<p>This page is statically generated at build time.</p>
</div>
);
});
To generate the static site, run the build command:
npm run build
This produces a dist/ directory containing static HTML, CSS, and the minimal JavaScript bundles Qwik needs. You can preview the static output locally:
npm run preview
Dynamic Routes with SSG
Qwik City can statically generate dynamic routes by providing the list of parameters at build time. Use the onStaticGenerate hook:
// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const onStaticGenerate = async () => {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
params: posts.map((post) => ({ slug: post.slug })),
};
};
export const usePost = routeLoader$(async ({ params }) => {
const res = await fetch(`https://api.example.com/posts/${params.slug}`);
return res.json();
});
export default component$(() => {
const post = usePost();
return (
<article>
<h1>{post.value.title}</h1>
<div dangerouslySetInnerHTML={post.value.content} />
</article>
);
});
During the build, Qwik calls onStaticGenerate, fetches all slugs, and produces a static HTML file for each one. This is perfect for blogs, documentation sites, and marketing pages.
Incremental Static Regeneration (ISR) in Qwik
ISR combines the speed of SSG with the freshness of SSR. The first request generates and caches the page. Subsequent requests serve the cached version. In the background, after a specified time, the page is regenerated so the next request gets fresh content. This is ideal for content that updates periodically but doesn't need to be real-time.
Implementing ISR
Qwik City supports ISR through cache control directives combined with serverless deployment platforms that support on-demand regeneration. Here's an example of a route using ISR with a 60-second revalidation window:
// src/routes/news/[id]/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const onGet = ({ cacheControl }) => {
// Serve stale content, revalidate in background every 60 seconds
cacheControl('stale-while-revalidate=60');
};
export const onStaticGenerate = async () => {
const res = await fetch('https://api.example.com/news/ids');
const ids = await res.json();
return {
params: ids.map((id) => ({ id: String(id) })),
};
};
export const useNewsArticle = routeLoader$(async ({ params }) => {
const res = await fetch(`https://api.example.com/news/${params.id}`);
return res.json();
});
export default component$(() => {
const article = useNewsArticle();
return (
<article>
<h1>{article.value.headline}</h1>
<p>{article.value.summary}</p>
<div dangerouslySetInnerHTML={article.value.body} />
</article>
);
});
The stale-while-revalidate=60 directive tells compatible hosting platforms (like Netlify, Vercel, or Cloudflare) to serve the cached page for 60 seconds, then regenerate it in the background. The first visitor after expiration gets the stale version, and subsequent visitors get the fresh one.
On-Demand Revalidation
For more control, you can implement on-demand revalidation using a webhook or API endpoint. This is useful when you want to regenerate a page immediately after content changes in your CMS:
// src/routes/api/revalidate/index.ts
import { type RequestHandler } from '@builder.io/qwik-city';
export const onPost: RequestHandler = async ({ json, request }) => {
const { secret, path } = await request.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Trigger revalidation on the hosting platform
// Implementation depends on your hosting provider
return json({ revalidated: true, path });
};
Your CMS can call this endpoint whenever content is published, ensuring users always see the latest version without waiting for the revalidation window to expire.
Combining Strategies in a Single App
One of Qwik City's strengths is that you can mix rendering strategies within the same application. Different routes can use different strategies based on their needs:
// src/routes/layout.tsx
import { component$, Slot } from '@builder.io/qwik';
export default component$(() => {
return (
<html>
<head>
<title>My Qwik App</title>
</head>
<body>
<nav>
<a href="/">Home (SSG)</a>
<a href="/dashboard">Dashboard (SSR)</a>
<a href="/news">News (ISR)</a>
</nav>
<Slot />
</body>
</html>
);
});
Each route directory can independently define its rendering strategy through its exports. The home page might be fully static, the dashboard SSR because it shows user-specific data, and the news section ISR because it updates periodically.
Best Practices
Choose the Right Strategy Per Route
- Use SSG for content that rarely changes: landing pages, blog posts, documentation, legal pages.
- Use SSR for personalized content: dashboards, user profiles, search results, shopping carts.
- Use ISR for periodically updated content: news feeds, product catalogs, pricing pages, event listings.
Optimize Data Fetching
Always use routeLoader$ for data fetching instead of fetching inside components. This ensures data is available during SSR and serialized for resumability:
// Good: data fetched during SSR
export const useUserData = routeLoader$(async ({ cookie }) => {
const token = cookie.get('session')?.value;
if (!token) return null;
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
});
// Avoid: fetching inside component runs on client only
export default component$(() => {
// This would not run during SSR
// const data = useFetch('/api/data');
return <p>Content</p>
});
Set Appropriate Cache Headers
Cache headers are your primary tool for controlling rendering behavior. Here's a quick reference:
// Immutable static content
cacheControl('public, max-age=31536000, immutable');
// Short-lived cache (ISR-like behavior)
cacheControl('stale-while-revalidate=60');
// Always fresh (SSR)
cacheControl('no-cache');
// Private, user-specific content
cacheControl('private, no-store');
Leverage Qwik's Lazy Loading
Qwik automatically code-splits at the component and event-handler level. Take advantage of this by keeping components small and focused. Heavy components that are below the fold or conditionally rendered will only load when needed:
import { component$, useVisibleTask$, useSignal } from '@builder.io/qwik';
export default component$(() => {
const showChart = useSignal(false);
return (
<div>
<button onClick$={() => (showChart.value = true)}>
Show Chart
</button>
{showChart.value && (
<Chart /> {/* This component's JS only loads when clicked */}
)}
</div>
);
});
Handle Errors Gracefully
Always handle errors in your route loaders to prevent entire pages from crashing during SSR:
export const useProducts = routeLoader$(async () => {
try {
const res = await fetch('https://api.example.com/products');
if (!res.ok) throw new Error('Failed to fetch');
return await res.json();
} catch (error) {
return { error: 'Unable to load products', items: [] };
}
});
Deployment Considerations
Your hosting platform determines which strategies are fully supported. Static hosts like GitHub Pages only support SSG. Platforms like Netlify, Vercel, and Cloudflare Pages support SSR and ISR through serverless functions. For full control, you can deploy the Qwik City Node adapter to any Node.js-capable server:
npm run build
node server/entry.express
Qwik City also provides adapters for Netlify, Vercel, Cloudflare Pages, and Deno Deploy, each optimized for that platform's edge runtime and caching capabilities.
Conclusion
Qwik's approach to server-side rendering, static generation, and incremental static regeneration is both familiar and revolutionary. The strategies map cleanly to what developers already know from other frameworks, but Qwik's resumability eliminates the hydration tax that makes SSR expensive on the client. By choosing the right rendering strategy per route, leveraging routeLoader$ for data fetching, setting appropriate cache headers, and taking advantage of Qwik's automatic code splitting, you can build applications that are fast on first load, cheap to host, and fresh where it matters. Whether you're building a static marketing site, a dynamic dashboard, or a hybrid application with all three strategies, Qwik City gives you the tools to deliver exceptional performance without compromising on developer experience.