Skip to content

Migrate from Remix

Remix and Juice share a philosophy: web standards, progressive enhancement, server-first rendering. The migration is mostly about moving from Remix's loader/action model to React 19 server components and server actions.

Concept Mapping

RemixJuiceNotes
loader()Async server componentData fetching happens directly in the component, not a separate function
action()'use server' functionReact 19 server actions replace Remix actions
useLoaderData()Component propsData is rendered directly in the server component — no hook needed
useFetcher()useActionState + callServerReact 19 primitives replace Remix's fetcher
useNavigation()useRouter()Different API shape but same purpose
<Link><Link>Same concept, different import path
<Form>Standard <form>With React 19, standard forms work with server actions
redirect()redirect()Same concept — throws a Response with redirect status
json()new Response(JSON.stringify(...))No helper — use standard Response constructor
ErrorBoundary exportcatch.tsx fileFile convention instead of named export
Nested routes (outlet)Layout childrenJuice uses layout components with children prop
Resource routesexport function GET/POSTExport HTTP method handlers from route files
meta()export const responseresponse.head for title and meta tags

The Big Shift: Loaders to Server Components

Remix separates data fetching (loader) from rendering (component). Juice collapses them into one: the component itself fetches data because it runs on the server.

// Remix
export async function loader({ params }: LoaderFunctionArgs) {
  const user = await db.user.findUnique({ where: { id: params.id } });
  if (!user) throw new Response('Not Found', { status: 404 });
  return json({ user });
}

export default function UserProfile() {
  const { user } = useLoaderData<typeof loader>();
  return <h1>{user.name}</h1>;
}
// Juice
export default async function UserProfile({
  params,
}: {
  params: { id: string };
}) {
  const user = await db.user.findUnique({ where: { id: params.id } });
  if (!user) throw new Response('Not Found', { status: 404 });
  return <h1>{user.name}</h1>;
}

The component is async. It fetches data directly. No useLoaderData, no json() wrapper, no separate function. The data and the JSX live together.

Actions to Server Actions

// Remix
export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const title = form.get('title') as string;
  await db.task.create({ data: { title } });
  return redirect('/tasks');
}

export default function NewTask() {
  return (
    <Form method="post">
      <input name="title" />
      <button type="submit">Create</button>
    </Form>
  );
}
// Juice
import { redirect } from '@cmj/juice/runtime';

async function createTask(formData: FormData) {
  'use server';
  const title = formData.get('title') as string;
  await db.task.create({ data: { title } });
  redirect('/tasks');
}

export default function NewTask() {
  return (
    <form action={createTask}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

The action function is colocated with the component using 'use server'. Standard <form> replaces Remix's <Form>. Progressive enhancement still works — the form submits as a POST without JavaScript.

What Is Different

  • No nested route modules. Remix's routes/ directory maps each file to a route with loader, action, and component in one file. Juice separates concerns: routes are components, actions are 'use server' functions, data fetching happens in the component.
  • No Outlet. Juice layouts receive childrenas a prop instead of rendering an <Outlet />.
  • No shouldRevalidate. Juice does not revalidate loaders on navigation because there are no loaders. Server components re-render on navigation automatically.
  • No defer(). Juice's streaming modes (streaming: 'shell') handle progressive rendering. Wrap slow components in Suspense boundaries.
  • No built-in session management. Remix has createCookieSessionStorage. Juice provides context passing — use a session library or implement cookies directly in middleware.

Migration Strategy

Step 1: Start with static pages

Pages without loaders or actions are the easiest. Move the component, update imports, replace <Link> imports.

Step 2: Convert loaders to async components

For each route with a loader: move the loader logic into the default export function, make it async, and replace useLoaderData() with direct variable usage.

Step 3: Convert actions to server actions

For each route with an action: create an inline 'use server'function or a separate server action module. Replace <Form> with <form action={serverAction}>.

Step 4: Convert middleware and auth

Remix uses loaders for auth checks. Juice uses middleware files. Create middleware.ts files in the route directories that need protection.

Step 5: Convert error boundaries

Replace export function ErrorBoundary() with catch.tsxfiles in the appropriate route directories.

Common Gotchas

  • No request in loader args. In Juice, requestis a prop on the component: ({ request, params }).
  • No json(). Return new Response(JSON.stringify(data))for API routes.
  • Form data in actions. Remix actions receive request and you call request.formData(). Juice server actions receive FormData directly as the first argument.
  • No useMatches(). Juice does not expose the route match hierarchy to client components. Use context or props instead.