visit
Be sure to read the pitfalls section at the bottom!
Note:
libs
and packages
directories.
pnpm dlx create-turbo@latest
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": "//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.
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
{
"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"
}
}
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:
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.
link-workspace-packages= true
prefer-workspace-packages=true
tsconfig.json
Inside Libs/Config
Inside libs/config
instantiate a tsconfig.lib.json
:
touch "libs/config/tsconfig.base.lib.json"
{
"$schema": "//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,
});
// 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(),
});
// 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!;
}
}
// 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"
{
"extends": "@repo/configs/tsconfig.base.lib.json",
"include": ["./src"],
}
@repo/configs
is used as the path to refer to our tsconfig.base.lib.json.pnpm add tsx -D --filter=@repo/db
libs/db
Directorytouch "libs/db/.env"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
NODE_ENV="development"
MODE="node"
libs/db
Repository to Our Remix Projectpnpm 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.
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
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 //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
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
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} />;
}
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
docker-compose up -d
pnpm turbo dev
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.
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.
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 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.
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.
link-workspace-packages= true
prefer-workspace-packages=true
This will tell pnpm to use the workspace packages first.
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!
That’s it, folks!!!