How to Create a Monorepo With Vite, Cloudflare, Remix, PNPM and Turborepo (No Build Step) | HackerNoon

Introduction

I needed a way to use Remix with Vite and Cloudflare Workers-Pages with minimal configuration.

I saw other repositories, such as:

But they had some limitations:

  1. I didn’t want to pre-build it, as I didn’t want to poison the repositories with more configuration files.

  2. Cloudflare Workers/Pages has a different target. It became tricky to target it with tsup, as packages such as Postgres would pull the node dependencies breaking when imported into Remix.

  3. I also needed a way to consume different targets (Remix-Cloudflare, Node/Bun)

Nonetheless, I thank them, as they paved the way for making this possible!

Be sure to read the pitfalls section at the bottom!

Follow Me on Social!

I’m building an automated testing platform in public to catch those 1% errors in production.

I share my progress on:

X/Twitter @javiasilis

LinkedIn @javiasilis

GitHub Repository

You can access the full implementation here.

Step-by-Step

Requirements

  1. NodeJS
  2. PNPM
  3. Docker (Optional – For Local database example)

Although this walks you through a new mono-repository, it is perfectly valid to transform an existing one into one.

This will also assume that you have some mono repo knowledge.

Note:

  • “at root” refers to the beginning path of your monorepository. For this project, it will be outside the libs and packages directories.

Install Turborepo

Turborepo works on top of your package manager’s workspaces to manage your project’s scripts and outputs (it can even cache your output). So far, it is the only mono-repo tool besides Rush (Which I haven’t tried and don’t like) that is capable of working.

NX doesn’t have Remix’s Vite support (as of this writing – Aug 28, 2024).

https://turbo.build/

pnpm dlx create-turbo@latest

1. Configure PNPM Workspaces

We will use PNPM’s workspace capabilities to manage dependencies.

On your Monorepo directory, create a pnpm-workspace.yaml.

Inside it, add:

packages:
  - "apps/*"
  - "libs/*"

This will tell pnpm that all the repositories will lie inside apps and libs. Note that using libs or packages (as you may have seen elsewhere) doesn’t matter.

2. Create an Empty package.json at the Root of the Project:

pnpm init
{
  "name": "@repo/main",
  "version": "1.0.0",
  "scripts": {},
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Note the name:@repo/main This tells us that this is the main entry of the application. You don’t need to follow a particular convention or use the @ prefix. People use it to differentiate it from local/remote packages or to make it easy to group it into an organization.

3. Create the turbo.json File in the Root of the Project:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {},
    "dev": {
      "cache": false,
      "persistent": true
    },
    "start": {
      "dependsOn": ["^build"],
      "persistent": true
    },
    "preview": {
      "cache": false,
      "persistent": true
    },
    "db:migrate": {}
  }
}

The turbo.json file tells the turbo repo how to interpret our commands. Everything that lies inside the tasks key will match those found in the all package.json.

Note that we define four commands. These match the ones in the script section of each repository’s package.json. However, not all package.json must implement these commands.

E.g: The dev command will be triggered by turbo dev , and it will execute all the packages which dev is found within package.json. If you don’t include it in turbo, it won’t execute.

4. Create an apps Folder in the Root of the Project

mkdir apps

5. Create a Remix App in the apps Folder (Or Move an Existing One)

npx create-remix --template edmundhung/remix-worker-template

When it asks you to Install any dependencies with npm say no.

6. Rename the name of the package.json to @repo/my-remix-cloudflare-app (Or Your Name)

{
-  "name": "my-remix-cloudflare-app",
+  "name": "@repo/my-remix-cloudflare-app",
  "version": "1.0.0",
  "keywords": [],
  "author": "",
  "license": "ISC"
}

7. Copy the Dependencies and devDependencies From apps/<app>/package.json to the Root’s package.json

E.g:

<root>/package.json

