Shopify app notes · 03

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.

React Router v7

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:

ExportRunsJob
loaderOn the server, before the page showsRead data
actionOn the server, when a form is submittedWrite data
defaultIn the browserShow the UI
Remember this and the rest follows

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.

FileURL
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

PatternMeansExample
$nameA changing value in the URLapp.products.$id.jsx → /app/products/42
_indexThe page at exactly this pathapp.products._index.jsx → /app/products
$.jsxCatch-all: matches anything belowauth.$.jsx → /auth/anything/here
folder/route.jsxSame 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
};
Trap — URL values are strings, and untrusted

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":

app/routes/app.jsx (the parent)
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.

The practical consequence

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().

app/routes/app.products._index.jsx
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.

Trap — everything a loader returns is sent to the browser

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.

app/routes/app.products.create.jsx
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>
  );
}
The 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.

submit → action runs → loaders re-run → page updates

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.

Choosing quickly

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.

app/routes/webhooks.app.uninstalled.jsx
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.

Trap — fetching a page route and getting HTML

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 vs return

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.

app/routes/app.tags._index.jsx
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 name attribute.
  • 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