Skip to content

Full Circle Example

Build a complete app with Juice: SQLite database, server-rendered pages, client interactivity, server actions, useActionState, auth middleware, shell streaming, and deployment. Everything in one walkthrough.

The App: A Task Manager

We will build a task manager with: a SQLite database for persistence, a task list page (server-rendered), an add-task form with validation and pending state (useActionState), a completion toggle that persists to the database (server action), auth middleware that guards all routes, a login page, and SPA navigation. This covers every major Juice feature.

app/
  routes/
    layout.tsx          # Root layout with nav
    home.tsx            # Task list (/)
    add.tsx             # Add task form (/add)
    login.tsx           # Login page (/login)
    middleware.ts       # Auth guard — redirects to /login
  components/
    task-item.tsx       # 'use client' — toggle via server action
    add-form.tsx        # 'use client' — useActionState
    nav-links.tsx       # 'use client' — active link highlighting
server.ts               # Entry: SQLite + context + streaming

1. Server Entry

The server entry creates the database, shares it via typed context, and enables shell streaming. This is the entire backend.

// server.ts
import { createRouter } from '@cmj/juice/runtime';
import { createContextKey, setContext } from '@cmj/juice/runtime';
import { Database } from 'bun:sqlite';
import manifest from './flight-manifest.json';

// ── Database ────────────────────────────────────────────────
const db = new Database('tasks.db');
db.run('PRAGMA journal_mode = WAL');
db.run(`
  CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    done INTEGER NOT NULL DEFAULT 0,
    user_id TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  )
`);

// ── Typed context keys ──────────────────────────────────────
export const dbKey = createContextKey<Database>('db');
export const userKey = createContextKey<{ id: string; name: string }>('user');

// ── Router ──────────────────────────────────────────────────
const router = createRouter(manifest, {
  root: import.meta.url,
  streaming: 'shell',     // Fast TTFB, correct status codes for the shell
  onBeforeRequest: async (req) => {
    setContext(req, dbKey, db);
  },
});

Bun.serve({ port: 3000, fetch: router });
console.log('http://localhost:3000');

streaming: 'shell' sends the layout immediately (fast TTFB). Auth redirects in middleware still produce correct 303 status codes because they execute in the shell, before headers are committed. The task list inside Suspense streams in when the database query completes.

2. Root Layout

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" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Tasks</title>
      </head>
      <body>
        <nav>
          <h1>Tasks</h1>
          <NavLinks />
        </nav>
        <main>
          <Suspense fallback={<p>Loading...</p>}>
            {children}
          </Suspense>
        </main>
      </body>
    </html>
  );
}

3. Auth Middleware

Middleware runs before every route in its directory. This one checks for a session cookie. If missing, it redirects to /login. If present, it shares the user with downstream routes via typed context.

The login page is excluded from the guard — it sits at /loginand is matched before middleware runs.

// app/routes/middleware.ts
import { setContext, redirect } from '@cmj/juice/runtime';
import { userKey } from '../../server.js';

export default async function auth(
  req: Request,
  next: () => Promise<Response>,
) {
  const url = new URL(req.url);

  // Skip auth for the login page
  if (url.pathname === '/login') {
    return next();
  }

  const cookie = req.headers.get('cookie') ?? '';
  const session = cookie.match(/session=([^;]+)/)?.[1];

  if (!session) {
    redirect('/login');
  }

  // Decode the session (in a real app: validate a JWT or look up a session store)
  const [id, name] = atob(session).split(':');
  setContext(req, userKey, { id, name });

  return next();
}

4. Login Page

A simple login page. The server action validates the name, sets a session cookie, and redirects to the task list. No auth library needed — just a cookie and a redirect.

// app/routes/login.tsx
import React from 'react';
import { redirect } from '@cmj/juice/runtime';

async function login(formData: FormData) {
  'use server';
  const name = (formData.get('name') as string)?.trim();

  if (!name) {
    return { error: 'Name is required' };
  }

  const session = btoa(`${crypto.randomUUID()}:${name}`);

  // Set cookie and redirect — the browser follows with GET
  throw new Response(null, {
    status: 303,
    headers: {
      Location: '/',
      'Set-Cookie': `session=${session}; Path=/; HttpOnly; SameSite=Lax`,
    },
  });
}

export default function Login() {
  return (
    <div>
      <h2>Log In</h2>
      <form action={login}>
        <input name="name" placeholder="Your name" required autoFocus />
        <button type="submit">Enter</button>
      </form>
    </div>
  );
}

export const response = {
  head: { title: 'Log In — Tasks' },
};

5. Task List

The home page is an async server component. It queries SQLite directly — no loader, no hook, no API call. The component IS the data layer.

// app/routes/home.tsx
import React from 'react';
import { getContext } from '@cmj/juice/runtime';
import { dbKey, userKey } from '../../server.js';
import { TaskItem } from '../components/task-item.js';

export default async function Home({ request }: { request: Request }) {
  const db = getContext(request, dbKey)!;
  const user = getContext(request, userKey)!;

  const tasks = db
    .prepare('SELECT id, title, done FROM tasks WHERE user_id = ? ORDER BY created_at DESC')
    .all(user.id) as { id: number; title: string; done: number }[];

  return (
    <div>
      <h2>Hello, {user.name}</h2>
      {tasks.length === 0 ? (
        <p>No tasks yet. <a href="/add">Add one.</a></p>
      ) : (
        <ul>
          {tasks.map(task => (
            <TaskItem
              key={task.id}
              id={task.id}
              title={task.title}
              done={task.done === 1}
            />
          ))}
        </ul>
      )}
    </div>
  );
}

export const response = {
  head: { title: 'Tasks' },
};