{
  "name": "@repo/main",
  "version": "1.0.0",
  "scripts": {},
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@markdoc/markdoc": "^0.4.0",
    "@remix-run/cloudflare": "^2.8.1",
    "@remix-run/cloudflare-pages": "^2.8.1",
    "@remix-run/react": "^2.8.1",
    "isbot": "^3.6.5",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20240222.0",
    "@octokit/types": "^12.6.0",
    "@playwright/test": "^1.42.1",
    "@remix-run/dev": "^2.8.1",
    "@remix-run/eslint-config": "^2.8.1",
    "@tailwindcss/typography": "^0.5.10",
    "@types/react": "^18.2.64",
    "@types/react-dom": "^18.2.21",
    "autoprefixer": "^10.4.18",
    "concurrently": "^8.2.2",
    "cross-env": "^7.0.3",
    "eslint": "^8.57.0",
    "eslint-config-prettier": "^9.1.0",
    "husky": "^9.0.11",
    "lint-staged": "^15.2.2",
    "msw": "^2.2.3",
    "postcss": "^8.4.35",
    "prettier": "^3.2.5",
    "prettier-plugin-tailwindcss": "^0.5.12",
    "rimraf": "^5.0.5",
    "tailwindcss": "^3.4.1",
    "typescript": "^5.4.2",
    "vite": "^5.1.5",
    "vite-tsconfig-paths": "^4.3.1",
    "wrangler": "^3.32.0"
  }
}

8. Add Turbo as a devDependency at the Root Level (This Will Install All the Remix’s Packages).

Verify that turbo is inside package.json’s devDependencies. If it isn’t listed, execute the following command:

pnpm add turbo -D -w

The -w flag tells pnpm to install it at the workspace root.

9. Add the Following Entries to the Root package.json

{
  "name": "@repo/main",
  "version": "1.0.0",
  "scripts": {
    "dev": "turbo dev"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "[email protected]",
  "dependencies": {
    // omitted for brevity
  },
  "devDependencies": {
    // omitted for brevity
  }
}

10. Verify That Everything Is Working by Running pnpm dev

pnpm dev

11. Create a Libs Folder at the Root of the Project. Add config, db, and utils:

mkdir -p libs/config libs/db libs/utils

12. Add a src/index.ts for Each of the Packages.

touch libs/config/src/index.ts libs/db/src/index.ts libs/utils/src/index.ts
  • The index.ts file will be used to export all the packages.
  • We will use the folder as our entry point to make everything compact.
  • This is a convention, and you can choose not to follow it.

13. Create an Empty package.json, and Add the Following to the libs/config/package.json File:

{
  "name": "@repo/config",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./src/index.ts",
      "default": "./src/index.ts"
    }
  }
}

14. Do the Same for libs/db/package.json:

{
  "name": "@repo/db",
  "version": "1.0.0",
  "exports": {
    ".": {
      "import": "./src/index.ts",
      "default": "./src/index.ts"
    }
  }
}

15. And libs/utils/package.json :

{
  "name": "@repo/utils",
  "version": "1.0.0",
  "exports": {
    ".": {
      "import": "./src/index.ts",
      "default": "./src/index.ts"
    }
  }
}
  • We specify the “exports” field. This tells other repositories where to import the package from.
  • We specify the “name” field. This is used to install the package and refer it to other repositories.

16. (DB) – Add Drizzle and Postgres

Notes:

  • I’m beginning to despise ORMs. I’ve spent over 10 years learning 6 different ones, and it’s knowledge you can’t transfer.

  • You have problems when new technologies come out. Prisma doesn’t support Cloudflare workers out of the box.

  • With LLMs, it’s easier than ever to write complex SQL queries.

  • Learning SQL is a universal language and will not likely change.

pnpm add drizzle-orm drizle-kit --filter=@repo/db

Install Postgres at the workspace level. See the Pitfall section.

pnma add postgres -w

Notes:

  • The --filter=@repo/db flag tells pnpm to add the package to the db repository.

17. Add the dotenv to the Workspace Repository

pnpm add dotenv -w

Notes

  • The -w flag tells pnpm to install it at the root’s package.json

18. Add the Config Project to All the Projects.

pnpm add @repo/config -r --filter=!@repo/config

Notes:

  • The -r flag tells pnpm to add the package to all the repositories.
  • The --filter=! flag tells pnpm to exclude the config repository.
  • Note the ! before the package name

19. (Optional) Do the Commands Above Don’t Work? Use .npmrc

If pnpm is pulling the packages from the repository, we can create a .npmrc file at the root of the project.

.npmrc

link-workspace-packages= true
prefer-workspace-packages=true
  • This will tell pnpm to use the workspace packages first.
  • Thanks to ZoWnx from Reddit, who helped me craft a .nprmc file

20. Configure the Shared tsconfig.json Inside Libs/Config

Using the power of pnpm workspaces, you can create config files that can be shared across projects.

We will create a base tsconfig.lib.json that we will use for our libraries.

