Data Fetching
In Next.js pages router, you had getServerSideProps. In Remix, you had loaders. In Juice, your component is async. You just fetch.
The Paradigm Shift
React Server Components are async. This means you can awaitdirectly in the component body. No special data-fetching function, no loader convention, no intermediate serialization layer. The component IS the data fetcher.
// app/routes/blog/[slug].tsx
import type { PageProps } from '@cmj/juice/runtime';
export default async function BlogPost(props: PageProps['/blog/:slug']) {
const post = await db.posts.findBySlug(props.params.slug);
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
<div>{post.body}</div>
</article>
);
}Notice the error handling. When the post does not exist, you throw a Responsewith status 404. The Juice runtime catches this and returns it as the HTTP response. This works in any server component, at any depth in the tree.
What happens when the API is down? If db.posts.findBySlug()throws an error, Juice catches it via the onError handler (or the nearest error.tsx boundary if streaming is enabled). In production, the user sees a generic error page. In development, they see a stack trace. You should handle expected failures (not found, unauthorized) explicitly. Let unexpected failures (network errors, database timeouts) propagate to the error boundary.
Request-Scoped cache()
Here is the problem cache() solves. You have a getUser()function. Your page component calls it. Your sidebar component calls it. Your header component calls it. Without deduplication, that is three database queries for the same user in a single request.
import { cache } from '@cmj/juice/runtime';
const getUser = cache(async (userId: string) => {
console.log('Fetching user', userId); // Logged once per request
return await db.users.find(userId);
});
// Page component
export default async function Profile({ params }: { params: { id: string } }) {
const user = await getUser(params.id);
return (
<div>
<UserHeader userId={params.id} />
<h1>{user.name}</h1>
<UserSidebar userId={params.id} />
</div>
);
}
// Called in the same request -- deduplicated, no second DB query
async function UserHeader({ userId }: { userId: string }) {
const user = await getUser(userId);
return <header>{user.name} ({user.email})</header>;
}
async function UserSidebar({ userId }: { userId: string }) {
const user = await getUser(userId);
return <aside>Member since {user.createdAt}</aside>;
}How Identity-Based Keys Work
Coming from Next.js: Next.js extends fetch() with automatic deduplication based on URL strings. Juice does not patch fetch(). Instead, cache() wraps any function and deduplicates based on argument identity.
Primitives (strings, numbers, booleans) are compared by value: getUser("42")called twice returns the same promise. Objects and functions are compared by reference using a WeakMap of monotonic IDs. This means passing a Requestobject works correctly -- two calls with the same Request instance deduplicate, but two different Request objects with identical URLs do not. This is deliberate. JSON serialization of complex objects is fragile (circular references, non-serializable values, key ordering). Reference identity is always correct.
When NOT to Use cache()
cache() is for reads. If your function has side effects (writes to the database, sends an email, logs an audit event), do not cache it. Cached functions are memoized: the side effect would only happen on the first call, and subsequent calls would silently return the cached result without executing the function body.
Cross-Request CacheAdapter
cache() is per-request. When the request ends, the cache is gone. For caching across requests (CDN-level, Redis, KV), pass a cache adapter to createRouter():
import { createRouter } from '@cmj/juice/runtime';
createRouter(manifest, {
cache: {
get: (key) => kv.get(key),
set: (key, value, ttl) => kv.set(key, value, { ttl }),
},
});When does this matter? On Cloudflare Workers, each request runs in an isolate. The per-request cache dies with the isolate. If you want CDN-level deduplication across requests, you need an external cache. Here is the Cloudflare-specific pattern:
// Cloudflare Workers with caches.default
createRouter(manifest, {
cache: {
get: async (key) => {
const cached = await caches.default.match(new Request(key));
return cached ? await cached.json() : undefined;
},
set: async (key, value, ttl) => {
await caches.default.put(
new Request(key),
new Response(JSON.stringify(value), {
headers: { 'Cache-Control': `max-age=${ttl ?? 60}` },
})
);
},
},
});On Bun or Node.js running a long-lived process, the per-request cache already lives in the same memory space. A cross-request adapter is useful for sharing cache across multiple server instances (behind a load balancer) or persisting cache across restarts.
Response Headers for Caching
Cache-Control headers are the platform's caching mechanism. Juice does not own caching. It sets headers on the Response, and the CDN (Cloudflare, Vercel, Fastly) decides what to cache. This is intentional: Juice does not reinvent HTTP caching.
export const response = {
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
};max-age controls the browser cache. s-maxage controls the CDN cache. Set s-maxage higher than max-age so the CDN serves stale content while the browser re-validates. This is standard HTTP, not a Juice feature.
Metadata and SEO
React 19 hoists <title> and <meta> elements from anywhere in the tree into <head>. Juice generates them from your response config.
Static metadata:
export const response = {
head: {
title: 'About Us',
description: 'Learn about our team and mission.',
'og:image': '/images/about-og.png',
},
};Dynamic metadata from params:
export async function response({ params }) {
const product = await getProduct(params.id);
return {
head: {
title: product.name,
description: product.summary,
'og:image': product.imageUrl,
'twitter:card': 'summary_large_image',
},
};
}Individual export (alternative):
export const metadata = { title: 'About', description: '...' };Response headers for caching:
export const response = {
head: { title: 'Products' },
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
};The response function receives { params, searchParams, request }. The metadata function receives { params, searchParams } (no request). Use response when you need request headers (e.g. for auth-dependent metadata).
Static Prerendering
Export prerender = true to mark a route as static. The runtime sets immutable cache headers on the response.
export const prerender = true;
export default function About() {
return <h1>About Us</h1>;
}When NOT to Use Juice for Data Fetching
If your app is 90% client-side state (a spreadsheet, a drawing tool, a chat app), server components add round-trip latency for every data update. You would be better served by 'use client' on the entire page with client-side data fetching (React Query, SWR, or plain fetch in useEffect). Juice still works as the shell renderer, but the server component model is not helping you.