6. Toggle Completion (Server Action)

Each task item is a client component. The checkbox calls a server action that UPDATEs the database. The toggle persists — refresh the page and the state is still there.

// app/components/task-item.tsx
'use client';

import { useOptimistic, useTransition } from 'react';

export function TaskItem({
  id,
  title,
  done,
  toggleAction,
}: {
  id: number;
  title: string;
  done: boolean;
  toggleAction: (id: number) => Promise<void>;
}) {
  const [isPending, startTransition] = useTransition();
  const [optimisticDone, setOptimisticDone] = useOptimistic(done);

  function handleToggle() {
    startTransition(async () => {
      setOptimisticDone(!optimisticDone);
      await toggleAction(id);
    });
  }

  return (
    <li style={{
      textDecoration: optimisticDone ? 'line-through' : 'none',
      opacity: isPending ? 0.6 : 1,
    }}>
      <label>
        <input
          type="checkbox"
          checked={optimisticDone}
          onChange={handleToggle}
        />
        {title}
      </label>
    </li>
  );
}

The server action lives in the page component (not in the client component). Pass it as a prop:

// In home.tsx, add the server action and pass it to TaskItem:
async function toggleTask(id: number) {
  'use server';
  db.prepare('UPDATE tasks SET done = NOT done WHERE id = ?').run(id);
}

// In the JSX:
<TaskItem
  key={task.id}
  id={task.id}
  title={task.title}
  done={task.done === 1}
  toggleAction={toggleTask}
/>

useOptimistic flips the checkbox immediately. The server action runs in the background. If it fails, React rolls back the optimistic state.

7. Add Task Form (useActionState)

The add form uses useActionState for pending state, validation errors, and form reset. This is the React 19 way to build forms with server actions.

// app/routes/add.tsx
import React from 'react';
import { getContext } from '@cmj/juice/runtime';
import { dbKey, userKey } from '../../server.js';
import { AddForm } from '../components/add-form.js';

async function addTask(prev: { error?: string } | null, formData: FormData) {
  'use server';
  const title = (formData.get('title') as string)?.trim();

  if (!title) {
    return { error: 'Title cannot be empty' };
  }

  if (title.length > 200) {
    return { error: 'Title must be under 200 characters' };
  }

  // Insert into database
  // (In a real app, get db and user from context via the request)
  const db = getContext(this.request, dbKey)!;
  const user = getContext(this.request, userKey)!;

  db.prepare('INSERT INTO tasks (title, user_id) VALUES (?, ?)')
    .run(title, user.id);

  return { success: true };
}

export default function Add() {
  return (
    <div>
      <h2>Add Task</h2>
      <AddForm action={addTask} />
    </div>
  );
}

export const response = {
  head: { title: 'Add Task — Tasks' },
};
// app/components/add-form.tsx
'use client';

import { useActionState, useRef, useEffect } from 'react';

export function AddForm({
  action,
}: {
  action: (prev: any, formData: FormData) => Promise<any>;
}) {
  const [state, formAction, isPending] = useActionState(action, null);
  const formRef = useRef<HTMLFormElement>(null);

  // Clear the form on success
  useEffect(() => {
    if (state?.success) {
      formRef.current?.reset();
    }
  }, [state]);

  return (
    <form ref={formRef} action={formAction}>
      <input
        name="title"
        placeholder="What needs doing?"
        required
        autoFocus
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Adding...' : 'Add'}
      </button>
      {state?.error && (
        <p style={{ color: '#ef4444' }}>{state.error}</p>
      )}
      {state?.success && (
        <p style={{ color: '#22c55e' }}>Task added.</p>
      )}
    </form>
  );
}

Without JavaScript: the form submits as a POST. The server action processes it and returns the result. The page re-renders with the success or error message.

With JavaScript: useActionState intercepts the submission. The button shows "Adding...", the input disables, and the form resets on success. No page reload.

8. Navigation

NavLinks is a client component in the layout. It uses useRouter() to highlight the active link. Because it lives in the layout, it stays mounted across navigations — its state is preserved.

// app/components/nav-links.tsx
'use client';

import { useRouter, 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.

9. Run It

bun run server.ts

Open http://localhost:3000. You will see:

  1. Redirect to /login — middleware detects no session cookie and responds with 303. Because streaming is 'shell' and middleware runs in the shell, the redirect has the correct status code.
  2. Log in — type a name, submit. The server action sets a cookie and redirects to /.
  3. Empty task list — the async server component queries SQLite. No tasks yet.
  4. Click "Add Task" — SPA navigation. The layout stays mounted. The URL changes. Only the page content swaps.
  5. Submit a taskuseActionState shows "Adding...", then "Task added." The form resets. The database has a new row.
  6. Click "All Tasks" — SPA navigation back. Your task appears. The server component re-renders with fresh data from SQLite.
  7. Toggle the checkboxuseOptimistic flips it immediately. The server action UPDATEs the database in the background. Refresh the page — the toggle persisted.
  8. View source — real HTML. The task list is server-rendered. SEO-friendly. Works without JavaScript (forms submit as POST, navigation is full page loads).

10. Deploy

juice build
juice preview    # verify locally

# Then deploy to your target:
bun server.ts                        # Bun (production)
# or: wrangler deploy               # Cloudflare Workers (swap bun:sqlite for D1)
# or: deno run --allow-net server.ts # Deno

What You Did NOT Need

  • No client-side router configuration — SPA navigation is built in
  • No data fetching library — async components query the database directly
  • No form library — useActionState + server actions
  • No state management library — useOptimistic + server state
  • No auth library — middleware + cookies + typed context
  • No ORM — bun:sqlite with raw SQL
  • No build configuration — plugins: [juice()] is the entire Vite config