API routes and RESTful services in Remix
Exploring different usages of API routes in Remix such as client-side form submissions, receiving webhooks, and exposing a RESTful API to the outside world.
December 6, 2024When building Remix apps, you'll often need API routes for things for client-side form submissions, receiving third-party webhooks or even exposing your own API to the world. This article will show you how to do all three, with examples.
API routes in Remix
First, the term "API route" in Remix is somewhat of misnomer as functionally they're the same as any other route but without the default export
of a JSX component (Remix calls these "resource routes"). Like other Remix routes, resource routes return a web Response object meaning you have full control over the response headers, status codes, and body content, making them perfect for APIs.
Creating an API route
To create an API route in Remix, you simply create a new file in the routes
directory of your project. Let's make a basic API route that returns a JSON response:
import { json } from "@remix-run/node"; export const loader = () => { return json({ message: 'Hello, world!' }); };
Easy! Now let's make it do something useful.
Client-side form submissions
Sometimes you want to perform client-side form submissions without the use of Remix's <Form>
component. Consider the following example where we have a traditional form to verify a user's email address with a verification code, with a separate button to resend the verification code if it hasn't been received.
Since we don't want to revalidate any data, refresh the page or navigate away from the current page, we can use useFetcher
rather than a traditional Remix <Form>
component to submit data directly to our API route.
import { useFetcher} from "@remix-run/react"; import { useEffect, useState } from "react"; export default function AuthVerify() { const formFetcher = useFetcher(); const [errorMessage, setErrorMessage] = useState(); const [successMessage, setSuccessMessage] = useState(); useEffect(() => { if (formFetcher.data.error) { setErrorMessage("Error: " + formFetcher.data.error.message); } else { setSuccessMessage(formFetcher.data.message) } }, [formFetcher.data]); return ( <> <VerifyEmailForm /> <div className={"mt-2 text-center text-sm text-muted-foreground"}> Didn't receive a code?{" "} <button onClick={() => { formFetcher.submit( {}, { action: "/api/verification/resend", method: "post", }, ); }} className="text-primary" > Send again </button> {errorMessage && <div className={"text-danger"}>{errorMessage}</div>} {successMessage && <div className={"text-success"}>{successMessage}</div>} </div> </> ); }
The /api/verification/resend
route in this scenario would look something like this:
import { json } from "@remix-run/node"; export const action = async ({ request }) => { const userId = await getUserIdFromSession(request); sendSignupVerificationEmail(userId); return json({ message: 'Verification code sent!' }); };
Note: Because API routes are no different to other Remix routes, they are publicly accessible and thus should always validate the appropriate permissions within
Receiving webhooks
Another great use case for API routes is receiving webhooks from third-party services. A common example of this is receiving Stripe webhooks for checkout and subscription events.
The following is an example of a app/routes/stripe.webhook.ts
file
async function getStripeEvent(request) { // validate webhook } export const action = async ({ request }) => { const event = await getStripeEvent(request); const eventType = event.type; const data = event.data.object; switch (eventType) { case "checkout.session.completed": await handleCheckoutSessionCompleted(data); break; case "customer.subscription.updated": await handleSubscriptionUpdatedEvent(data); break; case "customer.subscription.deleted": await handleSubscriptionDeleted(data); break; default: break; } return null; };
Note: Again since this route is public, when receiving third-party webhooks you generally always want to validate that the event came from the official source. This process is called webhook signature verification (Stripe example docs)
Exposing an RESTful API to the outside world
Sometimes you may want to offer a public API for your customers, or receive requests from a non-browser client such as a mobile app. As before, this is super easy with Remix using loaders and actions.
I generally always prefix these routes with public
and add a version number to the route to make it clear that this is a public API. For example, app/routes/api.public.v1.todos.ts
.
import { json } from "@remix-run/node"; import { z } from "zod"; export const PublicApiTodoSchema = z.object({ id: z.string(), title: z.string(), description: z.string(), }); export const serializeIssueForApi = (todo) => { return PublicApiTodoSchema.parse(todo); }; const getUserFromApiKey = async (request) => { const apiKeyString = request.headers.get("X-API-Key"); return await getUserFromApiKey(apiKeyString); }; export const loader = async ({ request }) => { const user = await getUserFromApiKey(request); if (!user) { return json({ message: "Invalid API key" }, { status: 401 }); } const todos = await findTodosForUser( user.id, ); const todosList = todos.map(serializeTodosForApi); return json({ todos: todosList }); }; export const action = async ({ request }) => { const user = await getUserFromApiKey(request); if (!user) { return json({ message: "Invalid API key" }, { status: 401 }); } const body = await request.json(); if (!PublicApiTodoSchema.safeParse(body)) { return json({ message: "Invalid request body" }, { status: 400 }); } const newTodo = await createTodoForUser(user.id, body); return json({ todo: serializeTodoForApi(newTodo) }); };
Since these requests don't come from logged-in users, we authenticate them by validating API keys in the request headers. We use Zod to validate both request and response bodies, which is a good practice for all loaders and actions in general, but it's especially crucial for public APIs. Validation ensures the data matches the expected format both when saving to the database and when sending responses to clients, allowing your database model to evolve over time without breaking existing clients.
If we want to get really fancy, we can also use Zod to define an OpenAPI schema so that clients have automatically generated public API documentation like this.
If you're looking for a SaaS starter kit with API routes and automatically generated documentation, check out Launchway. It also offers authentication, subscriptions, database connections, tons of UI components and more to get a SaaS started.