React Router for Full-Stack Product Workflows

A route is not only a URL. In a product app, a route often represents a task the user is trying to finish.

That sounds obvious, but it changes how I design the code. A settings page that starts an import is not just a screen. It has current state, validation, permission checks, a submit action, a redirect, and a failure state. If those pieces are scattered, the workflow becomes harder to reason about.

React Router loaders and actions give a simple way to keep the page boundary explicit.

export async function loader() {
  return getImportStatus();
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const result = await startImportFromForm(formData);

  return redirect(`/settings/imports/${result.jobId}`);
}

The example is small, but it shows the shape I like. The loader answers: what does the user need before seeing this page? The action answers: what happens when the user submits the form?

The route should not own every decision. If an uploaded file needs validation, permissions, an idempotency key, or a background job, I prefer to move that into a service. The route should coordinate the web boundary. The service should own the product rule.

That keeps the route readable:

const result = await startImportFromForm(formData);

The route does not need to know every detail of import processing. It needs to know how to turn a user request into the next user-visible state.

This is where route design becomes product design. After the action starts a slow task, the user should not stay on a frozen form. A redirect to a status page gives the user an answer: the request was accepted, it has a job id, and the app can show whether it is queued, running, done, or failed.

The first version of a workflow often starts inside one action because that is the fastest way to ship it. That is fine. The warning sign is when the action becomes the only place where the rule exists. At that point, it becomes harder to test without a browser and harder to reuse from another entry point.

I usually look for these boundaries:

  • the route handles form data, response shape, redirects, and route-level errors
  • the schema validates external input
  • the service decides what the workflow means
  • the repository or database layer handles persistence
  • the UI shows the state in a way the user can act on

React Router is a good fit when the workflow maps naturally to pages and forms. It is less helpful if most interactions are highly realtime, canvas-based, or mostly local state. In those cases, the route may be a thin shell around a more interactive client.

The trade-off is that loaders and actions can make routes feel powerful enough to hold everything. I try to resist that. A thin route with a clear service behind it is usually easier to test and easier to change.

The practical test is simple: if I can explain the user task by reading the route and test the business rule without rendering the page, the boundary is probably in a good place.

Related Posts

Astro for Documentation and a Professional Site

I use Astro because this site is mostly writing. I do not need a heavy app framework for pages that should load fast and be easy to edit. That sounds simple, but it is the mai

read more

Localization in Product Apps

Localization is not only replacing English strings with another language. In a product app, language touches workflow. It changes labels, validation messages, dates, empty states, permissions copy, d

read more

MCP as a Safe AI Integration Boundary

MCP is interesting because it makes AI integrations feel less like prompt magic and more like software boundaries. That is the part I care about. A model should no

read more

Zod, OpenAPI, and Swagger for API Contracts

A public API is not just backend code. It is a product surface for another developer. That means the contract has to be readable. It also has to be enforced at runtime. Types in the app are useful, b

read more

pg-boss for Durable Background Jobs

The customer problem was not "we need a queue". The problem was that a slow operation made the user wait with no clear answer. That distinction matters. A queue is an implementation detail. The produ

read more

Pragmatic Drag and Drop for Real Ordering Tasks

Drag and drop is easy to add for a demo and harder to make reliable for real work. The product question is not "can the item move on screen?" The question is whether the user can safely change an ord

read more

Prisma and PostgreSQL as the Product Source of Truth

I do not think of PostgreSQL as only infrastructure. In a product app, it is where the product remembers what happened. That makes database design a product decision. I

read more

shadcn-Style UI as an Owned Product System

I like copied UI primitives because they make the component library feel like part of the app, not something the app is borrowing. That is the part of the shadcn/ui-style ap

read more

Dense Operational UI with Tables and Editors

Sometimes a simple form is the wrong UI. If the user needs to compare many values and make careful edits, a table can be kinder than a long page of inputs. Dense UI has a bad reputation when it is us

read more

Vercel AI SDK with Explicit Tool Boundaries

The risky part of an AI feature is not the chat UI. The risky part is what the chat is allowed to do. It is easy to make an assistant feel powerful by giving it tools. With something like the [Vercel

read more

Vertical Slice Architecture with Dependency-Cruiser

I like vertical slices because they make a feature easier to delete, move, or review. The folder structure is not the main value. The value is that the code for one workflow is not spread across ten u

read more

Testing Product Workflows with Vitest and Playwright

I do not want a test suite that only proves functions work. I want it to protect the workflows that would hurt if they broke. That does not mean every rule needs a browser test. Browser tests are val

read more

Zod Beyond Validation

Zod is usually introduced as a validation library. That is true, but the more useful idea is boundary definition. A TypeScript type only helps after data is already inside the pro

read more