Inside libs/config instantiate a tsconfig.lib.json:

touch "libs/config/tsconfig.base.lib.json"

Then, add the following:

tsconfig.base.lib.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "target": "ES2022",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "allowImportingTsExtensions": true,
    "allowJs": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "incremental": true,
    "composite": false,
    "declaration": true,
    "declarationMap": true,
    "inlineSources": false,
    "isolatedModules": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "preserveWatchOutput": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,

  }
}

21. Add the db Connection to the db Repository.

// libs/db/drizzle.config.ts (Yes, this one is at root of the db package, outside the src folder)
// We don't want to export this file as this is ran at setup.

import "dotenv/config"; // make sure to install dotenv package
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  out: "./src/generated",
  schema: "./src/drizzle/schema.ts",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  // Print all statements
  verbose: true,
  // Always ask for confirmation
  strict: true,
});

The schema file:

// libs/db/src/drizzle/schema.ts
export const User = pgTable("User", {
  userId: char("userId", { length: 26 }).primaryKey().notNull(),
  subId: char("subId", { length: 36 }).notNull(),
  // We are not making this unique to support merging accounts in later
  // iterations
  email: text("email"),
  loginProvider: loginProviderEnum("loginProvider").array().notNull(),
  createdAt: timestamp("createdAt", { precision: 3, mode: "date" }).notNull(),
  updatedAt: timestamp("updatedAt", { precision: 3, mode: "date" }).notNull(),
});

The client file:

// libs/db/src/drizzle-client.ts
import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

export type DrizzleClient = PostgresJsDatabase<typeof schema>;

let drizzleClient: DrizzleClient | undefined;

type GetClientInput = {
  databaseUrl: string;
  env: string;
  mode?: "cloudflare" | "node";
};

declare var window: typeof globalThis;
declare var self: typeof globalThis;

export function getDrizzleClient(input: GetClientInput) {
  const { mode, env } = input;

  if (mode === "cloudflare") {
    return generateClient(input);
  }

  const globalObject =
    typeof globalThis !== "undefined"
      ? globalThis
      : typeof global !== "undefined"
        ? global
        : typeof window !== "undefined"
          ? window
          : self;

  if (env === "production") {
    drizzleClient = generateClient(input);
  } else if (globalObject) {
    if (!(globalObject as any).__db__) {
      (globalObject as any).__db__ = generateClient(input);
    }
    drizzleClient = (globalObject as any).__db__;
  } else {
    drizzleClient = generateClient(input);
  }

  return drizzleClient;
}
type GenerateClientInput = {
  databaseUrl: string;
  env: string;
};
function generateClient(input: GenerateClientInput) {
  const { databaseUrl, env } = input;
  const isLoggingEnabled = env === "development";
  // prepare: false for serverless
  try {
    const client = postgres(databaseUrl, { prepare: false });
    const db = drizzle(client, { schema, logger: isLoggingEnabled });
    return db;
  } catch (e) {
    console.log("ERROR", e);
    return undefined!;
  }
}

The migration file:

// libs/db/src/drizzle/migrate.ts
import { config } from "dotenv";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import "dotenv/config";
import path from "path";
config({ path: "../../../../apps/my-remix-cloudflare-app/.dev.vars" });

const ssl = process.env.ENVIRONMENT === "development" ? undefined : "require";

const databaseUrl = drizzle(
  postgres(`${process.env.DATABASE_URL}`, { ssl, max: 1 })
);

// Somehow the current starting path is /libs/db
// Remember to have the DB running before running this script
const migration = path.resolve("./src/generated");

const main = async () => {
  try {
    await migrate(databaseUrl, {
      migrationsFolder: migration,
    });
    console.log("Migration complete");
  } catch (error) {
    console.log(error);
  }
  process.exit(0);
};

main();

This should be executed after the migrations

And export the client and schema in the src/index.ts file. Others are run at specific times.

// libs/db/src/index.ts
export * from "./drizzle/drizzle-client";
export * from "./drizzle/schema "

In your package.json, add the drizzle-kit generate , and the code to run the migration command:

{
  "name": "@repo/db",
  "version": "1.0.0",
  "main": "./src/index.ts",
  "module": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "dotenv tsx ./drizzle/migrate",
  },
  "exports": {
    ".": {
      "import": "./src/index.ts",
      "default": "./src/index.ts"
    }
  },
  "dependencies": {
    "@repo/configs": "workspace:^",
    "drizzle-kit": "^0.24.1",
    "drizzle-orm": "^0.33.0",
 
  },
  "devDependencies": {
    "@types/node": "^22.5.0"
  }
}

