Remix is a full-stack framework for rapidly building production-grade applications. It’s built on top of React and React Router and delivers a fast, slick, and resilient user experience.
Nx is an open source build system that help us to build, develop, test, and deploy multiple apps and libraries into a single repository at any scale, which means we can run multiple Remix apps inside a single Nx monorepo.
Nx ships with a large toolset that simplifies monorepo management. The Nx package also provides technology- and framework-agnostic capabilities like workspace analysis, caching, distribution, task running, code generation, and automated code migrations.
In this article, we will discuss how to build a monorepo application in Nx with Remix. You can find our companion GitHub repository here.
main
branch clean. Moreover, we can enforce naming guidelines, code review standards, and best practices while sharing the same linters and configuration in multiple projectsWe’ll start by creating our project and adding a couple of commands to get into integrating Nx with a Remix application.
Let’s start by creating our project:
mkdir nx-remix cd nx-remix pnpm init
After these commands, we can create our application and structure it more like an app-centric monorepo. For this, we’ll create a couple of directories inside our app:
mkdir apps packages
We now need to configure it properly to recognize this as a workspace. Create a new file and add the following snippet:
touch pnpm-workspace.yaml
Inside this file, we can describe the packages we’ll use throughout the application:
# pnpm-workspace.yaml packages: # executable/launchable applications - 'apps/*' # all packages in subdirs of packages/ and components/ - 'packages/*'
We are all ready to create our blank Remix application. Let’s visit our app directory to scaffold our starter Remix project:
cd apps pnpx create-remix@latest
Let’s give our Remix application the name remix-nx
.
We could run the app inside there, but let’s instead spin the Remix app we created in the root of our monorepo directory.
pnpm has a handy feature that will help us restrict commands to a specific subset of packages, and the selector syntax lets us pick those packages by name or relation. You can learn more about filters in their official docs.
The below command:
pnpm --filter <package_selector> <command>
Ends up being equivalent to the below, in our case:
pnpm -filter remix-nx-app dev *note the project name should be the same as the one defined in the package.json file
We can also create a shared UI library that we can use in any project inside our monorepo. Visit the packages
folder we created earlier and create a new shared folder. Then, initialize a package.json
file for our customization:
cd packages mkdir shared cd shared pnpm init
We can start editing the new package.json
file we have created and mark it private
just to access it locally, within our app. If we intend to use this as a public npm registry package, we can instead mark it true
:
{ "private": "true", "name": "shared", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }
Now, let’s create a React component and consume it inside of our Remix project. We will use React with TypeScript for our shared UI components.
Inside of our shared
folder, install React and TypeScript as dev dependencies with --filtershared
to install it locally:
pnpm add --filter shared react pnpm add --filter shared typescript -D
We can now create a shared component inside our React app, which can then be used inside of our Remix project. Let’s start by calling it Card.tsx
:
import * as React from 'react'; export function Card(props: any) { return ( <div classname="item-wrapper"> <img src={props.image} alt={props.description} /> <h3>{props.heading}</h3> <span>{props.price}</span> <button onClick={() => props.onClick()}>{props.children || 'Add to cart'}</button> </div> ); } export default Card;
We can also add another file that will collectively export the constants we declared. We will create a new file, index.tsx, inside of shared
, as below:
export * from './Card';
We can also create a TypeScript compiler to handle our compilation output created in a packages/shared/tsconfig.json
file with the following configuration:
{ "compilerOptions": { "jsx": "react-jsx", "allowJs": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "module": "commonjs", "outDir": "./dist" }, "include": ["."], "exclude": ["dist", "node_modules", "**/*.spec.ts"] }
The output directory here is pointing to the ./dist
directory:
{ "private": "true", "name": "shared", "version": "1.0.0", "description": "", "main": "dist/index.js" }
When we do actually build the app, we might need to remove the files and folders from the stale build of the previous output. To delete old build files, we can add these lines into our package.json
files under the shared
folder:
"scripts": { "build": "rm -rf dist && tsc", "test": "echo \"Error: no test specified\" && exit 1" }
We can now use the pnpm filter
command at the root of our project to build just the shared project we have with the command below:
pnpm --filter shared build
Let’s register our component with the command below:
pnpm add shared --filter remix-nx-app --workspace
The command will append and add the dependency into the apps/remix-nx/package.json
file.
Below is the updated package.json
file with the new changes:
{ "private": true, "sideEffects": false, "name": "remix-nx-app", "scripts": { "build": "remix build", "dev": "remix dev", "start": "remix-serve build", "typecheck": "tsc" }, "dependencies": { "@remix-run/css-bundle": "^1.19.3", "@remix-run/node": "^1.19.3", "@remix-run/react": "^1.19.3", "@remix-run/serve": "^1.19.3", "isbot": "^3.6.8", "react": "^18.2.0", "react-dom": "^18.2.0", "shared": "workspace:^" }, "devDependencies": { "@remix-run/dev": "^1.19.3", "@remix-run/eslint-config": "^1.19.3", "@types/react": "^18.0.35", "@types/react-dom": "^18.0.11", "eslint": "^8.38.0", "typescript": "^5.0.4" }, "engines": { "node": ">=18.0.0" } }
The term workspace:^
denotes that the package is resolved locally in the workspace, instead of from a remote npm registry. We can now use our card component inside of our Remix application:
import type { V2_MetaFunction } from "@remix-run/node"; import { Card } from 'shared'; export const meta: V2_MetaFunction = () => { return [ { title: "New Remix App" }, { name: "description", content: "Welcome to Remix!" }, ]; }; export default function Index() { return ( <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <h1>Remix app with Nx</h1> <Card image="https://images.unsplash.com/photo-1603178455924-ef33372953bb?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1936&q=80" heading="A flower vase" price="$12.00" onClick={() => console.log('Added to basket')}>Buy item</Card> </div> ); }
To see the reflected changes, we need to build the shared files into the dist build
directory. We can run the command below to output the build files:
pnpm --filter shared build
We can now serve our Remix app and see the shared component we created is reflected inside our app:
pnpm --filter remix-nx-app dev
Below are some handy commands we can use to run the previous command recursively:
# Runs the build for all projects with a single command pnpm run -r build #Runs parallelized commands using --parallel pnpm run --parallel -r build
We will use Nx to run operations across the entire monorepo workspace. When our application becomes bigger, we might need to run tasks only in a specific package, not everything. This is where Nx helps us to cache contents and to determine whether to run any task that is computed before or after.
Let’s install Nx with the following command at the root level:
pnpm add nx -D -w
It is represented with the following syntax:
npx nx <target> <project>
<target>
is the npm script we want to execute in this specific case. Let’s try to run the build for our shared package:
npx nx build shared
With this command, Nx automatically finds the project with the given name and runs the build script. In the same way we can launch our Remix app with:
npx mx dev remix-nx-app
We can use the following to run commands in parallel across projects:
npx nx run-many --target=build --all
And the following to selectively specify individual projects:
npx nx run-many --target=build --projects=my-remix-app,shared-ui
One of the benefits of Nx is its caching speed. It uses computation caching as a feature where the different inputs (source files, .env
variables, command flags, etc.) are stored in a hash-computed local folder. When we run the command next time, it serves the app via cache. All operations are not cacheable; only the side effect-free ones are cached.
To enable caching in our application, create a new file at the root of the project named nx.json
:
{ "tasksRunnerOptions": { "default": { "runner": "nx/tasks-runners/default", "options": { "cacheableOperations": ["build", "test"] } } } }
Inside cacheableOperation
, we can add other options like linting, Git hooks, and more. After we enable this, we can test it by running the below command twice to see the time difference:
npx nx build remix-nx-app
We should then experience a high-millisecond gain in execution time after this implementation.
We can also consider cases like dependent apps inside shared
, which are not compiled and ready to use inside of our Remix server. This can be temporarily tackled by manually building shared
and then the Remix apps. But Nx comes with a targetDefaults
definition called as part of the build pipeline. This will help us to prioritize the build proces by adding a new property, dependsOn
, in the task definition that will look something like:
{ "tasksRunnerOptions": { ... }, "namedInputs": { "noMarkdown": ["!{projectRoot}/**/*.md", "!{projectRoot}/**/*.mdx"] }, "targetDefaults": { "build": { "inputs": ["noMarkdown", "^noMarkdown"] }, "test": { "inputs": ["noMarkdown", "^noMarkdown"] } } }
We also introduced an additional property named noMarkdown
to reuse the glob in multiple places. We can fine-tune the caching by adding a targetDefaults
node in the nx.json
and defining that the default input for the build target should exclude *.mdx
and *.md
files, in this case.
But there might be a case where one project’s files needs to be built first, in order to be consumed by another file. We can get around this by adding the dependsOn
property in the build definition, telling Nx to first run tasks in dependent projects, and running the command we invoked.
Our final nx.json
file would look something like the below:
{ "tasksRunnerOptions": { "default": { "runner": "nx/tasks-runners/default", "options": { "cacheableOperations": ["build", "test"] } } }, "namedInputs": { "noMarkdown": ["!{projectRoot}/**/*.md", "!{projectRoot}/**/*.mdx"] }, "targetDefaults": { "build": { "inputs": ["noMarkdown", "^noMarkdown"], "dependsOn": ["^build"] }, "dev": { "dependsOn": ["^build"] }, "test": { "inputs": ["noMarkdown", "^noMarkdown"] } } }
In this article, we’ve learned how we can leverage Nx to build a monorepo with Remix application. We’ve built a Remix application but using Nx, it’s possible to generate Cypress, React, Vue, Angular, Jest, and Storybook applications using their generators. We have seen how using monorepos can improve the development experience and speed of building applications without the limitations on scaling your project.
Nx is an excellent choice if you are planning to build a standalone or a monorepo project. Nx will definitely be helpful when managing and scaling infinitely.
You can find our full implementation of Nx with Remix in this GitHub repository.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.