\
\ \
IntroductionI 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:
I didn’t want to pre-build it, as I didn’t want to poison the repositories with more configuration files.
\
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.
\
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:
GitHub RepositoryYou can access the full implementation here.
Step-by-Step Requirements\ 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:
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).
pnpm dlx create-turbo@latest 1. Configure PNPM WorkspacesWe 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.
\
E.g:
/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.jsonAdd the dev command to scripts
Add the packageManager to option
\
\
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.
\
\ Install Postgres at the workspace level. See the Pitfall section.
pnma add postgres -w\ Notes:
Notes
Notes:
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\
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\ 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/utilsCreate 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"], }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"\
\
25. Add the libs/db Repository to Our Remix ProjectFrom 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 ContainerIf 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 -dThe -d flag tells docker-compose to run detached so you can have access to your terminal again.
29. Generate the DB SchemaNow, navigate to the libs/db repository and run db:generate.
cd `./libs/db` && pnpm db:generateWe 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\
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 RecordNote 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" } } Extras Creating a .npmrc FileIn 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=trueThis will tell pnpm to use the workspace packages first.
\ Thanks to ZoWnx from Reddit who helped me craft a .nprmc file
Pitfalls -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!
\
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 RepositoryYou 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:
All Rights Reserved. Copyright , Central Coast Communications, Inc.