22. Use the Shared tsconfig.json for libs/db and libs/utils

Create a tsconfig.json for libs/db and libs/utils

touch "libs/db/tsconfig.json" "libs/utils/tsconfig.json"

Then add to each:

{
  "extends": "@repo/configs/tsconfig.base.lib.json",
  "include": ["./src"],
}
  • See that @repo/configs is used as the path to refer to our tsconfig.base.lib.json.
  • It makes our path clean.

23. Install TSX

TypeScript Execute (TSX) is a library alternative to ts-node. We will use this to execute the drizzle’s migrations.

pnpm add tsx -D --filter=@repo/db

24. Add an Empty .env at the libs/db Directory

touch "libs/db/.env"

Add the following contents:

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
NODE_ENV="development"
MODE="node"

It should look something like this

25. Add the libs/db Repository to Our Remix Project

From the root of the project, run:

pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app

If this doesn’t work, then go to the apps/my-remix-cloudflare-app‘s package.json, and add the dependency manually.

{
  "name": "@repo/my-remix-cloudflare-app",
  "version": "1.0.0",
  "dependencies": {
    "@repo/db": "workspace:*"
  }
}

Note the workspace:* in the version field. This tells pnpm to use any version of the package in the workspace.

If you installed it via the CLI by using pnpm add, you will probably see something like workspace:^. It shouldn’t matter as long as you don’t increase the local package versions.

If you did add this manually, then run pnpm install from the root of the project.

We should be able to consume the @repo/db in our project.

26. Add Some Shared Code to Our Utils:

Add this code to the libs/utils/src/index.ts file:

// libs/utils/src/index.ts
export function hellowWorld() {
  return "Hello World!";
}

27. Install the Libs/Utils to our Remix App:

pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app

28. (Optional) Launch a Postgres From a Docker Container

If you don’t have a Postgres instance running, we can launch one using docker-compose. Note, I am assuming you know Docker.

Create a docker-compose.yml file at the root of the project.

# Auto-generated docker-compose.yml file.
version: '3.8'
# Define services.
services:
  postgres:
    image: postgres:latest
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=postgres
    ports:
      - "5432:5432"
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
  pgadmin:
  # To connect PG Admin, navigate to http://localhost:8500 use:
  # host.docker.internal
  # postgres
  # (username) postgres
  # (password) postgres
    image: dpage/pgadmin4
    ports:
      - "8500:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: [email protected]
      PGADMIN_DEFAULT_PASSWORD: admin

Then you can run:

docker-compose up -d

The -d flag tells docker-compose to run detached so you can have access to your terminal again.

29. Generate the DB Schema

Now, navigate to the libs/db repository and run db:generate.

cd `./libs/db` && pnpm db:generate
  • Note that the db:generate is an alias for: drizzle-kit generate
  • Verify that you have the proper .env.
  • Additionally, this assumes that you have a Postgres instance running.

30. Run the Migrations.

We need to run the migrations to scaffold all the tables within our database.

Navigate to the libs/db repository (if you’re not there) and run db:generate.

cd `./libs/db` && pnpm db:migrate
  • Note that the db:migrate is an alias for: dotenv tsx ./drizzle/migrate
  • Verify that you have the proper .env.
  • Additionally, this assumes that you have a Postgres instance running.

31. Insert a DB Call Inside Your Remix App.

// apps/my-remix-cloudflare-app/app/routes/_index.tsx
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json, useLoaderData } from '@remix-run/react';
import { getDrizzleClient } from '@repo/db';
import { Markdown } from '~/components';
import { getFileContentWithCache } from '~/services/github.server';
import { parse } from '~/services/markdoc.server';

export async function loader({ context }: LoaderFunctionArgs) {
        const client = await getDrizzleClient({
                databaseUrl: context.env.DATABASE_URL,
                env: 'development',
                mode: 'cloudflare',
        });
        if (client) {
                const res = await client.query.User.findFirst();
                console.log('res', res);
        }
        const content = await getFileContentWithCache(context, 'README.md');

        return json(
                {
                        content: parse(content),
                        // user: firstUser,
                },
                {
                        headers: {
                                'Cache-Control': 'public, max-age=3600',
                        },
                },
        );
}

