Skip to content

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 install

Create 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 highlighting

2. 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 dev

Open 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.ts

What 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