Here are 24 Remix interview questions with model answers, written for senior engineers who build with Remix v2 or with React Router v7, which is where Remix's framework features now live. They cover loaders and actions, nested routes, the Remix to React Router merge, streaming, error boundaries, forms and deployment adapters. Core React topics such as hooks, rendering and the React Compiler are on our React interview questions page. At Ryz, senior React and Remix engineers are sourced by recruiters, assessed in structured NTRVSTA AI interviews, and reviewed by recruiters before and after. The AI scores are one input. People make the decisions.
Most teams that say "Remix" today run one of three things: a Remix v2 app, a Remix v2 app with the future flags turned on, or a React Router v7 app in framework mode. The ideas are the same, but the imports and a few APIs differ. Tell the candidate which one you run, and accept answers in either vocabulary. A candidate who knows the v2 names and explains the v7 equivalents is showing exactly the awareness you want.
The strongest signal in a Remix interview is how a candidate thinks about the request: what runs on the server, what runs in the browser, and when data is loaded again. Ask them to trace a form submission from click to fresh UI. Then have them build a small route live.
A loader is a function exported from a route module that runs on the server before the route renders, both on the first document request and on client-side navigations, where Remix calls it over a fetch request. The component reads the result with useLoaderData(), or in React Router v7 through the typed loaderData prop. Because loaders only run on the server, they can read the database or secrets directly, and that code never ships to the browser.
What a strong answer shows: They know loaders run on navigations too, not only on the first load, and that the loader is the server boundary of the route.
// app/routes/projects.$id.tsx (React Router v7 framework mode)
import { data, redirect, Form } from "react-router";
import type { Route } from "./+types/projects.$id";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireUser(request);
const project = await db.project.find(params.id, user.id);
if (!project) throw data("Not found", { status: 404 });
return { project };
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await requireUser(request);
const form = await request.formData();
const name = String(form.get("name") ?? "").trim();
if (!name) return data({ error: "Name is required" }, { status: 400 });
await db.project.rename(params.id, user.id, name);
return redirect(`/projects/${params.id}`);
}
In Remix v2 the same code imports from @remix-run/node and typically wraps returns in json(). With single fetch and in v7, loaders return plain objects, and data() is used only when you need a status code or headers.
What a strong answer shows: Auth inside both functions, validation in the action, a thrown 404 rather than an empty page, and a redirect after a successful write.
Remix revalidates: it calls the loaders of every route on the page again so the UI reflects the write without any manual cache updates. If the action redirects, the loaders of the new location run instead. Routes can opt out with a shouldRevalidate export when a loader is expensive and unaffected by the change.
What a strong answer shows: They know revalidation is the reason Remix apps rarely need a client data cache, and they use shouldRevalidate carefully because skipping it can show stale data.
No. On navigation, Remix knows every route that matches the URL up front, so it calls all of their loaders in parallel. This is the main way Remix avoids the request waterfalls that come from components that fetch after their parent renders. The trade-off is that a child loader cannot read its parent loader's result, so shared work such as loading the user must be repeated or cached per request.
What a strong answer shows: They can explain why parallel loaders matter for speed and how they share work without serial fetches.
Not by that check alone. Because loaders run in parallel and each one can be called directly over HTTP, a child loader or action runs whether or not the parent's check passed. Every loader and action that touches protected data has to authenticate and authorize on its own, usually through a shared helper such as requireAdmin(request).
What a strong answer shows: They treat every loader and action as a public endpoint. This is one of the most common security mistakes in Remix codebases.
clientLoader or clientAction?They run in the browser instead of, or in addition to, the server functions. Good uses are caching server data on the client, reading browser-only state such as IndexedDB, calling a third-party API straight from the browser, or building parts of an app in SPA mode. A clientLoader can call serverLoader() to get the server data and add to it, and a HydrateFallback export renders while it runs on first load.
What a strong answer shows: They see these as escape hatches with a clear reason, not a default.
<Outlet />.Each URL segment can map to a route module, and parent routes render their children where they place <Outlet />. A URL such as /dashboard/invoices/42 renders the root layout, the dashboard layout, the invoices list and the invoice detail together. On navigation, only the routes that changed load data again and re-render, so the shared layout stays mounted and keeps its state.
What a strong answer shows: They connect nested routes to data loading, error isolation and code splitting, not just to layout.
app/routes/_index.tsx
app/routes/dashboard.tsx
app/routes/dashboard.invoices.$id.tsx
app/routes/dashboard_.print.tsx
app/routes/_auth.login.tsx
app/routes/($lang).about.tsx
app/routes/files.$.tsx
_index is the index route for /. Dots create URL segments and nesting, so dashboard.invoices.$id is /dashboard/invoices/:id rendered inside the dashboard layout. A trailing underscore (dashboard_) keeps the URL but escapes the layout, which suits a print view. A leading underscore (_auth) is a pathless layout that wraps routes without adding a segment. Parentheses mark an optional segment, and $ alone is a splat that matches the rest of the path.
What a strong answer shows: They know these flat-route conventions and that in React Router v7 the same files work through flatRoutes() in app/routes.ts, or routes can be declared in config.
Each route exports a handle object, for example with a breadcrumb function. The root layout calls useMatches(), filters the matches that have one, and renders them in order using each match's loader data. No route needs to know about its parents.
What a strong answer shows: They use the route hierarchy as data instead of duplicating it in a breadcrumbs config.
In 2024 the Remix team merged Remix's framework features into React Router. What would have been Remix v3 shipped instead as React Router v7 in November 2024. Its "framework mode" provides loaders, actions, nested routes, server rendering and the Vite plugin, so a Remix v2 app upgrades to React Router v7 rather than to a new Remix major. The team later said the Remix name would be used for a separate, new framework, so React Router v7 is the path for existing React apps.
What a strong answer shows: They know the history accurately and do not describe React Router v7 as an unrelated rewrite.
First, on Remix v2, move to the Vite plugin and turn on the future flags one at a time, fixing what each breaks: v3_fetcherPersist, v3_relativeSplatPath, v3_throwAbortReason, v3_singleFetch, v3_lazyRouteDiscovery and v3_routeConfig. Once every flag is on and the app is stable, the upgrade is mostly mechanical: replace @remix-run/* packages with react-router, @react-router/dev and the matching adapter package, update imports (the official codemod handles most of it), add app/routes.ts, and switch the Vite plugin. Then adopt the generated route types.
What a strong answer shows: Flags first, each one shipped on its own, and the package swap as the last step, not a big-bang upgrade.
Before single fetch, a client navigation made one HTTP request per matching loader. With single fetch, it makes one request for all of them, and responses are serialized with turbo-stream instead of JSON, so values such as Date, Map, Set, BigInt and promises arrive with their types intact. That is why json() and defer() are no longer needed. Headers now merge differently, so code that relied on per-loader headers needs checking.
What a strong answer shows: They know the serialization change can break code that expected dates as strings, and they test caching headers after turning it on.
typeof loader?In Remix v2, components typed data with useLoaderData<typeof loader>(), which inferred JSON-serialized types. React Router v7 generates a +types module per route, giving typed Route.LoaderArgs with params from the URL pattern, and typed loaderData and actionData props. Renaming a URL param then shows up as a type error.
What a strong answer shows: They have used typegen and know it needs the dev server or react-router typegen to run.
export async function loader({ params }: Route.LoaderArgs) {
const order = await getOrder(params.id); // critical: await it
const history = getOrderHistory(params.id); // slow: don't await
return { order, history };
}
export default function Order({ loaderData }: Route.ComponentProps) {
return (
<>
<OrderSummary order={loaderData.order} />
<Suspense fallback={<HistorySkeleton />}>
<Await resolve={loaderData.history} errorElement={<p>Couldn't load history</p>}>
{(history) => <OrderHistory items={history} />}
</Await>
</Suspense>
</>
);
}
Await the data the page needs to be useful and return the slow promise unawaited. The server streams the HTML shell and fills in the panel when the promise resolves. In Remix v2 without single fetch, the loader wraps the result in defer(). Start both requests before awaiting either, or the slow one only starts after the fast one finishes.
What a strong answer shows: They choose what to defer by what the user needs first, start promises early, and handle the rejected case with errorElement.
The host and everything in front of it must support streamed responses; some proxies and CDNs buffer the whole response. Streamed promises also need a time limit: the server entry sets a stream timeout (ABORT_DELAY in older templates, streamTimeout in v7) after which pending promises reject. Deferred content is also not in the initial HTML, so do not defer content you need for SEO.
What a strong answer shows: They check that the deployment actually streams and keep crawlable content awaited.
A route exports an ErrorBoundary component. When that route's loader, action or render throws, Remix renders the nearest boundary in place of that route while parent layouts keep rendering. Inside the boundary, useRouteError() returns what was thrown, and isRouteErrorResponse() tells an intentional thrown response, such as a 404, apart from an unexpected exception.
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status === 404 ? "Invoice not found" : error.statusText}</p>;
}
return <p>Something went wrong loading this invoice.</p>;
}
What a strong answer shows: They throw responses for expected cases such as not found or forbidden, and keep unexpected errors generic for users while logging details on the server.
It must render a full document, because it replaces the root route. Use the root Layout export so the boundary and the app share the same <html>, <head>, <Meta />, <Links /> and <Scripts />. Report errors from the server entry's error handler or the framework's error hook to monitoring, and never show stack traces in production.
What a strong answer shows: They have seen a root boundary lose styles because it rendered outside the document shell, and know how to prevent it.
<Form> instead of onSubmit with fetch?<Form> submits to the route's action using normal HTML form semantics, so it works before JavaScript loads and degrades to a plain form post. With JavaScript, Remix intercepts it, calls the action with fetch, revalidates loaders and manages pending state. You get progressive enhancement, no hand-written API route, and no manual cache updates.
What a strong answer shows: They can explain progressive enhancement as a reliability feature, not only an accessibility one, and use useNavigation() for pending UI.
useFetcher instead of <Form>?When the interaction should not change the URL: a like button, inline editing of a row, a combobox that loads suggestions, or many independent submissions on one page. Each fetcher has its own state and data, can call any route's loader or action, and still triggers revalidation after an action.
What a strong answer shows: They pick <Form> for navigations and fetchers for in-place mutations, and know fetchers handle race conditions by cancelling stale loads.
function TodoItem({ todo }) {
const fetcher = useFetcher();
const done = fetcher.formData
? fetcher.formData.get("done") === "true"
: todo.done;
return (
<fetcher.Form method="post" action={`/todos/${todo.id}`}>
<input type="hidden" name="done" value={String(!done)} />
<button aria-pressed={done}>{done ? "Done" : "Mark done"}</button>
</fetcher.Form>
);
}
While the submission is in flight, fetcher.formData holds what was sent, so the UI shows the expected result at once. When the action finishes, revalidation returns the real value. If the action fails, the optimistic value disappears and the server value shows again.
What a strong answer shows: They derive optimistic state from the pending submission instead of copying it into local state, and handle the error case.
Give each submit button name="intent" with a different value, read formData.get("intent") in the action and branch on it. Validate each branch's fields with a schema library such as Zod, return field errors with a 400 status so the form can show them, and redirect after destructive changes.
What a strong answer shows: A clear intent pattern, server-side validation that does not trust the client, and correct status codes.
Remix and React Router framework mode are built on the web Fetch API's Request and Response. An adapter translates between that and a specific runtime. Options include Node with Express or the built-in app server (@remix-run/express or @react-router/express, and @react-router/serve), Cloudflare Workers and Pages, AWS through Architect, and host-maintained presets for platforms such as Vercel and Netlify. Choose by runtime needs: Node for native modules and long-lived database connections, edge runtimes for global latency on lighter workloads.
What a strong answer shows: They know edge runtimes limit Node APIs and database drivers, and that moving runtimes is mostly an adapter swap because app code uses web standards.
Set Cache-Control through the route's headers export or response headers, for example a short s-maxage with stale-while-revalidate for public pages. Keep personalized pages private. With nested routes, decide how parent and child headers combine, and remember that loader data requests are cacheable too.
What a strong answer shows: They never cache responses that set cookies or show user data at a shared cache, and they verify headers in the deployed environment.
Remix provides createCookie and session storage factories, such as cookie, memory, file and Cloudflare KV storage, plus custom storage for a database or Redis. Read the session in loaders and actions with getSession(request.headers.get("Cookie")), and write it back with a Set-Cookie header from commitSession. Sign cookies with rotating secrets and set httpOnly, secure and sameSite.
What a strong answer shows: They know cookie sessions have a size limit, how secret rotation works, and why sameSite=lax helps against CSRF when actions only run on non-GET requests.
useEffect inside Remix routes instead of using loaders.onSubmit with fetch for every form and loses revalidation and progressive enhancement.Run a 90-minute pairing session on a prepared repo: a Remix or React Router v7 app with a projects list, a project detail route nested under a dashboard layout, and an edit form. Seed it with problems: a child loader with no auth check, a component that fetches in an effect, a slow loader that blocks the whole page, a form built with onSubmit and fetch, and no error boundary on the detail route. Ask the candidate to fix what matters most, explaining priorities as they go.
<Form> or a fetcher with pending and error states.If you need engineers who already think this way, hire senior React and Remix developers through Ryz. They come from the top 1% of candidates we interview, work on your team and in your repos, and work on US business hours. Our vetting process shows how we evaluate them, and you can adapt our React developer job description for a Remix posting.
Interview for the concepts, which are the same: loaders, actions, nested routes and forms. Name the version your team runs and accept answers in either vocabulary. A candidate strong in Remix v2 can pick up React Router v7 framework mode quickly, because most changes are imports, types and route config.
Next.js experience transfers well for server rendering and React fundamentals, but Remix's data model is different: loaders and actions per route, revalidation after writes, and forms that work without JavaScript. Ask one or two loader and action questions to see whether a Next.js candidate adjusts quickly.
Six to eight. Pick two from loaders and actions, one on routing, one on the React Router v7 upgrade if you are migrating, and one each on streaming, errors and forms. Then spend the remaining time on a short live exercise.
Questions we didn't answer? Email info@ryzlabs.com.