How toUse Action Routes in React Router

The "Action Routes" pattern is a way to use Resource Routes in React Router to handle specific actions like creating, updating, or deleting resources, centralizing the logic in a single file that can define a server action, a client action, or both.

The main idea is that if you have an action you want to trigger from different parts of your app, you can define it in one file and reuse it in different components or routes. I usually recommend moving the logic to a separate file and reusing it in the routes that render the forms or fetchers triggering the action.

In some cases, actions are triggered from multiple UI routes. In those cases, you may want to share not just the logic, but the entire action, including authentication, form validation, different responses based on the result, and even client-side effects like showing a toast or redirecting the user.

Let’s see how to implement this pattern in React Router.

Configure Action Routes in React Router

First, create a routes/actions folder. I call it actions because it will contain all the actions of the app, but you can name it however you want. Inside, create one file per action. For example, for a "create post" action, create post-create.ts.

How you add these routes to routes.ts depends on your setup, but if you're using the flat routes convention, I usually do this:

import type { RouteConfig } from "@react-router/dev/routes";
import { prefix } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

const [routes, actionRoutes] = await Promise.all([
  flatRoutes({ rootDirectory: "./routes" }),
  flatRoutes({ rootDirectory: "./routes/actions" }),
]);

export default [
  ...routes,
  ...prefix("/actions", actionRoutes),
] satisfies RouteConfig;

This will load routes from the routes folder and from routes/actions, and it will prefix the action routes with /actions. For example, the post-create action will be available at /actions/post-create.

Create a Server Action with Validation

Create the post-create.ts file in the routes/actions folder. I like using the noun-verb.ts convention, where the noun is the resource (post, user, etc.) and the verb is the action (create, update, delete, etc.).

You can use any naming convention, but I find this one clear and easy to organize, especially since it groups actions by resource.

If you're not using flat routes, you could also go with noun/verb.ts (like post/create.ts), but in my experience flat routes are easier to manage and understand.

Example:

export async function action({ request }: Route.ActionArgs) {
  let user = await authenticate(request);

  if (!user) {
    return unauthorized({ message: "You must be logged in to create a post." });
  }

  let formData = await request.formData();

  let result = z
    .object({ title: z.string(), content: z.string() })
    .safeParse(Object.fromEntries(formData));

  if (!result.success) {
    return badRequest({
      message: "Invalid input data",
      errors: z.treeifyError(result.error),
    });
  }

  let post = await Post.create({
    userId: user.id,
    title: result.data.title,
    content: result.data.content,
  });

  return created({ message: "Post created successfully", post });
}

This authenticates the user, validates the input with Zod, creates a post, and returns a 201 response.

Those helpers like unauthorized, badRequest, and created are just functions I use to return typed responses. Here's how created might look:

import { data } from "react-router";

type ResponseInitWithoutStatus = Omit<ResponseInit, "status">;

const StatusCode = {
  Created: 201 as const,
  // Add more status codes as needed
};

export function created<T>(value: T, init?: ResponseInitWithoutStatus) {
  return data(
    { ...value, status: StatusCode.Created },
    { ...init, status: StatusCode.Created }
  );
}

Note that the created function returns a data object with the value and a status code of 201. You can create similar functions for other status codes like badRequest, unauthorized, etc.

Handle Client-Side Effects After Actions

Sometimes you want to show a toast or redirect the user after an action. You can do that with a clientAction function in the same file:

export async function clientAction({
  serverAction,
  params,
}: Route.ClientActionArgs) {
  let result = await serverAction();

  if (result.status < 300) {
    toast.success(result.message);
    return redirect(href("/posts/:postId", { postId: result.post.id }));
  } else if (result.status >= 400) {
    toast.error(result.message);
  }

  return result;
}

This pattern:

  1. Calls the server action
  2. Shows a success toast and redirects on success
  3. Shows an error toast on failure
  4. Returns the result so it can be used in the UI

Translate Action Messages with i18n

If your app supports i18n, you can use it directly in your action:

import { i18n } from "~/middleware/i18n";

export async function action({ request, context }: Route.ActionArgs) {
  let { t } = i18n(context);

  return badRequest({ message: t("actions.createPost.errors.generic") });
}

This way the action takes care of translation, and the toast will display a localized message.

Handle Auth and Permissions to Actions

If you use middleware for authentication, you can skip that part inside the action:

export async function action({ request, context }: Route.ActionArgs) {
  let user = await getUser(context);
  // You assume the user is already authenticated
}

You still need to handle authorization. For example:

if (!(await hasActiveSubscription(user))) {
  return forbidden({ message: "You don't have permission to create a post." });
}

In this case, hasActiveSubscription checks whether the user has access to perform the action and returns a 403 if not.

Final Thoughts

As your app grows, centralizing actions helps reduce duplication, enforce consistency, and separate concerns more clearly. Treating actions as first-class routes gives you better composability, easier testing, and more flexible reuse, especially when combined with typed responses, shared validations, and controlled side effects.

This pattern isn't about abstracting for the sake of it. It's about designing for long-term maintainability.