export default function Index() {
        const { content } = useLoaderData<typeof loader>();

        return <Markdown content={content} />;
}
  • Note that we’re not following best practices here.
  • I’d advise you not to make any DB calls directly within your loader, but create an abstraction that calls them.
  • Cloudflare is challenging when it comes to setting the environment variables. They’re passed by request

32. Add in Your .dev.vars the Following:

apps/my-remix-cloudflare-app/.dev.vars

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"

33. Execute the Remix Project!

Launch the postgres instance (if not ready)

docker-compose up -d

Launch the project

pnpm turbo dev

Advanced Use Case – CQRS in GetLoadContext in Cloudflare Workers.

In my projects, I tend to implement a CQRS pattern, 2. This is outside of the scope of this tutorial.

Nonetheless, within the load context, I tend to inject a mediator (and a cookie flash message) that will decouple my entire Remix Application from my business logic.

This looks something like this:

export const getLoadContext: GetLoadContext = async ({ context, request }) => {
  const isEnvEmpty = Object.keys(context.cloudflare.env).length === 0;
  const env = isEnvEmpty ? process.env : context.cloudflare.env;
  const sessionFlashSecret = env.SESSION_FLASH_SECRET;

  const flashStorage = createCookieSessionStorage({
    cookie: {
      name: "__flash",
      httpOnly: true,
      maxAge: 60,
      path: "/",
      sameSite: "lax",
      secrets: [sessionFlashSecret],
      secure: true,
    },
  });

  return {
    ...context,
    cloudflare: {
      ...context.cloudflare,
      env,
    },
    dispatch: (await dispatchWithContext({
      env: env as unknown as Record<string, string>,
      request,
    })) as Dispatch,
    flashStorage,
  };
};

Note that the dispatch code is omitted. You can find more about it in my article on how to 10x your TypeScript dev experience here.

I can strip out Remix or use another consumer without altering my code.

But….

There is an additional challenge when you work in a monorepo structure using turborepo.

If you import a TypeScript file from a package within the load-context, let’s say @repo/db Vite will return an error that the file with extension .ts is unknown, and will not know how to process it.

This happens because load-context + workspaces are outside the site’s main importing graph, leaving TypeScript files outside of play.

The trick is to use tsx and load it before calling Vite, which will work. This is important because it overcomes the following limitations:

Cloudflare Package Dependencies.

Cloudflare Package Dependencies and Pre-building

First of all, that was the step that I was trying to avoid, as it meant that I had to introduce a build step for each of the packages, which meant more configuration.

Fortunately, this didn’t work for Cloudflare Pages. Specific libraries, such as Postgres, will detect the runtime and pull the required package.

There’s a workaround: We can use tsx to load all the TypeScript files and transpile them before we execute.

You can argue this is a pre-build step, but since it’s still at the remix’s repository level, I don’t see significant issues with this approach.

To solve this, we add tsx as a dependency:

pnpm add tsx -D --filter=@repo/my-remix-cloudflare-app

And then, we need to modify our package.json and add the tsx process to each one of our remix scripts:

{
  "name": "@repo/my-remix-cloudflare-app",
  "version": "1.0.0",
  "scripts": {
    // Other scripts omitted
    "build": "NODE_OPTIONS="--import  tsx/esm" remix vite:build",
    "dev": "NODE_OPTIONS="--import tsx/esm" remix vite:dev",
    "start": "NODE_OPTIONS="--import tsx/esm" wrangler pages dev ./build/client"
  }
}

Creating a .npmrc File

In case you’re having issues while adding your local packages with the command line, you can create a .npmrc file in the root of the project.

.npmrc

link-workspace-packages= true
prefer-workspace-packages=true

This will tell pnpm to use the workspace packages first.

Thanks to ZoWnx from Reddit who helped me craft a .nprmc file

Pitfalls –

  1. Careful with naming .client and .server in your files. Even if it’s in a separate library. Remix uses these to determine if it’s a client or server file. The project isn’t compiled per repository so it will throw an import error!

  2. If you’re having problems with multi-platform packages such as Postgres, installing it at the workspace level is better. It will detect the proper import. Installing it directly in the @repo/db repository will break when importing it to Remix.

That’s it, folks!!!

GitHub Repository

You can access the full implementation here.

Follow Me on Social!

I’m building an automated testing engineer in public to catch those 1% errors in production.

I share my progress on:

X/Twitter @javiasilis

LinkedIn @javiasilis