Full Circle Example
Build a complete app with Juice: server-rendered pages, client interactivity, SPA navigation, forms, auth, and deployment. Everything in one walkthrough.
The App: A Task Manager
We will build a task manager with: a task list page (server-rendered), an add-task form (server action with progressive enhancement), client-side interactivity (task completion toggle), SPA navigation between pages, and auth middleware. This covers every major Juice feature.
1. Scaffold and Structure
npx @cmj/juice create tasks --target bun
cd tasks && bun installCreate the route structure:
app/
routes/
layout.tsx # Root layout with nav
home.tsx # Task list (/)
add.tsx # Add task form (/add)
middleware.ts # Auth check
components/
task-item.tsx # 'use client' — toggle completion
nav-links.tsx # 'use client' — active link highlighting2. Root Layout with Navigation
The layout renders the HTML shell and navigation. It wraps every page. The children prop is the current page — it changes on navigation but the layout stays mounted.
// app/routes/layout.tsx
import React, { Suspense } from 'react';
import { NavLinks } from '../components/nav-links.js';
import './global.css';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head><meta charSet="utf-8" /></head>
<body>
<nav>
<h1>Tasks</h1>
<NavLinks /> {/* Client component — stays mounted across navigations */}
</nav>
<main>
<Suspense fallback={<p>Loading...</p>}>
{children}
</Suspense>
</main>
</body>
</html>
);
}3. Client Component: Active Navigation
NavLinks is a client component. It uses useRouter() to highlight the active link. Because it is in the layout, it stays mounted and preserves state when pages change. This is the SPA navigation behavior.
// app/components/nav-links.tsx
'use client';
import { useRouter } from '@cmj/juice/client';
import { Link } from '@cmj/juice/client';
export function NavLinks() {
const router = useRouter();
return (
<div>
<Link
href="/"
style={{ fontWeight: router.pathname === '/' ? 'bold' : 'normal' }}
>
All Tasks
</Link>
<Link
href="/add"
style={{ fontWeight: router.pathname === '/add' ? 'bold' : 'normal' }}
>
Add Task
</Link>
</div>
);
}SPA navigation is automatic. When you click a <Link>, Juice fetches an RSC payload from the server and updates only the page content. The layout (including NavLinks and its state) stays mounted. No full page reload. No configuration needed.
4. Server Component: Task List
The home page is an async server component. It fetches data directly — no loader functions, no getServerSideProps, no hooks. The component IS the data layer.
// app/routes/home.tsx
import React from 'react';
import { cache } from '@cmj/juice/runtime';
import { TaskItem } from '../components/task-item.js';
// cache() deduplicates within a single request render
const getTasks = cache(async () => {
// In a real app: database query, API call, etc.
return [
{ id: '1', title: 'Build with Juice', done: false },
{ id: '2', title: 'Deploy to Workers', done: false },
{ id: '3', title: 'Ship it', done: true },
];
});
export default async function Home() {
const tasks = await getTasks();
return (
<div>
<h2>All Tasks</h2>
<ul>
{tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</ul>
</div>
);
}
export const response = {
head: { title: 'Tasks — My App' },
headers: { 'Cache-Control': 'private, no-cache' },
};5. Client Component: Interactive Task Item
Each task item is a client component with local state. The checkbox toggles completion without a server round-trip — pure client-side React state. This state is preserved when you navigate away and back because the layout stays mounted and React reconciles the page.
// app/components/task-item.tsx
'use client';
import { useState } from 'react';
interface Task {
id: string;
title: string;
done: boolean;
}
export function TaskItem({ task }: { task: Task }) {
const [done, setDone] = useState(task.done);
return (
<li style={{ textDecoration: done ? 'line-through' : 'none' }}>
<label>
<input
type="checkbox"
checked={done}
onChange={() => setDone(!done)}
/>
{task.title}
</label>
</li>
);
}6. Server Action: Add Task Form
The add-task page has a form that works with and without JavaScript. Without JS: standard form POST, server processes, redirects. With JS:useActionState shows pending state and errors inline.
// app/routes/add.tsx
import React from 'react';
import { redirect } from '@cmj/juice/runtime';
// Server action — runs on the server
async function addTask(formData: FormData) {
'use server';
const title = formData.get('title') as string;
if (!title || title.trim().length === 0) {
return { error: 'Title is required' };
}
// In a real app: save to database
console.log('New task:', title);
// POST-Redirect-GET: redirect back to task list
redirect('/', 303);
}
export default function AddTask() {
return (
<div>
<h2>Add Task</h2>
<form action={addTask} method="POST">
<input
name="title"
placeholder="What needs doing?"
required
autoFocus
/>
<button type="submit">Add</button>
</form>
<p style={{ color: '#888', fontSize: '0.85rem' }}>
This form works without JavaScript. With JS, it enhances
with pending states via useActionState.
</p>
</div>
);
}
export const response = {
head: { title: 'Add Task — My App' },
};7. Middleware: Auth Guard
Middleware runs before every route in its directory. This one checks for a session cookie and shares the user with downstream routes via typed context.
// app/routes/middleware.ts
import { setContext, createContextKey, redirect } from '@cmj/juice/runtime';
export const userKey = createContextKey<{ id: string; name: string }>('user');
export default async function auth(
req: Request,
next: () => Promise<Response>,
) {
// Check session cookie
const cookie = req.headers.get('cookie') ?? '';
const session = cookie.match(/session=([^;]+)/)?.[1];
if (!session) {
// No session — for this example, create a default user
// In a real app: redirect('/login')
setContext(req, userKey, { id: 'guest', name: 'Guest' });
} else {
setContext(req, userKey, { id: session, name: 'User' });
}
return next();
}8. Reading Context in a Page
Any page can read the user from context. The type is inferred from the ContextKey — no manual casting.
// In any page component:
import { getContext } from '@cmj/juice/runtime';
import { userKey } from './middleware.js';
export default function Dashboard({ request }: { request: Request }) {
const user = getContext(request, userKey);
return <h1>Hello, {user?.name}</h1>;
}9. Run and Navigate
bun run devOpen http://localhost:5173. You will see:
- The task list renders server-side (view source — real HTML)
- Click a checkbox — client-side state update, no server call
- Click "Add Task" — SPA navigation, layout stays mounted, no reload
- Submit the form — server action processes, redirects to task list
- Click "All Tasks" — SPA navigation back, layout state preserved
This is the full circle. Server rendering for first paint and SEO. Client hydration for interactivity. SPA navigation for speed. Server actions for forms. Middleware for auth. All in one framework, with 2 dependencies.
10. Deploy
juice build
juice preview # verify locally
# Then deploy to your target:
wrangler deploy # Cloudflare Workers
# or: bun server.ts
# or: deno run --allow-net server.tsWhat You Did NOT Need
- No client-side router configuration —
initNavigation()handles it - No data fetching library — async components fetch directly
- No form library — HTML forms + server actions
- No state management library — React state + server components
- No auth library — middleware + typed context
- No build configuration —
plugins: [juice()]is the entire config