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 theusersarray) - POST via
action()(creating and returning a new user)
| Method | URL | Route file | Handler | Purpose |
|---|---|---|---|---|
| GET | /api/users | api.users.tsx | loader | List users |
| POST | /api/users | api.users.tsx | action | Create a user |
| GET | /api/users/:id | api.users.$id.tsx | loader | Fetch one user |
| PUT/PATCH/DELETE | /api/users/:id | api.users.$id.tsx | action | Replace, 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.
- The
loader()here returnsjson(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.
- The
request.methodcheck is a defensive guard: Remix routes can technically receive multiple HTTP methods, and this action explicitly only supportsPOSTright now. If anything else hits this endpoint, the route returns a405 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 atry/catchensures 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 theid. 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.
- The first
ifensures the payload includesnameandemail. If either is missing (or empty), we return a400 Bad Requestwith 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.
Math.max(...users.map(u => u.id), 0) + 1finds the largest existing ID and adds 1. The extra0argument 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 (likename,email, and any otherUserfields exceptid). 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
catchreturns{ error: 'Invalid request body' }with400. 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.
isBusyprevents 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/jsonand serializes withJSON.stringify. This is exactly what youraction()expects, because it reads the payload usingawait request.json(). - For
GET, it removes thebodyfield 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.
- The UI validates
nameandemailbefore 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. isActiveis derived from a select input and converted to a boolean (isActiveInput === "true"). That matters because your API’srequest.json()will parse booleans correctly if they’re sent as JSON booleans, not as strings.roleis 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,
handleRequestsetsContent-Type: application/json, and your backendaction()reads the payload viaawait 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).
