├── .dev-example.vars ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── app.css ├── entry.server.tsx ├── root.tsx ├── routes.ts ├── routes │ ├── home.tsx │ └── static-page.tsx └── welcome │ ├── logo-dark.svg │ ├── logo-light.svg │ └── welcome.tsx ├── database └── schema.ts ├── drizzle.config.ts ├── drizzle ├── 0000_outstanding_trauma.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── load-context.ts ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── react-router.config.ts ├── tailwind.config.ts ├── tsconfig.cloudflare.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── worker-configuration.d.ts ├── workers └── app.ts └── wrangler.toml /.dev-example.vars: -------------------------------------------------------------------------------- 1 | # These values are required to make drizzle studio work when viewing remote database (pnpm db:studio:production) 2 | CLOUDFLARE_ACCOUNT_ID="" 3 | CLOUDFLARE_DATABASE_ID="" 4 | # Enter values that are supposed to be kept secert in this file below this line 5 | # Don't check them in or add them to wrangler.toml 6 | CLOUDFLARE_TOKEN="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | 8 | # Cloudflare 9 | .dev.vars 10 | .mf 11 | .wrangler 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matthew Lynch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to React Router on Cloudflare Workers with D1! 2 | 3 | A modern, production-ready template for building full-stack React applications using React Router, hosted on Cloudflare Workers with D1 as the database. 4 | 5 | You can quickly create a new React Router application from this template by running: 6 | 7 | ``` 8 | npx create-react-router@latest --template matthewlynch/react-router-cloudflare-d1 9 | ``` 10 | 11 | Some of the code in this repo was adapted from the [React Router Cloudflare D1 template](https://github.com/remix-run/react-router-templates/tree/main/cloudflare-d1). 12 | 13 | ## Features 14 | 15 | - 🚀 Server-side rendering 16 | - ⚡️ Hot Module Replacement (HMR) 17 | - 📦 Asset bundling and optimization 18 | - 🔄 Data loading and mutations 19 | - 🔒 TypeScript by default 20 | - 🎉 TailwindCSS for styling 21 | - 🟧️ Setup to deploy to Cloudflare Workers 22 | - 📊 Cloudflare D1 database for production and SQLite database for local development 23 | - 📜 Pre-render routes at build time 24 | - 🌍 Separate environments for preview and production 25 | - 📟 [`cloudflareDevProxy`](https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/cloudflare-proxy-plugin.ts) to make Cloudflare bindings work locally 26 | - 📖 [React Router docs](https://reactrouter.com/) 27 | - 📖 [Cloudflare Workers docs](https://developers.cloudflare.com/workers/) 28 | - 📖 [D1 database docs](https://developers.cloudflare.com/d1/) 29 | 30 | ## Getting Started 31 | 32 | 1. Run `cp .dev-example.vars .dev.vars` to create an .env file you can use to override variables defined in `wrangler.toml` or set secret values you don't want to check into source control 33 | 2. Update the `name` field in `wranlger.toml` 34 | 3. Install dependencies `pnpm install` 35 | 4. Create a database by running [`wrangler d1 create `](https://developers.cloudflare.com/d1/wrangler-commands/#d1-create) and update `wranlger.toml` with the UUID and name for the database 36 | 1. Create an additional database for the "preview" environment and update `env.preview.d1_databases` in `wranlger.toml` with the UUID and name for the preview database 37 | 2. OR delete `env.preview*` if you don't want to deploy a preview version of your app 38 | 5. Add your Cloudflare Account ID/Database UUID/Token to `.dev.vars` (you only need this when you want to view data via Drizzle Studio for your remote database) 39 | 6. Run `pnpm typegen` any time you make changes to `wranlger.toml` to ensure types from bindings and module rules are up to date for type safety 40 | 41 | ### Development 42 | 43 | Run an initial database migration: 44 | 45 | ```bash 46 | pnpm db:migrate 47 | ``` 48 | 49 | Start the development server with HMR: 50 | 51 | ```bash 52 | pnpm dev 53 | ``` 54 | 55 | Your application will be available at [`http://localhost:5173`](http://localhost:5173). 56 | 57 | ### Database 58 | 59 | You can develop against a local SQLite database then push changes to your remote D1 database. 60 | 61 | #### Workflow 62 | 63 | 1. Make changes to the schema in `./database/schema.ts` 64 | 2. Run `pnpm db:generate` to generate SQL migration files 65 | 3. Run `pnpm db:migrate` to apply the generated migration files to your local SQLite database 66 | 4. Run `pnpm db:migrate:production` to apply the changes to your remote D1 database 67 | 68 | #### Viewing data (via Drizzle Studio) 69 | 70 | Run `pnpm db:studio` to browse data in your local database on disk or `pnpm db:studio:production` to browse your remote D1 database. 71 | 72 | You need to have added your Cloudflare Account ID/Database UUID/Token to `.dev.vars` if you want to run `pnpm db:studio:production`. 73 | 74 | #### Creating a Cloudflare token for Drizzle Studio 75 | 76 | 1. Log in to Cloudflare and visit https://dash.cloudflare.com/profile/api-tokens and click "Create Token" 77 | 2. Scroll down to "Create Custom Token" and click on "Get Started" 78 | 3. Enter a name for the token 79 | 4. "Permissions" needs to be set to "Account" / "D1" / "Edit" 80 | 5. Click "Continue to summary" and copy the token value so you can set the `CLOUDFLARE_TOKEN` environment variable in `.dev.vars` 81 | 82 | ## Building for Production 83 | 84 | Create a production build: 85 | 86 | ```bash 87 | pnpm build 88 | ``` 89 | 90 | ## Deployment 91 | 92 | Deployment is done using the Wrangler CLI. 93 | 94 | Make sure you have run `pnpm db:generate` & `pnpm db:migrate:production` so the deployed app can query the database. 95 | 96 | To deploy directly to production from your machine: 97 | 98 | ```sh 99 | npx wrangler deploy 100 | ``` 101 | 102 | Or to deploy to the preview environment: 103 | 104 | ```sh 105 | npx wrangler versions upload --env preview 106 | ``` 107 | 108 | The CLI will output the URL you can use to view the app and the UUID of the deployment. 109 | 110 | You can then promote a version to production after verification or roll it out progressively. 111 | 112 | ```sh 113 | npx wrangler versions deploy 114 | ``` 115 | 116 | Select the UUID of the deployment you want to promote. 117 | 118 | ## Continuous deployment via GitHub 119 | 120 | You can configure CD via GitHub once you have run the deployment steps above 121 | 122 | 1. Visit the "Workers & Pages" page in the Cloudflare Dashboard 123 | 2. Click on the worker you deployed (the name will match what is defined in `wrangler.toml#name` field) 124 | 3. Click "Settings" 125 | 4. Scroll down to "Build" and click on "Connect" 126 | 5. Select your repository and branch 127 | 6. Click "connect" 128 | 7. Push changes to the repo for automatic deployments 129 | 130 | ## Styling 131 | 132 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. 133 | 134 | --- 135 | 136 | Built with ❤️ by [Matt](https://mattlynch.dev) 137 | -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | @apply bg-white dark:bg-gray-950; 8 | 9 | @media (prefers-color-scheme: dark) { 10 | color-scheme: dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | routerContext: EntryContext, 11 | _loadContext: AppLoadContext, 12 | ) { 13 | let shellRendered = false; 14 | const userAgent = request.headers.get("user-agent"); 15 | 16 | const body = await renderToReadableStream( 17 | , 18 | { 19 | onError(error: unknown) { 20 | responseStatusCode = 500; 21 | // Log streaming rendering errors from inside the shell. Don't log 22 | // errors encountered during initial shell rendering since they'll 23 | // reject and get logged in handleDocumentRequest. 24 | if (shellRendered) { 25 | console.error(error); 26 | } 27 | }, 28 | }, 29 | ); 30 | shellRendered = true; 31 | 32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 35 | await body.allReady; 36 | } else { 37 | responseHeaders.set("Transfer-Encoding", "chunked"); 38 | } 39 | 40 | responseHeaders.set("Content-Type", "text/html"); 41 | 42 | return new Response(body, { 43 | headers: responseHeaders, 44 | status: responseStatusCode, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | 10 | import type { Route } from "./+types/root"; 11 | import stylesheet from "./app.css?url"; 12 | 13 | export const links: Route.LinksFunction = () => [ 14 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 15 | { 16 | rel: "preconnect", 17 | href: "https://fonts.gstatic.com", 18 | crossOrigin: "anonymous", 19 | }, 20 | { 21 | rel: "stylesheet", 22 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 23 | }, 24 | { rel: "stylesheet", href: stylesheet }, 25 | ]; 26 | 27 | export function Layout({ children }: { children: React.ReactNode }) { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export default function App() { 46 | return ; 47 | } 48 | 49 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 50 | let message = "Oops!"; 51 | let details = "An unexpected error occurred."; 52 | let stack: string | undefined; 53 | 54 | if (isRouteErrorResponse(error)) { 55 | message = error.status === 404 ? "404" : "Error"; 56 | details = 57 | error.status === 404 58 | ? "The requested page could not be found." 59 | : error.statusText || details; 60 | } else if (import.meta.env.DEV && error && error instanceof Error) { 61 | details = error.message; 62 | stack = error.stack; 63 | } 64 | 65 | return ( 66 |
67 |

