Creating Users with POST

Creating Users with POST in a Remix API Route

Welcome! In this lesson, we’re going to add the “create” part of a simple Users API. You already have a Remix route serving data—now you’ll learn how that same route can accept data from the client and add a new user to the in-memory store.

You’ll work in app/routes/api.users.tsx to handle POST /api/users, validate the incoming JSON payload, generate a new user ID, and return the newly-created user with the right HTTP status code. Then you’ll see how the UI in app/routes/_index.tsx triggers that POST request so you can test everything right in the app.

Previously…

In the previous lesson, you served a list of users from an API route and learned how query parameters can shape a response. That’s the “read” side of an API. Now we’ll build the “create” side—accepting JSON input from the frontend and pushing a new record into the same in-memory users collection.

Where POST logic lives in Remix

In Remix API routes, loader() is for read-only requests (typically GET), and action() is for requests that change data (like POST, PUT, PATCH, and DELETE). In this project, both live in the same file: app/routes/api.users.tsx.

That means /api/users supports:

  • GET via loader() (returning the users array)
  • POST via action() (creating and returning a new user)
MethodURLRoute fileHandlerPurpose
GET/api/usersapi.users.tsxloaderList users
POST/api/usersapi.users.tsxactionCreate a user
GET/api/users/:idapi.users.$id.tsxloaderFetch one user
PUT/PATCH/DELETE/api/users/:idapi.users.$id.tsxactionReplace, partially update, or delete one user

Returning all users with the loader

This first block is the GET handler in app/routes/api.users.tsx. It returns the current in-memory users array as JSON.

// app/routes/api.users.tsx
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
import { users, type User } from '~/lib/data';

/**
 * Handles GET requests to /api/users.
 * Retrieves and returns a list of all users.
 */
export async function loader() {
  console.log('GET request to /api/users');
  return json(users);
}
  • The loader() here returns json(users), which means the response body is the raw array (not wrapped in { users: ... }). That’s important because the UI expects the endpoint to behave like a straightforward JSON API.
  • Even though Remix can pass { request } into a loader, this implementation doesn’t need the request object, because it always returns the full array exactly as it exists in memory.
  • The console.log(...) is a lightweight way to make requests visible while you’re developing and testing. When you click buttons in the UI, you can confirm which endpoint/method was hit.

Handling POST in the action: method guard + parsing

Now we’ll move to the action() in app/routes/api.users.tsx. This first chunk is responsible for two key things: ensuring the request is actually POST, and reading JSON from the request body safely.

// app/routes/api.users.tsx
/**
 * Handles POST requests to /api/users.
 * Creates a new user and adds it to the data store.
 */
export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== 'POST') {
    return json(
      { error: 'Method not allowed' },
      { status: 405, headers: { Allow: 'GET, POST' } }
    );
  }

  try {
    const newUser: Omit<User, 'id'> = await request.json();
  • The request.method check is a defensive guard: Remix routes can technically receive multiple HTTP methods, and this action explicitly only supports POST right now. If anything else hits this endpoint, the route returns a 405 Method Not Allowed.
  • await request.json() parses the incoming request body as JSON. If the client sends invalid JSON (or no body), parsing can throw—so wrapping it in a try/catch ensures we respond with a controlled error instead of crashing the request.
  • The type Omit<User, 'id'> communicates an important API design idea: the client should not send the id. The server owns ID generation, so the payload should only include the other user fields.

Important: TypeScript annotations do not validate request bodies at runtime. request.json() returns untrusted data, so real APIs should check field types and allowed keys before trimming, spreading, or storing values.

Validating required fields and email format

Once the JSON is parsed, the route performs basic validation: it requires name and email, and it checks that the email looks like an email address.

// app/routes/api.users.tsx
    // Basic validation
    if (!newUser.name || !newUser.email) {
      return json({ error: 'Name and email are required' }, { status: 400 });
    }

    // Email validation (basic regex)
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(newUser.email)) {
      return json({ error: 'Invalid email format' }, { status: 400 });
    }
  • The first if ensures the payload includes name and email. If either is missing (or empty), we return a 400 Bad Request with a clear error message that the client can show to the user.
  • The email regex is intentionally simple: it doesn’t try to perfectly validate every possible email address, but it catches obvious mistakes (missing @, missing domain, etc.). For a learning project, this strikes a good balance between realism and readability.
  • Returning early with json(..., { status: 400 }) keeps the action’s control flow easy to follow: validate, or stop. That pattern becomes even more valuable once you add more rules later.

Creating the user: generating an ID, storing it, returning 201

After validation passes, we generate a new numeric ID, build the final User object, push it to the in-memory array, and return the created resource.

