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:
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!
I’m building an automated testing platform in public to catch those 1% errors in production.
I share my progress on:
You can access the full implementation here.
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:
libs
and packages
directories.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
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.
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.
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.
apps
Folder in the Root of the Projectmkdir apps
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.
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"
}
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"
}
}
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.
Add the dev
command to scripts
Add the packageManager
to option
{
"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
}
}
pnpm dev
pnpm dev
mkdir -p libs/config libs/db libs/utils
src/index.ts
for Each of the Packages.touch libs/config/src/index.ts libs/db/src/index.ts libs/utils/src/index.ts
libs/config/package.json
File:{
"name": "@repo/config",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
libs/db/package.json
:{
"name": "@repo/db",
"version": "1.0.0",
"exports": {
".": {
"import": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
libs/utils/package.json
:{
"name": "@repo/utils",
"version": "1.0.0",
"exports": {
".": {
"import": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
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:
--filter=@repo/db
flag tells pnpm to add the package to the db repository.pnpm add dotenv -w
Notes
-w
flag tells pnpm to install it at the root’s package.jsonpnpm add @repo/config -r --filter=!@repo/config
Notes:
-r
flag tells pnpm to add the package to all the repositories.--filter=!
flag tells pnpm to exclude the config repository.!
before the package nameIf 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
tsconfig.json
Inside Libs/ConfigUsing 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,
}
}
// 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"
}
}
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"],
}
@repo/configs
is used as the path to refer to our tsconfig.base.lib.json.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
libs/db
Directorytouch "libs/db/.env"
Add the following contents:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
NODE_ENV="development"
MODE="node"
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.
Add this code to the libs/utils/src/index.ts
file:
// libs/utils/src/index.ts
export function hellowWorld() {
return "Hello World!";
}
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
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.
Now, navigate to the libs/db repository and run db:generate
.
cd `./libs/db` && pnpm db:generate
db:generate
is an alias for: drizzle-kit generate
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
db:migrate
is an alias for: dotenv tsx ./drizzle/migrate
// 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} />;
}
apps/my-remix-cloudflare-app/.dev.vars
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
Launch the postgres instance (if not ready)
docker-compose up -d
Launch the project
pnpm turbo dev
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"
}
}
.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=true
This will tell pnpm to use the workspace packages first.
Thanks to ZoWnx from Reddit who helped me craft a .nprmc file
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!!!
You can access the full implementation here.
I’m building an automated testing engineer in public to catch those 1% errors in production.
I share my progress on: