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
| Remix | Juice | Notes |
|---|---|---|
loader() | Async server component | Data fetching happens directly in the component, not a separate function |
action() | 'use server' function | React 19 server actions replace Remix actions |
useLoaderData() | Component props | Data is rendered directly in the server component — no hook needed |
useFetcher() | useActionState + callServer | React 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 export | catch.tsx file | File convention instead of named export |
| Nested routes (outlet) | Layout children | Juice uses layout components with children prop |
| Resource routes | export function GET/POST | Export HTTP method handlers from route files |
meta() | export const response | response.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 receivechildrenas 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 inSuspenseboundaries. - 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
requestin loader args. In Juice,requestis a prop on the component:({ request, params }). - No
json(). Returnnew Response(JSON.stringify(data))for API routes. - Form data in actions. Remix actions receive
requestand you callrequest.formData(). Juice server actions receiveFormDatadirectly as the first argument. - No
useMatches(). Juice does not expose the route match hierarchy to client components. Use context or props instead.