{message}

68 |

{details}

69 | {stack && ( 70 |
71 |           {stack}
72 |         
73 | )} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig } from "@react-router/dev/routes"; 2 | import { index, route } from "@react-router/dev/routes"; 3 | 4 | export default [ 5 | index("routes/home.tsx"), 6 | route("pre-rendered", "routes/static-page.tsx"), 7 | ] satisfies RouteConfig; 8 | -------------------------------------------------------------------------------- /app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import * as schema from "~/database/schema"; 2 | 3 | import type { Route } from "./+types/home"; 4 | import { Welcome } from "~/welcome/welcome"; 5 | 6 | export function meta({}: Route.MetaArgs) { 7 | return [ 8 | { title: "React Router App + Cloudflare Workers" }, 9 | { 10 | name: "description", 11 | content: "Welcome to React Router hosted on Cloudflare Workers!", 12 | }, 13 | ]; 14 | } 15 | 16 | export async function action({ request, context }: Route.ActionArgs) { 17 | const formData = await request.formData(); 18 | let name = formData.get("name"); 19 | let email = formData.get("email"); 20 | 21 | if (typeof name !== "string" || typeof email !== "string") { 22 | return { guestBookError: "Name and email are required" }; 23 | } 24 | 25 | name = name.trim(); 26 | email = email.trim(); 27 | 28 | if (!name || !email) { 29 | return { guestBookError: "Name and email are required" }; 30 | } 31 | 32 | const db = context.db; 33 | 34 | try { 35 | await db.insert(schema.guestBook).values({ name, email }); 36 | } catch (error) { 37 | return { guestBookError: "Error adding to guest book" }; 38 | } 39 | } 40 | 41 | export async function loader({ context }: Route.LoaderArgs) { 42 | const db = context.db; 43 | 44 | const guestBook = await db.query.guestBook.findMany({ 45 | columns: { 46 | id: true, 47 | name: true, 48 | }, 49 | }); 50 | 51 | return { 52 | guestBook, 53 | message: context.VALUE_FROM_CLOUDFLARE, 54 | }; 55 | } 56 | 57 | export default function Home({ actionData, loaderData }: Route.ComponentProps) { 58 | return ( 59 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/routes/static-page.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router"; 2 | 3 | export default function StaticPage() { 4 | return ( 5 |
6 |

7 | This route will be pre-rendered at build time based on the config 8 | defined in
react-router.config.ts
9 |

10 | 11 | Go back to Home 12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/welcome/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/welcome/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/welcome/welcome.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useNavigation } from "react-router"; 2 | 3 | import logoDark from "./logo-dark.svg"; 4 | import logoLight from "./logo-light.svg"; 5 | 6 | const linkClassName = 7 | "group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"; 8 | 9 | export function Welcome({ 10 | guestBook, 11 | guestBookError, 12 | message, 13 | }: { 14 | guestBook: { 15 | name: string; 16 | id: number; 17 | }[]; 18 | guestBookError?: string; 19 | message: string; 20 | }) { 21 | const navigation = useNavigation(); 22 | 23 | return ( 24 |
25 |
26 |
27 |
28 | React Router 33 | React Router 38 |
39 |
40 |
41 | 71 |
72 |
{ 76 | if (navigation.state === "submitting") { 77 | event.preventDefault(); 78 | } 79 | const form = event.currentTarget; 80 | requestAnimationFrame(() => { 81 | form.reset(); 82 | }); 83 | }} 84 | > 85 | 92 | 100 | 107 | {guestBookError && ( 108 |

109 | {guestBookError} 110 |

111 | )} 112 |
113 |
114 |

Signed by:

115 |
    116 | {guestBook.map(({ id, name }) => ( 117 |
  • 118 | {name} 119 |
  • 120 | ))} 121 |
122 |
123 |
124 |
125 |
126 |
127 | ); 128 | } 129 | 130 | const resources = [ 131 | { 132 | href: "https://reactrouter.com/docs", 133 | external: true, 134 | text: "React Router Docs", 135 | icon: ( 136 | 151 | ), 152 | }, 153 | { 154 | href: "https://rmx.as/discord", 155 | external: true, 156 | text: "Join React Router Discord", 157 | icon: , 158 | }, 159 | { 160 | href: "https://developers.cloudflare.com/workers/", 161 | external: true, 162 | text: "Cloudflare Workers Docs", 163 | icon: ( 164 | 184 | ), 185 | }, 186 | { 187 | href: "https://discord.com/invite/cloudflaredev", 188 | external: true, 189 | text: "Join Cloudflare Discord", 190 | icon: , 191 | }, 192 | { 193 | href: "/pre-rendered", 194 | external: false, 195 | text: "View a pre-rendered route at build time", 196 | icon: ( 197 | 214 | ), 215 | }, 216 | ]; 217 | 218 | function DiscordIcon() { 219 | return ( 220 | 234 | ); 235 | } 236 | -------------------------------------------------------------------------------- /database/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | 3 | export const guestBook = sqliteTable("guestBook", { 4 | id: integer().primaryKey({ autoIncrement: true }), 5 | name: text().notNull(), 6 | email: text().notNull().unique(), 7 | }); 8 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | const LOCAL_DB_PATH = process.env.LOCAL_DB_PATH; 4 | 5 | export default LOCAL_DB_PATH 6 | ? ({ 7 | schema: "./database/schema.ts", 8 | dialect: "sqlite", 9 | dbCredentials: { 10 | url: LOCAL_DB_PATH, 11 | }, 12 | } satisfies Config) 13 | : ({ 14 | schema: "./database/schema.ts", 15 | out: "./drizzle", 16 | dialect: "sqlite", 17 | driver: "d1-http", 18 | dbCredentials: { 19 | accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, 20 | databaseId: process.env.CLOUDFLARE_DATABASE_ID!, 21 | token: process.env.CLOUDFLARE_TOKEN!, 22 | }, 23 | } satisfies Config); 24 | -------------------------------------------------------------------------------- /drizzle/0000_outstanding_trauma.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `guestBook` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `name` text NOT NULL, 4 | `email` text NOT NULL 5 | ); 6 | --> statement-breakpoint 7 | CREATE UNIQUE INDEX `guestBook_email_unique` ON `guestBook` (`email`); -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "008ee181-36ed-4cc3-b593-481f13e2254a", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "guestBook": { 8 | "name": "guestBook", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "email": { 25 | "name": "email", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": { 33 | "guestBook_email_unique": { 34 | "name": "guestBook_email_unique", 35 | "columns": [ 36 | "email" 37 | ], 38 | "isUnique": true 39 | } 40 | }, 41 | "foreignKeys": {}, 42 | "compositePrimaryKeys": {}, 43 | "uniqueConstraints": {}, 44 | "checkConstraints": {} 45 | } 46 | }, 47 | "views": {}, 48 | "enums": {}, 49 | "_meta": { 50 | "schemas": {}, 51 | "tables": {}, 52 | "columns": {} 53 | }, 54 | "internal": { 55 | "indexes": {} 56 | } 57 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1732083994982, 9 | "tag": "0000_outstanding_trauma", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /load-context.ts: -------------------------------------------------------------------------------- 1 | import type { PlatformProxy } from "wrangler"; 2 | import { drizzle } from "drizzle-orm/d1"; 3 | 4 | import * as schema from "./database/schema"; 5 | 6 | // `Env` is defined in worker-configuration.d.ts 7 | 8 | type GetLoadContextArgs = { 9 | request: Request; 10 | context: { 11 | cloudflare: Omit< 12 | PlatformProxy, 13 | "dispose" | "caches" 14 | > & { 15 | caches: 16 | | PlatformProxy["caches"] 17 | | CacheStorage; 18 | }; 19 | }; 20 | }; 21 | 22 | declare module "react-router" { 23 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 24 | interface AppLoadContext extends ReturnType { 25 | // This will merge the result of `getLoadContext` into the `AppLoadContext` 26 | } 27 | } 28 | 29 | // Shared implementation compatible with Vite, Wrangler, and Cloudflare Workers 30 | export function getLoadContext({ context }: GetLoadContextArgs) { 31 | const db = drizzle(context.cloudflare.env.DB, { schema }); 32 | 33 | return { 34 | ...context, 35 | db, 36 | VALUE_FROM_CLOUDFLARE: context.cloudflare.env.VALUE_FROM_CLOUDFLARE, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-cloudflare-d1", 3 | "description": "React Router Cloudflare D1 template", 4 | "private": true, 5 | "sideEffects": false, 6 | "type": "module", 7 | "license": "MIT", 8 | "repository": { 9 | "url": "github:matthewlynch/react-router-cloudflare-d1" 10 | }, 11 | "scripts": { 12 | "build": "NODE_ENV=production react-router build", 13 | "db:generate": "dotenv -- drizzle-kit generate", 14 | "db:migrate": "wrangler d1 migrations apply --local DB", 15 | "db:migrate:production": "dotenvx run -f .dev.vars -- drizzle-kit migrate", 16 | "db:studio": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio", 17 | "db:studio:production": "dotenvx run -f .dev.vars -- drizzle-kit studio", 18 | "dev": "react-router dev", 19 | "start": "wrangler dev", 20 | "typegen": "wrangler types", 21 | "typecheck": "react-router typegen && tsc", 22 | "preview": "wrangler versions upload --env preview", 23 | "deploy:production": "wrangler deploy" 24 | }, 25 | "dependencies": { 26 | "@react-router/node": "^7.1.1", 27 | "@react-router/serve": "^7.1.1", 28 | "drizzle-orm": "~0.36.4", 29 | "isbot": "^5.1.19", 30 | "react": "^19.0.0", 31 | "react-dom": "^19.0.0", 32 | "react-router": "^7.1.1" 33 | }, 34 | "devDependencies": { 35 | "@cloudflare/workers-types": "^4.20241224.0", 36 | "@dotenvx/dotenvx": "^1.32.0", 37 | "@react-router/dev": "^7.1.1", 38 | "@types/node": "^20.17.10", 39 | "@types/react": "^19.0.2", 40 | "@types/react-dom": "^19.0.2", 41 | "autoprefixer": "^10.4.20", 42 | "better-sqlite3": "^11.7.0", 43 | "dotenv-cli": "^7.4.4", 44 | "drizzle-kit": "~0.28.1", 45 | "postcss": "^8.4.49", 46 | "prettier": "^3.4.2", 47 | "tailwindcss": "^3.4.17", 48 | "typescript": "^5.7.2", 49 | "vite": "^5.4.11", 50 | "vite-tsconfig-paths": "^5.1.4", 51 | "wrangler": "^3.99.0" 52 | }, 53 | "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthewlynch/react-router-cloudflare-d1/950ecd05052abc7c43347492a206a02f2ef0c87e/public/favicon.ico -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | // Server-side render by default, to enable SPA mode set this to `false` 6 | ssr: true, 7 | serverBuildFile: "index.js", 8 | async prerender() { 9 | return ["pre-rendered"]; 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: [ 9 | '"Inter"', 10 | "ui-sans-serif", 11 | "system-ui", 12 | "sans-serif", 13 | '"Apple Color Emoji"', 14 | '"Segoe UI Emoji"', 15 | '"Segoe UI Symbol"', 16 | '"Noto Color Emoji"', 17 | ], 18 | }, 19 | }, 20 | }, 21 | plugins: [], 22 | } satisfies Config; 23 | -------------------------------------------------------------------------------- /tsconfig.cloudflare.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "worker-configuration.d.ts", 5 | "load-context.ts", 6 | ".react-router/types/**/*", 7 | "app/**/*", 8 | "app/**/.server/**/*", 9 | "app/**/.client/**/*", 10 | "database/**/*", 11 | "workers/**/*", 12 | "vite.config.ts", 13 | ], 14 | "compilerOptions": { 15 | "allowJs": true, 16 | "checkJs": true, 17 | "skipLibCheck": true, 18 | "composite": true, 19 | "strict": true, 20 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 21 | "types": ["@cloudflare/workers-types", "node", "vite/client"], 22 | "target": "ES2022", 23 | "module": "ES2022", 24 | "moduleResolution": "bundler", 25 | "jsx": "react-jsx", 26 | "baseUrl": ".", 27 | "rootDirs": [".", "./.react-router/types"], 28 | "paths": { 29 | "~/database/*": ["./database/*"], 30 | "~/*": ["./app/*"] 31 | }, 32 | "esModuleInterop": true, 33 | "resolveJsonModule": true, 34 | "noEmit": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.node.json" }, 5 | { "path": "./tsconfig.cloudflare.json" } 6 | ], 7 | "compilerOptions": { 8 | "allowJs": true, 9 | "checkJs": true, 10 | "verbatimModuleSyntax": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "noEmit": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["tailwind.config.ts", "drizzle.config.ts", "react-router.config.ts"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "strict": true, 7 | "types": ["node"], 8 | "lib": ["ES2022"], 9 | "target": "ES2022", 10 | "module": "ES2022", 11 | "moduleResolution": "bundler" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare"; 2 | import { reactRouter } from "@react-router/dev/vite"; 3 | import autoprefixer from "autoprefixer"; 4 | import tailwindcss from "tailwindcss"; 5 | import { defineConfig } from "vite"; 6 | import tsconfigPaths from "vite-tsconfig-paths"; 7 | 8 | import { getLoadContext } from "./load-context"; 9 | 10 | export default defineConfig(({ isSsrBuild }) => ({ 11 | build: { 12 | cssMinify: process.env.NODE_ENV === "production", 13 | rollupOptions: isSsrBuild 14 | ? { 15 | input: { 16 | // `index.js` is the entrypoint for pre-rendering at build time 17 | "index.js": "virtual:react-router/server-build", 18 | // `worker.js` is the entrypoint for deployments on Cloudflare 19 | "worker.js": "./workers/app.ts", 20 | }, 21 | } 22 | : undefined, 23 | }, 24 | css: { 25 | postcss: { 26 | plugins: [tailwindcss, autoprefixer], 27 | }, 28 | }, 29 | ssr: { 30 | target: "webworker", 31 | noExternal: true, 32 | external: ["node:async_hooks"], 33 | resolve: { 34 | conditions: ["workerd", "browser"], 35 | }, 36 | optimizeDeps: { 37 | include: [ 38 | "react", 39 | "react/jsx-runtime", 40 | "react/jsx-dev-runtime", 41 | "react-dom", 42 | "react-dom/server", 43 | "react-router", 44 | ], 45 | }, 46 | }, 47 | plugins: [ 48 | cloudflareDevProxy({ getLoadContext }), 49 | reactRouter(), 50 | { 51 | // This plugin is required so both `index.js` / `worker.js can be 52 | // generated for the `build` config above 53 | name: "react-router-cloudflare-workers", 54 | config: () => ({ 55 | build: { 56 | rollupOptions: isSsrBuild 57 | ? { 58 | output: { 59 | entryFileNames: "[name]", 60 | }, 61 | } 62 | : undefined, 63 | }, 64 | }), 65 | }, 66 | tsconfigPaths(), 67 | ], 68 | })); 69 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | interface Env { 4 | VALUE_FROM_CLOUDFLARE: "Edit this value from wranlger.toml"; 5 | DB: D1Database; 6 | } 7 | -------------------------------------------------------------------------------- /workers/app.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "react-router"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore This file won’t exist if it hasn’t yet been built 5 | // import * as build from "./build/server"; 6 | import { getLoadContext } from "../load-context"; 7 | 8 | // const requestHandler = createRequestHandler( 9 | // build as unknown as ServerBuild, 10 | // import.meta.env.MODE, 11 | // ); 12 | 13 | const requestHandler = createRequestHandler( 14 | // @ts-expect-error - virtual module provided by React Router at build time 15 | () => import("virtual:react-router/server-build"), 16 | import.meta.env.MODE, 17 | ); 18 | 19 | export default { 20 | async fetch(request, env, ctx) { 21 | const loadContext = getLoadContext({ 22 | request, 23 | context: { 24 | cloudflare: { 25 | // This object matches the return value from Wrangler's 26 | // `getPlatformProxy` used during development via Remix's 27 | // `cloudflareDevProxyVitePlugin`: 28 | // https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy 29 | // @ts-ignore 30 | cf: request.cf, 31 | ctx: { 32 | waitUntil: ctx.waitUntil.bind(ctx), 33 | passThroughOnException: ctx.passThroughOnException.bind(ctx), 34 | }, 35 | caches, 36 | env, 37 | }, 38 | }, 39 | }); 40 | 41 | return await requestHandler(request, loadContext); 42 | }, 43 | } satisfies ExportedHandler; 44 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | 3 | # Visit https://developers.cloudflare.com/workers/wrangler/configuration/ to see full list of options for this file 4 | 5 | # This will be the name of your worker in the Cloudflare Dashboard 6 | name = "react-router-cloudflare-d1" 7 | # Compatibility date docs: https://developers.cloudflare.com/workers/configuration/compatibility-dates/ 8 | compatibility_date = "2024-11-18" 9 | # See https://developers.cloudflare.com/workers/configuration/compatibility-flags/ for full list of flags 10 | compatibility_flags = ["nodejs_compat"] 11 | # Entry point for the worker app 12 | main = "./build/server/worker.js" 13 | 14 | [build] 15 | command = "pnpm build" 16 | 17 | [assets] 18 | # https://developers.cloudflare.com/workers/static-assets/binding/ - these are the static assets served to clients 19 | directory = "./build/client" 20 | 21 | # These vars apply to both development & production 22 | [vars] 23 | VALUE_FROM_CLOUDFLARE="Edit this value from wranlger.toml" 24 | # Values defined in .dev.vars will overwrite values here 25 | CLOUDFLARE_ACCOUNT_ID="" 26 | CLOUDFLARE_DATABASE_ID="" 27 | 28 | [[d1_databases]] 29 | binding = "DB" 30 | database_name = "rr-cloudflare-demo" 31 | database_id = "fc17e5e7-3203-41e5-bea7-27c45dbc4792" 32 | migrations_dir = "drizzle" 33 | 34 | # You can create different environments that have their own bindings/variables 35 | # See https://developers.cloudflare.com/workers/wrangler/environments/ for more information 36 | [env.preview] 37 | workers_dev = true 38 | 39 | [env.preview.vars] 40 | VALUE_FROM_CLOUDFLARE="Edit this value from wranlger.toml (env.preview.vars)" 41 | CLOUDFLARE_ACCOUNT_ID="" 42 | CLOUDFLARE_DATABASE_ID="" 43 | 44 | [[env.preview.d1_databases]] 45 | binding = "DB" 46 | # You can keep these values the same as the database config above if you want to use a single database 47 | database_name = "rr-cloudflare-demo" 48 | database_id = "fc17e5e7-3203-41e5-bea7-27c45dbc4792" 49 | migrations_dir = "drizzle" 50 | --------------------------------------------------------------------------------