Routes, Loaders & Actions
How a file name becomes a URL, how a page gets its data, and how a form saves it. This is the core of React Router v7 — every note after this one builds on it.
The one idea to hold on to
In a React Router app, one file holds both the server code and the page. That feels strange at first if you are used to keeping a backend and a frontend apart.
Each route file can export three things:
| Export | Runs | Job |
|---|---|---|
loader | On the server, before the page shows | Read data |
action | On the server, when a form is submitted | Write data |
default | In the browser | Show the UI |
Loader reads. Action writes. Default draws.
Loaders and actions never run in the browser, so it is safe to use your database and secret keys there.
File names are URLs
You do not write a routing table. You name files in app/routes/ and the name is the URL. A dot becomes a slash.
| File | URL |
|---|---|
app.jsx | /app |
app.products.jsx | /app/products |
app.products.create.jsx | /app/products/create |
app.settings.billing.jsx | /app/settings/billing |
The four special characters
| Pattern | Means | Example |
|---|---|---|
$name | A changing value in the URL | app.products.$id.jsx → /app/products/42 |
_index | The page at exactly this path | app.products._index.jsx → /app/products |
$.jsx | Catch-all: matches anything below | auth.$.jsx → /auth/anything/here |
folder/route.jsx | Same as a file, but lets you keep related files beside it | _index/route.jsx → / |
You read a $ value from params:
// app/routes/app.products.$id.jsx → /app/products/42
export const loader = async ({ params }) => {
params.id; // "42" — always a string
};
params.id is "42", not 42. Comparing it to a number fails silently, and passing it straight into a database query can crash the page. Anyone can type anything there.
const id = Number(params.id);
if (!Number.isFinite(id)) {
return Response.json({ error: "Invalid id" }, { status: 400 });
}
Nesting: why app. is on everything
Files sharing a prefix are nested. app.products.jsx sits inside app.jsx.
A parent renders its children through <Outlet /> — a placeholder meaning "the child page goes here":
export const loader = async ({ request }) => {
await authenticate.admin(request); // runs for EVERY child page
return { apiKey: process.env.SHOPIFY_API_KEY };
};
export default function Layout() {
return (
<AppProvider embedded apiKey={useLoaderData().apiKey}>
<s-app-nav>
<s-link href="/app/products">Products</s-link>
</s-app-nav>
<Outlet /> // ← the child page renders here
</AppProvider>
);
}
Opening /app/products runs both loaders — the parent's first. That is why the login check is written once and protects everything.
Name a file app.something.jsx and it is automatically behind the login. Name it anything else — webhooks.orders.jsx, proxy.config.jsx — and it is not.
That is not a flaw; it is how webhooks and public endpoints work at all, since Shopify and shoppers have no admin login. Just be deliberate about which side of the line a new file sits on.
Loaders: getting data onto the page
A loader runs on the server before the page renders. Return a plain object; read it with useLoaderData().
import { useLoaderData } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const products = await db.product.findMany({
where: { shop: session.shop }, // only THIS shop's rows
orderBy: { createdAt: "desc" },
});
return { products };
};
export default function ProductList() {
const { products } = useLoaderData();
return (
<s-page heading="Products">
{products.map((p) => <div key={p.id}>{p.title}</div>)}
</s-page>
);
}
When does a loader run? On first page load, on every navigation to it, and again automatically after an action succeeds.
The loader runs on the server, but its return value travels to the browser, where anyone can read it in developer tools.
// DANGEROUS — leaks the shop's access token
return { session };
// Safe — only what the page needs
return { shop: session.shop };
Never return a whole session, a whole user row, or an API key. Pick the fields you actually display.
Reading query strings
export const loader = async ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") || "1");
const query = url.searchParams.get("q")?.trim() || "";
// ... use them in your database query
};
This is how search, filters and pagination work: change the URL, the loader re-runs, the page updates. The URL becomes shareable and the back button works for free.
Actions: saving data
An action runs when a form is submitted. It receives the request, reads the submitted fields, saves them, and usually redirects.
import { Form, redirect, useNavigation } from "react-router";
export const action = async ({ request }) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const title = formData.get("title")?.toString().trim();
// Always validate on the server — the browser can be bypassed.
if (!title) {
return { error: "Title is required" }; // stay on the page
}
await db.product.create({ data: { title, shop: session.shop } });
return redirect("/app/products"); // go somewhere on success
};
export default function CreateProduct() {
const actionData = useActionData();
const navigation = useNavigation();
const saving = navigation.state !== "idle";
return (
<Form method="post">
<input name="title" /> // name = the key in formData
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
</Form>
);
}
name attribute is everything
formData.get("title") only works because the input has name="title". An input without a name is invisible to the server — one of the most common "why is it empty?" bugs.
Values always arrive as strings. Convert numbers yourself.
Return vs redirect
Return an object
Stay on the page. Use for validation errors and inline feedback.
return { error: "Too long" };
Return a redirect
Move to another page. Use after a successful save.
return redirect("/app/products");
Several buttons, one action
A page often needs Save and Delete. Both submit to the same action, so send a hidden value telling it which — conventionally called an intent.
export const action = async ({ request, params }) => {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await db.product.delete({ where: { id: Number(params.id) } });
return redirect("/app/products");
}
// otherwise: save
};
<Form method="post">
<button name="intent" value="save">Save</button>
<button name="intent" value="delete">Delete</button>
</Form>
The part that surprises everyone: automatic refresh
After an action finishes, React Router re-runs the loaders on the page automatically and re-renders with fresh data.
You never write "reload the list after saving" — it already happened.
So there is no refetch(), no manually updating a list in state after a delete. Delete a row, and the list that showed it re-renders without it.
Form vs useFetcher
Two ways to submit. The difference is whether you want to navigate.
<Form>
A real navigation. The URL can change, back button works.
Use for: create and edit pages, anything that moves you somewhere after saving.
useFetcher()
Submits in the background. You stay exactly where you are.
Use for: a toggle, a delete button in a list, a quick inline save.
const fetcher = useFetcher();
// send data without leaving the page
fetcher.submit({ intent: "toggle", id: "42" }, { method: "post" });
fetcher.state // "idle" | "submitting" | "loading"
fetcher.data // whatever the action returned
Both trigger the automatic loader refresh above.
Should the page change after this? Use <Form>. Should the user stay put? Use useFetcher.
Routes with no page
A route file with no default export returns data instead of HTML. These are called resource routes, and Shopify apps are full of them.
export const action = async ({ request }) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`${topic} for ${shop}`);
return new Response(); // 200 = "got it"
};
// no default export → no page
Use them for webhooks (Shopify calling you), app proxy endpoints (the storefront calling you), and JSON endpoints your own pages fetch.
If you fetch() a route that has a default export, you get an HTML document back, and response.json() throws a confusing parse error.
Anything you intend to call with fetch needs its own file with no default export.
When things go wrong
Inside a loader or action you can throw a response to stop everything immediately:
if (!product) {
throw new Response("Not Found", { status: 404 });
}
An ErrorBoundary export then renders instead of the page. Shopify's template gives you one:
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers = (args) => boundary.headers(args);
Put those in your layout route and every page below inherits them.
throw = stop now, show an error. return = normal result the page will handle. Redirects are usually returned, but throw redirect(...) is handy deep inside a helper where you want to abandon everything immediately.
A complete page
List, create and delete in one file — the shape most of your pages will take.
import { Form, useFetcher, useLoaderData, useNavigation } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const tags = await db.tag.findMany({ where: { shop: session.shop } });
return { tags };
};
export const action = async ({ request }) => {
const { session } = await authenticate.admin(request);
const form = await request.formData();
const intent = form.get("intent");
if (intent === "delete") {
await db.tag.deleteMany({
where: { id: Number(form.get("id")), shop: session.shop },
});
return { ok: true };
}
const name = form.get("name")?.toString().trim();
if (!name) return { error: "Name is required" };
await db.tag.create({ data: { name, shop: session.shop } });
return { ok: true };
};
export default function Tags() {
const { tags } = useLoaderData();
const fetcher = useFetcher();
const busy = useNavigation().state !== "idle";
return (
<s-page heading="Tags">
<Form method="post">
<input name="name" placeholder="New tag" />
<button disabled={busy}>{busy ? "Adding…" : "Add"}</button>
</Form>
{tags.map((tag) => (
<div key={tag.id}>
{tag.name}
<button onClick={() => fetcher.submit(
{ intent: "delete", id: String(tag.id) },
{ method: "post" }
)}>Delete</button>
</div>
))}
</s-page>
);
}
Notice what is missing: no useState for the list, no useEffect to fetch, no manual refresh after adding or deleting. The loader is the single source of truth and re-runs by itself.
Common mistakes
"db is not defined" in the browser console
You used db inside the component instead of the loader. Database code only works in loader and action. Fetch in the loader, pass the result down.
My form submits but nothing saves
- The inputs have no
nameattribute. - The form is missing
method="post"— without it the loader runs, not the action. - You used a plain
<form>instead of the imported<Form>.
My list doesn't update after deleting
Usually the list is in useState instead of coming from the loader. Render straight from useLoaderData() and the refresh is automatic.
Unexpected token '<' in JSON
You fetched a route that returns a page. HTML starts with <. Move that endpoint into its own file with no default export.
Do loaders run on every keystroke or render?
No. Only on navigation, and after an action. Rendering does not re-run them.
Cheat sheet
# naming
app.products.jsx /app/products
app.products.$id.jsx /app/products/:id params.id
app.products._index.jsx /app/products (exact)
auth.$.jsx /auth/anything
no default export → JSON endpoint, no page
# in the file
loader read data → useLoaderData()
action write data → useActionData()
default the UI
# reading input
params.id from the URL path
new URL(request.url).searchParams from ?query=
await request.formData() from a form
# submitting
<Form method="post"> navigates
useFetcher() stays put
# after an action
loaders re-run automatically — no manual refresh