// app/routes/api.users.tsx
    // Generate a new ID
    const newId = Math.max(...users.map(u => u.id), 0) + 1;
    const userWithId: User = {
      id: newId,
      name: newUser.name,
      email: newUser.email,
      role: newUser.role ?? 'user',
      isActive: newUser.isActive ?? true,
      createdAt: new Date().toISOString()
    };

    users.push(userWithId);
    console.log(`POST request to /api/users - created user id=${newId}`);
    return json(userWithId, { status: 201 });
  } catch (error) {
    return json({ error: 'Invalid request body' }, { status: 400 });
  }
}
  • Math.max(...users.map(u => u.id), 0) + 1 finds the largest existing ID and adds 1. The extra 0 argument is a small but important edge-case fix: if the array were empty, Math.max(...) would otherwise misbehave—this guarantees a starting point.
  • const userWithId: User = { id: newId, ...newUser } cleanly combines server-generated data (id) with client-supplied data (like name, email, and any other User fields except id). This keeps the “server owns IDs” rule crystal clear in the code.
  • Returning json(userWithId, { status: 201 }) uses the correct REST-style status code for “created.” That status is useful for clients and debugging (and your UI prints it in the result panel).
  • The catch returns { error: 'Invalid request body' } with 400. This specifically covers cases where JSON parsing fails or the body can’t be interpreted the way the server expects.

How the UI sends POST requests

The API route is only half the story. In this project, the homepage (app/routes/_index.tsx) includes a small “API Playground” UI that sends requests with fetch() and prints the response.

You don’t need to memorize every UI detail, but you do want to understand:

  • how requests are built (handleRequest)
  • how GET all users is triggered (handleGetAll)
  • how POST create user is triggered (handleCreate)

Playground helper note: the request helper, HTML fallback parsing, and response panel exist to make API testing convenient in this learning environment. Later lessons will focus on the backend route behavior and only briefly reference the helper.

The shared request helper: `handleRequest`

This excerpt from app/routes/_index.tsx is the reusable request function. It sets headers, serializes the body when needed, calls fetch, then prints a summary including the response status and parsed payload.

// app/routes/_index.tsx (excerpt)
const handleRequest = async (
  method: HttpMethod,
  path: string,
  body?: RequestPayload
) => {
  if (isBusy) return;

  setPendingAction(method);
  setError(null);
  setResult("Sending request...");

  try {
    const headers = new Headers({
      Accept: "application/json",
      "X-Requested-With": "XMLHttpRequest",
      "X-Remix-Data": "true"
    });

    let requestBody: string | undefined;
    if (body && Object.keys(body).length > 0) {
      headers.set("Content-Type", "application/json");
      requestBody = JSON.stringify(body);
    }

    const requestInit: RequestInit = {
      method,
      headers,
      credentials: "same-origin",
      body: requestBody
    };

    if (method === "GET") {
      delete requestInit.body;
    }

    const response = await fetch(path, requestInit);
    // ...parsing & UI rendering below...
  } finally {
    setPendingAction(null);
  }
};
  • isBusy prevents overlapping requests, which keeps the UI output consistent and avoids “races” where one response overwrites another unexpectedly.
  • When a body exists, the helper sets Content-Type: application/json and serializes with JSON.stringify. This is exactly what your action() expects, because it reads the payload using await request.json().
  • For GET, it removes the body field entirely. Some environments ignore GET bodies, and some treat them strangely—so this keeps GET requests clean and predictable.
  • The response parsing (not shown in full here) is robust: it checks content-type, parses JSON when possible, and still displays something readable even if HTML sneaks in.

GET all users: building query params and calling the endpoint

POST create user: validating inputs and sending JSON

The POST create user button calls handleCreate() in app/routes/_index.tsx. This is the client-side mirror of what your server expects: it ensures name and email exist, then sends a JSON body to /api/users.

// app/routes/_index.tsx (excerpt)
const handleCreate = () => {
  setError(null);
  const trimmedName = name.trim();
  const trimmedEmail = email.trim();

  if (!trimmedName || !trimmedEmail) {
    showValidationError("Name and email are required to create a user.");
    return;
  }

  const payload: RequestPayload = {
    name: trimmedName,
    email: trimmedEmail,
    isActive: isActiveInput === "true"
  };

  if (roleInput.trim()) {
    payload.role = roleInput.trim();
  }

  void handleRequest("POST", USERS_ENDPOINT, payload);
};
  • The UI validates name and email before sending the request, which saves time and gives instant feedback. Even with frontend validation, the backend still validates too—because clients can’t be trusted to always send good data.
  • isActive is derived from a select input and converted to a boolean (isActiveInput === "true"). That matters because your API’s request.json() will parse booleans correctly if they’re sent as JSON booleans, not as strings.
  • role is optional and only included if it’s not blank. This keeps the request payload minimal, and it mirrors how real clients often send optional fields only when they’re meaningful.
  • When you click the button, handleRequest sets Content-Type: application/json, and your backend action() reads the payload via await request.json(). That’s the complete end-to-end POST flow in this project.

Recap

In this lesson, you implemented user creation via POST using Remix’s action() in app/routes/api.users.tsx. You also connected that server behavior to the UI in app/routes/_index.tsx, where handleCreate() builds the JSON payload and handleRequest() sends it to /api/users.

You can now test the full flow by entering a name and email in the playground and clicking POST create user—then watching the response summary show a 201 status and the created user object (including the server-generated id).

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal