Remix Jokes with a decoupled Node.js API
This project came to be because of an excellent tutorial made by the Remix Team called Jokes. I highly recommend it.
I wanted to see if I could have an API running on a server and other front ends running on separate servers. This is the “boring and proven” way it has been done for years. After completing the Jokes tutorial and appreciating how elegant Remix’s data loading and mutation patterns are, I had a question that kept nagging at me: what happens when the Remix app is not the only consumer of my data? What if I need a React Native app, an admin dashboard, or a third-party integration all talking to the same backend? That question led me to build this project.
Why Would I Want a Separate API?
Even though Remix is a full-stack web framework and you can have an awesome experience deploying just one application that handles everything (including Resource Routes), I prefer to have the API decoupled from the frontend. This way I can have other frontend projects decoupled from Remix like a React Native app or maybe a Next.js admin app.
Having a decoupled API also lets me deploy it on a different server or serverless functions.
Another benefit of having the API/APIs decoupled is that there is no “vendor lock-in” to a particular front end stack. I’m excited about Remix and I see an amazing future for it, but I like to try new things and I love experimenting with the front end, so I want to have a reliable boring API and experiment with the front ends.
Let me break down the key reasons in more detail:
Multiple consumers. In real-world applications, your web frontend is rarely the only thing that needs to talk to your data. You might have a mobile app, an internal admin tool, a partner integration, or even a CLI tool. When your API is baked into your Remix loaders and actions, all of that logic lives inside the Remix application. To share it, you would need to extract it into shared modules or duplicate it. With a separate API, every client speaks the same language: HTTP requests to well-defined endpoints.
Independent scaling. A Remix frontend and a Node.js API have very different resource profiles. The frontend handles server-side rendering, which is CPU-bound work. The API handles database queries and business logic, which tends to be I/O-bound. When they are decoupled, you can scale them independently. If your API is under heavy load from a mobile app while web traffic is low, you can scale the API instances without touching the frontend.
Team autonomy. In larger teams, having a clear boundary between frontend and backend allows different developers or teams to work independently. The API contract becomes the interface between them. As long as the endpoints return the expected data shapes, either side can evolve without coordination.
How the Decoupled Architecture Works
The architecture is straightforward but effective. Here is how the pieces fit together:
┌─────────────────────┐
│ Remix Frontend │
│ (SSR + Client) │
└────────┬─────────────┘
│ HTTP requests
▼
┌─────────────────────┐
│ Express API │
│ (Node.js + TS) │
└───┬────────────┬─────┘
│ │
┌────────▼──┐ ┌────▼──────┐
│ PostgreSQL │ │ Redis │
│ (RDS) │ │ (Refresh │
│ │ │ Tokens) │
└────────────┘ └───────────┘
The Remix app handles all server-side rendering and client-side navigation. When a Remix loader needs data, it makes an HTTP request to the Express API instead of querying the database directly. Similarly, when a Remix action processes a form submission, it sends the data to the Express API.
The Express API is a standard REST API. It handles authentication, business logic, data validation, and database operations through Prisma. Redis is used specifically for managing the JWT refresh token blacklist.
What’s This Project About?
In the following example, we have a Remix app from the Jokes Tutorial that connects to the User and Jokes API made with Express + PostgreSQL.
The Express Server Setup
The Express server is the backbone of the backend. Here is a simplified version of how it is structured:
import express from "express";
import cors from "cors";
import { authRouter } from "./routes/auth";
import { jokesRouter } from "./routes/jokes";
import { usersRouter } from "./routes/users";
import { authenticateToken } from "./middleware/auth";
const app = express();
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") }));
app.use(express.json());
// Public routes
app.use("/api/auth", authRouter);
// Protected routes
app.use("/api/jokes", authenticateToken, jokesRouter);
app.use("/api/users", authenticateToken, usersRouter);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`API server running on port ${PORT}`);
});
The key detail here is the authenticateToken middleware. Every request to the jokes or users endpoints must include a valid JWT in the Authorization header. The middleware verifies the token, checks that it has not been revoked, and attaches the user information to the request object.
Auth
I also included JWT auth with Refresh Tokens in Redis. Auth tokens are saved to the cookie session and refreshed through “authenticated loader API calls” in Remix. You can find how this is done in the public GitHub repo.
The authentication flow works like this:
- The user logs in through the Remix frontend, which sends credentials to the Express API.
- The API validates the credentials and returns an access token (short-lived, typically 15 minutes) and a refresh token (longer-lived).
- Remix stores both tokens in a cookie session (HTTP-only, secure).
- On subsequent requests, Remix loaders include the access token when calling the API.
- When the access token expires, the Remix loader uses the refresh token to obtain a new pair of tokens.
- When a user logs out, the refresh token is added to a blacklist in Redis, preventing it from being used again.
This pattern is more complex than Remix’s built-in session management, but it gives us a stateless authentication mechanism that works across any frontend client. A React Native app or a Next.js admin panel can use the exact same auth endpoints.
The API also contains authorized routes for the user and joke models.
Deployment
The backend also contains config to deploy in AWS Elastic Beanstalk.
The Postgres DB is deployed to AWS RDS.
Even though I opted for this deployment, since it’s one of the most common and proven technology stacks, you can probably deploy it anywhere you want.
Deployment is where the decoupled architecture really shows its flexibility. Because the Express API is just a Node.js application, it can run anywhere Node.js runs: AWS Elastic Beanstalk, a Docker container on ECS or Kubernetes, a DigitalOcean droplet, or even serverless with some adaptation. Similarly, the Remix frontend can be deployed to Vercel, Netlify, Cloudflare Workers, or any Node.js hosting.
In my setup, I chose AWS Elastic Beanstalk for the API because it provides a straightforward deployment pipeline with auto-scaling, health monitoring, and easy environment variable management. The PostgreSQL database runs on AWS RDS, which handles backups, patching, and replication. This is the “boring and proven” stack I keep coming back to, it just works, and when something goes wrong, there are years of documentation and community knowledge to draw on.
Backend
Node.js Backend deployed with AWS Elastic Beanstalk and RDS:
- Typescript
- Auth Server (JWT with Access and Refresh tokens)
- Redis Server (for refresh tokens blacklist)
- Authenticated routes.
- Prisma
- PostgreSQL
- AWS Elastic Beanstalk + AWS RDS deployment (boring and proven)
Frontends
We have a Remix app that connects to the backend. We can deploy it to different places since it’s just a Remix project.
From this project we could also add more frontend apps that share to the same backend like a React Native app, giving us not only a lot of freedom to add, remove or change frontends but deploy each part in different technologies, like having the frontend in mobile app stores.
We could also have the Remix app deployed at the Edge using serverless and the backend in multi regions with replicas for the PostgreSQL DB and then completely change it to a different infrastructure in the future as the project scales and changes requirements.
How Remix Talks to the Express API
One of the interesting aspects of this setup is how Remix’s loader and action functions interact with the external API. Here is a simplified example of what a Remix loader looks like when it fetches data from the Express API:
// app/routes/jokes.tsx
import type { LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getAuthTokens, refreshTokens } from "~/utils/auth.server";
export const loader: LoaderFunction = async ({ request }) => {
const tokens = await getAuthTokens(request);
if (!tokens) {
// User is not authenticated, redirect to login
throw new Response("Unauthorized", { status: 401 });
}
let response = await fetch(`${process.env.API_URL}/api/jokes`, {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
// If the access token has expired, try to refresh it
if (response.status === 401 && tokens.refreshToken) {
const newTokens = await refreshTokens(tokens.refreshToken);
if (newTokens) {
response = await fetch(`${process.env.API_URL}/api/jokes`, {
headers: {
Authorization: `Bearer ${newTokens.accessToken}`,
},
});
}
}
const jokes = await response.json();
return json({ jokes });
};
This pattern gives us the best of both worlds. Remix handles the server-side rendering and provides an excellent developer experience for building the UI, while the Express API provides a stable, reusable backend that any client can consume.
Benefits and Trade-offs
No architecture is without trade-offs. Here is an honest assessment of what works well and what adds complexity.
Benefits:
- Complete freedom to add, remove, or swap frontend clients without touching the backend.
- Independent deployment, scaling, and monitoring for each service.
- Clear separation of concerns that makes the codebase easier to reason about as it grows.
- The backend is framework-agnostic. If Remix disappears tomorrow, the API is unaffected.
Trade-offs:
- Additional network latency. Every data request now involves an HTTP round-trip from the Remix server to the Express API. In practice, when both services are in the same region, this adds only a few milliseconds, but it is something to be aware of.
- More operational complexity. Instead of deploying one application, you are deploying at least two, plus a database and Redis. This means more infrastructure to manage, more logs to monitor, and more things that can fail.
- Type sharing becomes harder. When the API and the frontend are in the same codebase, TypeScript types flow naturally. In a decoupled setup, you need a strategy for keeping types in sync.
When to Use This Pattern
This decoupled approach makes the most sense when you expect multiple frontends to consume the same API, or when you want maximum flexibility to change your frontend stack in the future. For smaller projects or prototypes where you know the Remix app will be the only consumer, using Remix’s built-in data handling is simpler and faster to build.
Monorepo and Multirepo
I made two projects. One is a monorepo and the other is a multirepo.
Monorepo project
-
The monorepo project shares Prisma generated Typescript types because it shares the Prisma Client dependency in the root node_modules folder.
-
TODO: GitHub Actions for remix-jokes-backend have not been tested since it’s now a monorepo.
The monorepo approach turned out to be my preferred setup. Having the frontend and backend in the same repository makes it easy to share TypeScript types generated by Prisma, run integration tests across both services, and manage the project as a single unit. Tools like Turborepo or Nx can help manage the build pipeline in larger monorepos, but for this project size, a simple workspace configuration in package.json was sufficient.
Multirepo project
- TODO: Because this is not a monorepo, we cannot generate the types for Prisma in the common root node_modules folder that includes @prisma/client. The current solution is to use the remix-jokes-frontend/app/prisma.d.ts file that contains a copy of the generated types but we hace to do this manually. There are multiple solutions for this that include things such as: npm package, git submodule, symlinks, and many more.
The multirepo approach makes more sense in organisations where different teams own different services and need independent release cycles. The type-sharing challenge is the biggest friction point, and in practice, publishing types as an internal npm package or using a code generation tool from an API schema tends to be the cleanest solution.
Lessons Learned
Building this project taught me several things that I carry into every new project:
Start with the API contract. Before writing any code, define what your API endpoints will look like. What data do they accept? What do they return? This forces you to think about the domain model upfront and prevents the kind of ad-hoc endpoint design that becomes hard to maintain.
Do not over-engineer the auth. JWT with refresh tokens is a well-understood pattern, but it has its complexities. For many projects, a simpler session-based approach would suffice. I implemented JWT because I wanted to learn the pattern thoroughly and because it works well for multi-client architectures, but I would not reach for it by default on every project.
Boring infrastructure is a feature. PostgreSQL, Redis, Express, these are not exciting technologies. They are not going to generate buzz on social media. But they are reliable, well-documented, and well-understood. When something goes wrong at two in the morning, you want to be debugging your application logic, not your infrastructure.
Check out the Remix Express Decoupled project for more details.