Remix, Payload in a Single Express Server Monorepo

Remix Payload monorepo

Payload CMS’ Local API is an incredibly powerful tool when building server-side rendered applications.

In this post, we’ll look at an example of a monorepo with multiple apps; a Remix and Payload application communicating through the Local API, served by the same Express server.

Payload and Remix in a monorepo setup

Recently, there have been many frontend frameworks with SSR (server-side rendering) capabilities released. One of those is Remix, which uses React for it's rendering. Integrating Payload with such framework has, in my case, turned out fantastic. Therefore I wanted to share my thoughts and an example of such setup.

All the code can be found in this repository as a template for your next project. Consult the project documentation for more information about the technologies used, and it's setup.

Why a monorepo?

Since I learned that Payload has a local API, which I certainly find is one of the best features of Payload, I have thought about the best way to use it.

While it is possible to set up a repository with a single package where all your dependencies is installed, this could cause a few problems. React for example, is a peer dependency of Remix, which means that you are installing React yourself in order for Remix rendering to work. What if you wanted to write custom Payload components, and it turns out that Remix and Payload requires different versions of React? You would need to use package manager's aliasing feature as a work around. This would mostly work, but there is a better solution.

A monorepo lets you define multiple packages, where each package requires their own set of dependencies. The Remix application may require version 17 of react, while the Payload application requires react 18. This flexibility in dependency management make it worth paying for a more complex project setup, in this case a monorepo managed using Turborepo.

Why Remix?

The way Payload can be integrated with Remix turns out to be very elegant. In the example repository, since we are running the Remix and Payload instances in the same express server (you guessed it) we can use Payload's Local API.

This is how you integrate Payload with Remix:

// apps/server/index.ts
import payload from 'payload';
import express from 'express';
import { createRequestHandler } from '@remix-run/express';
// Initialize the Payload instance
express: app,
onInit: () => {`Payload Admin URL: ${payload.getAdminURL()}`);
// Pass any request that wasn't handled by Payload to Remix by utilizing it's createRequestHandler
app.all('*', (req, res, next) =>
getLoadContext(req, res) {
return {
payload: req.payload,
user: req?.user,
})(req, res, next)

By returning the Payload instance from Remix’s getLoadContext, we can use Payload Local API from any Remix Action or Loader (which is used to load and mutate data on the server side in Remix).

export const loader: LoaderFunction = async ({
context: { payload, user },
}): Promise<LoaderData> => {
if (!user) {
return { users: [], authenticatedUser: undefined };
const { docs: users } = await payload.find({
collection: 'users',
overrideAccess: false
return { users, authenticatedUser: user };

A subtle but significant advantage of this integration is that Remix don't have to know about or bundle a single line of code from the Payload project in order to work. This is because the Payload instance is passed to Remix during runtime.

The user in above example is actually originates from Payload as well, meaning we are using the Payload authentication system in our Remix application. This is accomplished by adding the Payload authentication middleware to Remix's Express routes.

Read more about this in the Payload documentation.

Local API

Apart from not having to use HTTP in order to communicate with your CMS and the performance benefits that comes with, this is also noteworthy:

The local API gives us the ability to bypass access control, when needed!

Imagine having a statistics collection which only administrators have CRUD (create, read, update and delete) access to. How would you register usage statistics for users other than administrators?

When using the HTTP API's there are of course solutions to this, like authorizing the statistics API through the usage of ab API key instead (yes, Payload have you covered here as well) or using collection hooks. But it certainly is a lot easier to simply bypass the Payload access control in the local API:

const response = await payload.create({
collection: 'statistics',
data: {
// ...
overrideAccess: true

While this functionality should be used with care, it is really nice to have available in your tool set.


Like NextJS, Remix is using a file based routing system. This means that you can create a file in the app/routes folder and Remix will automatically create a route for it. The example project have a Pages collection that we would like to use as pages in our application that the user should be able to navigate between.

In order for our Remix application to use the Pages collection as routes, we will use the following routing features in Remix:

The Pages collection has a slug field which is automatically populated based on the title (through a beforeChange hook), which we can use as a dynamic segment in our route.

First of all, let's create a dynamic route in Remix: project └── apps └── web └── app └── routes └── $page.tsx Now we simply need to fetch the correct page from Payload and render it.

import { Response } from '@remix-run/node';
import type { Page } from '@org/cms';
import type { TypedResponse, LoaderFunction } from '@remix-run/node';
export type LoaderData = {
pages: Page[];
export const loader: LoaderFunction = async ({
context: { payload, user },
}): Promise<LoaderData | TypedResponse<never>> => {
const { pathname } = new URL(request.url);
if (pathname === '/') {
// Redirect to the home page if no route path is used. This could as easily have been a Payload Global.
return redirect('/home');
const { pageSlug } = params;
const { docs: [page] } = await payload.find({
collection: 'pages',
overrideAccess: false,
where: { slug: { equals: pageSlug }},
if (!page) {
return new Response('Not found', { status: 404 });
return { page };

With the loader defined, the page can now be used inside our route component through Remix`s useLoaderData hook. If you are interested in an example of this, take a look at the example project. We do have a small issue here though, even though we try to redirect any user visiting the "/" route, this will currently not happen. This is because Remix is mapping the "/" route to an index route, which we currently don't have.

To resolve this we can either:

  • Create an index route which allow the user to navigate to the other pages, or;
  • Simply re-export the default loader and component from our dynamic $page route

The second option looks like:

// project/app/routes/index.tsx
export { default } from './$page';

Now, our $page route will be rendered when the user visits the "/" route as well. In the example project, we have refactored the loader above from being defined in the $page route into the root route instead. This is simply because we want to be able to create a navigation menu with all the pages, not only the current page the user is visiting.

Even though we fetch the pages in the root route, we can still access them in the $page route through the useMatches hook:

import type { RootLoaderData } from '~/root';
export default function Page() {
const { page: pageSlug } = useParams();
const [{ data }] = useMatches();
const { pages, user } = data as RootLoaderData;
const page = pages?.find((page) => page.slug === pageSlug);
// ...

Layout Route

Remix have a concept of Layout Routes, which is a way to wrap your routes with a layout component. A Layout Route is simply a react component that wraps your subroutes. This is useful for things like a navigation bar, or a footer. Since we already have a $page route that acts like a global route for all our pages, we want to add a layout route to wrap the $page route. Layout routes are named the same as the static part of subroutes they wrap. This is a problem for us, since we only have a dynamic route.

In order to solve this, we can use a Pathless Layout Route. The file structure looks like this:

project └── apps └── web └── app └── routes └── __page └── $page.tsx // Dynamic route └── index.tsx // Re-export the default loader and component from our dynamic `$page` route └── __page.tsx // Pathless Layout Route

In the example project we render the navigation menu in the Layout Route, and the page content in the $page route.


So, you have done a good job defining fields in your collections in regards to SEO, such as page title, description and keywords, right?

In Remix we can simply export a meta function from a route component, and when it renders, it will automatically add the meta tags that we return from the function to the page. Since we have already loaded all the pages in the root route, we can export a meta function like this, from the $page route:

import type { MetaFunction } from '@remix-run/node';
export const meta: MetaFunction = ({ parentsData, params }) => {
const { page: pageSlug } = params;
const {
root: { pages },
} = parentsData;
const page = pages?.find((page) => page.slug === pageSlug);
return {
title: page?.meta.title,
description: page?.meta.description,
keywords: page?.meta.keywords,

Access Control

We automatically seed two users and two pages into the database on the first startup, one user with the role admin and one with the role user. The admin user can access the everything (including the admin UI), and the user user can only access the public pages in the Remix application.

Try logging in to the Remix application as the user user, and you will see that you can only access the home page, while the admin user can access all the pages. This is all handled in the access control functions in Payload.

The developer experience of defining a single source of truth for you access control, which then cascades down to your API, admin UI and Remix application is amazing, in my personal opinion.

We can't wait to see what you build with it!


Lastly, here are a few tips and tricks that I have found useful when working with Payload and Remix—

Typed Remix context:

// In a *.d.ts file that is loaded in your tsconfig.json
// User is coming from Payload's genereteTypes feature, based on the Users collection
declare module '@remix-run/node' {
interface AppLoadContext {
payload: Payload;
user: {
user?: User;
token?: string;
exp?: number;
res: Response;

Let Payload set a User on the response object, when performing the Login operation through the Local API:

// In a Remix login action
export const action: ActionFunction = async ({
context: { payload, user, res },
}) => {
// ... other code
await payload.login({
collection: 'users',
data: { email, password },
res, // <-- Pass the response object to Payload like this
return redirect('/');