├── client
├── .env-template
├── src
│ ├── test
│ │ └── setup.ts
│ ├── vite-env.d.ts
│ ├── services
│ │ ├── monitor.use-monitor.ts
│ │ └── monitor.tsx
│ ├── config.ts
│ ├── main.tsx
│ ├── components
│ │ ├── ui.spinner.tsx
│ │ └── ui.modal.tsx
│ ├── index.css
│ ├── server-types.d.ts
│ ├── example.tsx
│ └── example.test.tsx
├── tsconfig.eslint.json
├── postcss.config.js
├── tsconfig.node.json
├── tsconfig.json
├── .gitignore
├── vitest.config.ts
├── tsconfig.app.json
├── index.html
├── vite.config.ts
├── eslint.config.js
└── package.json
├── .env-template
├── e2e
├── tsconfig.eslint.json
├── tsconfig.json
├── package.json
├── e2e.spec.ts
└── eslint.config.js
├── server
├── tsconfig.eslint.json
├── src
│ ├── types.shared.ts
│ ├── utils.ts
│ ├── types.ts
│ ├── test.ts
│ ├── index.ts
│ └── index.test.ts
├── .editorconfig
├── tsconfig.json
├── vitest.config.ts
├── tsconfig.shared.json
├── eslint.config.js
├── wrangler.toml
├── package.json
└── .gitignore
├── pnpm-workspace.yaml
├── .prettierrc
├── tsconfig.eslint.json
├── README.sentry
├── img
│ ├── 5 copy dsn.jpg
│ ├── 1 create project.jpg
│ ├── 2 create project.jpg
│ ├── 3 select project.jpg
│ ├── 4 click settings.jpg
│ └── 6 generate token.jpg
└── README.sentry.md
├── README.cloudflare
├── img
│ ├── 13 - gh vars.jpg
│ ├── 4 - dashboard.jpg
│ ├── 1 - create pages.jpg
│ ├── 11 - account id.jpg
│ ├── 2 - direct upload.jpg
│ ├── 3a - name project.jpg
│ ├── 7 - create worker.jpg
│ ├── 10 - add namespace.jpg
│ ├── 3 - create project.jpg
│ ├── 9 - create namespace.jpg
│ ├── settings add variables.jpg
│ ├── 12 - edit workers token.jpg
│ └── 8 - name worker and deploy.jpg
└── README.cloudflare.md
├── .gitignore
├── .vscode
└── settings.json
├── tsconfig.json
├── bin
├── update-json.js
└── cli.js
├── eslint.config.js
├── package.json
├── playwright.config.ts
├── README.md
├── .github
└── workflows
│ ├── stage.yml
│ └── ci.yml
└── README.project.md
/client/.env-template:
--------------------------------------------------------------------------------
1 | SENTRY_AUTH_TOKEN=
--------------------------------------------------------------------------------
/.env-template:
--------------------------------------------------------------------------------
1 | CLOUDFLARE_API_TOKEN=
2 | CLOUDFLARE_ACCOUNT_ID=
--------------------------------------------------------------------------------
/client/src/test/setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/client/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["."]
4 | }
5 |
--------------------------------------------------------------------------------
/e2e/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["."]
4 | }
5 |
--------------------------------------------------------------------------------
/server/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["."]
4 | }
5 |
--------------------------------------------------------------------------------
/server/src/types.shared.ts:
--------------------------------------------------------------------------------
1 | import type app from './index';
2 |
3 | export type ServerApi = typeof app;
4 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'client'
3 | - 'e2e'
4 | - 'server'
5 | shared-workspace-lockfile: true
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "singleQuote": true,
4 | "semi": true,
5 | "useTabs": false,
6 | "tabWidth": 2
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["eslint.config.js", "playwright.config.ts", "bin"]
4 | }
5 |
--------------------------------------------------------------------------------
/README.sentry/img/5 copy dsn.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.sentry/img/5 copy dsn.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/13 - gh vars.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/13 - gh vars.jpg
--------------------------------------------------------------------------------
/README.sentry/img/1 create project.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.sentry/img/1 create project.jpg
--------------------------------------------------------------------------------
/README.sentry/img/2 create project.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.sentry/img/2 create project.jpg
--------------------------------------------------------------------------------
/README.sentry/img/3 select project.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.sentry/img/3 select project.jpg
--------------------------------------------------------------------------------
/README.sentry/img/4 click settings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.sentry/img/4 click settings.jpg
--------------------------------------------------------------------------------
/README.sentry/img/6 generate token.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.sentry/img/6 generate token.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/4 - dashboard.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/4 - dashboard.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/1 - create pages.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/1 - create pages.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/11 - account id.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/11 - account id.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/2 - direct upload.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/2 - direct upload.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/3a - name project.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/3a - name project.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/7 - create worker.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/7 - create worker.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/10 - add namespace.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/10 - add namespace.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/3 - create project.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/3 - create project.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/9 - create namespace.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/9 - create namespace.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/settings add variables.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/settings add variables.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/12 - edit workers token.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/12 - edit workers token.jpg
--------------------------------------------------------------------------------
/README.cloudflare/img/8 - name worker and deploy.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/HEAD/README.cloudflare/img/8 - name worker and deploy.jpg
--------------------------------------------------------------------------------
/e2e/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "noUnusedLocals": true,
5 | "noUnusedParameters": true
6 | },
7 | "include": ["."]
8 | }
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .pnpm-store
3 | .eslintcache
4 | repo.txt
5 | .env
6 | .DS_Store
7 | /test-results/
8 | /playwright-report/
9 | /blob-report/
10 | /playwright/.cache/
11 | *.tsbuildinfo
--------------------------------------------------------------------------------
/e2e/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@create-react-spa-cloudflare/e2e",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "scripts": {
6 | "lint": "eslint .",
7 | "typecheck": "pnpm exec tsc --build"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "files": [],
4 | "references": [
5 | {
6 | "path": "./tsconfig.app.json"
7 | },
8 | {
9 | "path": "./tsconfig.node.json"
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/server/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = tab
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.yml]
12 | indent_style = space
13 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly GIT_BRANCH: string;
5 | readonly GIT_SHA: string;
6 | readonly E2E: string;
7 | }
8 |
9 | interface ImportMeta {
10 | readonly env: ImportMetaEnv;
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [{ "mode": "auto" }],
3 | "eslint.validate": [
4 | "javascript",
5 | "javascriptreact",
6 | "typescript",
7 | "typescriptreact"
8 | ],
9 | "typescript.tsdk": "node_modules/typescript/lib"
10 | }
11 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "types": [
5 | "@cloudflare/workers-types",
6 | "@cloudflare/workers-types/experimental",
7 | "@cloudflare/vitest-pool-workers"
8 | ],
9 | "noUnusedLocals": true,
10 | "noUnusedParameters": true
11 | },
12 | "include": ["src"]
13 | }
14 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/server/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { KVNamespace } from '@cloudflare/workers-types';
2 | import type { Context } from './types';
3 |
4 | export const getDb = (c: Context): KVNamespace => {
5 | switch (c.env.ENV) {
6 | case 'dev':
7 | return c.env.LOCAL_DB;
8 | case 'test':
9 | return c.env.TEST_DB;
10 | default:
11 | return c.env.DB;
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/server/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
2 |
3 | export default defineWorkersConfig({
4 | test: {
5 | poolOptions: {
6 | workers: {
7 | wrangler: { configPath: './wrangler.toml' },
8 | miniflare: {
9 | kvNamespaces: ['TEST_DB'],
10 | },
11 | },
12 | },
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/server/tsconfig.shared.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": false,
5 | "types": [
6 | "@cloudflare/workers-types",
7 | "@cloudflare/workers-types/experimental",
8 | "@cloudflare/vitest-pool-workers"
9 | ],
10 | "noUnusedLocals": true,
11 | "noUnusedParameters": true
12 | },
13 | "include": ["src/types.shared.ts"]
14 | }
15 |
--------------------------------------------------------------------------------
/client/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, mergeConfig } from 'vitest/config';
2 | import viteConfig from './vite.config';
3 |
4 | export default defineConfig((configEnv) =>
5 | mergeConfig(
6 | viteConfig(configEnv),
7 | defineConfig({
8 | test: {
9 | globals: true,
10 | environment: 'jsdom',
11 | setupFiles: './src/test/setup.ts',
12 | },
13 | }),
14 | ),
15 | );
16 |
--------------------------------------------------------------------------------
/server/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { KVNamespace } from '@cloudflare/workers-types';
2 | import type { Context as HonoContext } from 'hono';
3 |
4 | export interface Env {
5 | ALLOWED_HOST: string;
6 | DB: KVNamespace;
7 | LOCAL_DB: KVNamespace;
8 | TEST_DB: KVNamespace;
9 | ENV: 'dev' | 'prod' | 'stage' | 'test';
10 | GITHUB_REF_NAME: string;
11 | GITHUB_SHA: string;
12 | }
13 |
14 | export type Context = HonoContext<{
15 | Bindings: Env;
16 | }>;
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "ESNext",
5 | "lib": ["ES2022"],
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "moduleResolution": "bundler",
9 | "esModuleInterop": true,
10 | "allowSyntheticDefaultImports": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noEmit": true,
13 | "sourceMap": true,
14 | "noUnusedLocals": true,
15 | "noUnusedParameters": true,
16 | "noFallthroughCasesInSwitch": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/client/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "useDefineForClassFields": true,
8 | "allowImportingTsExtensions": true,
9 | "resolveJsonModule": true,
10 | "isolatedModules": true,
11 | "moduleDetection": "force",
12 | "jsx": "react-jsx",
13 | "types": ["vitest/globals"]
14 | },
15 | "include": ["src", "../server/src/*"]
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/services/monitor.use-monitor.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 |
3 | export const MonitorContext = createContext<{
4 | captureException: (exception: unknown) => string;
5 | } | null>(null);
6 |
7 | export const useMonitor = () => {
8 | const ctx = useContext(MonitorContext);
9 | if (!ctx) {
10 | return {
11 | captureException: () => {
12 | console.warn(
13 | 'tried to capture exception before monitor was initialized',
14 | );
15 | },
16 | };
17 | }
18 | return ctx;
19 | };
20 |
--------------------------------------------------------------------------------
/client/src/config.ts:
--------------------------------------------------------------------------------
1 | export const ENV = window.location.hostname.includes('stage.')
2 | ? 'stage'
3 | : window.location.hostname.includes('todo: change to prod domain')
4 | ? 'prod'
5 | : window.location.port === '4173'
6 | ? 'test'
7 | : 'dev';
8 |
9 | export const getServerUrl = () => {
10 | switch (ENV) {
11 | case 'stage':
12 | return 'todo: change to stage url';
13 | case 'prod':
14 | return 'todo: change to prod url';
15 | case 'test':
16 | return 'http://localhost:8788';
17 | case 'dev':
18 | default:
19 | return 'http://localhost:8787';
20 | }
21 | };
22 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | todo: rename me
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/e2e/e2e.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | test.use({
4 | actionTimeout: 5000,
5 | // add a user agent so server doesn't think playwright is a bot 🤖
6 | userAgent:
7 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
8 | });
9 |
10 | test.beforeEach(async () => {
11 | try {
12 | await fetch('http://localhost:8788/test/reset', { method: 'POST' });
13 | console.log('Successfully reset test db');
14 | } catch (error) {
15 | console.error('Failed to reset test db', error);
16 | }
17 | });
18 |
19 | test('smoke test', async ({ page }) => {
20 | await page.goto('/');
21 | await expect(page).toHaveTitle('todo: rename me');
22 | });
23 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { MonitorProvider } from './services/monitor';
6 | import { Example } from './example';
7 |
8 | const queryClient = new QueryClient();
9 |
10 | ReactDOM.createRoot(document.getElementById('root')!).render(
11 |
12 |
13 |
14 |
15 |
16 |
17 | ,
18 | );
19 |
20 | console.log(`branch: ${import.meta.env.GIT_BRANCH}`);
21 | console.log(`commit: ${import.meta.env.GIT_SHA}`);
22 | console.log(`e2e: ${import.meta.env.E2E || 'false'}`);
23 |
--------------------------------------------------------------------------------
/server/src/test.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from 'hono';
2 | import { HTTPException } from 'hono/http-exception';
3 | import type { Env } from './types';
4 |
5 | export const testRoute = new Hono<{ Bindings: Env }>()
6 | .post('/reset', async (c) => {
7 | if (c.env.ENV !== 'test') {
8 | console.warn(`cannot reset db in env: ${c.env.ENV}`);
9 | throw new HTTPException(404, { message: 'Not Found' });
10 | }
11 | // explicitly use c.env.TEST_DB and not getDb() to be sure we are only
12 | // adjusting the local test db binding
13 | await resetDb(c.env.TEST_DB);
14 | return new Response('ok', { status: 200 });
15 | })
16 | .notFound(() => {
17 | throw new HTTPException(404, { message: 'Not Found' });
18 | });
19 |
20 | const resetDb = async (testDb: Env['TEST_DB']) => {
21 | await testDb.delete('highScore');
22 | };
23 |
--------------------------------------------------------------------------------
/client/src/components/ui.spinner.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { ReactNode } from 'react';
3 |
4 | interface SpinnerProps {
5 | isSpinning: boolean;
6 | children: ReactNode;
7 | }
8 |
9 | export const Spinner = ({ isSpinning, children }: SpinnerProps) => {
10 | return (
11 |
12 | {isSpinning && (
13 |
14 |
15 |
16 | )}
17 |
23 | {children}
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/server/eslint.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | import path from 'path';
3 | import { fileURLToPath } from 'url';
4 | import rootConfig from '../eslint.config.js';
5 | import tsEslintParser from '@typescript-eslint/parser';
6 |
7 | // mimic CommonJS variables -- not needed if using CommonJS
8 | const __filename = fileURLToPath(import.meta.url);
9 | const __dirname = path.dirname(__filename);
10 |
11 | export default [
12 | ...rootConfig,
13 | {
14 | // Note: there should be no other properties in this object
15 | ignores: ['eslint.config.js'],
16 | },
17 | {
18 | languageOptions: {
19 | ecmaVersion: 2022,
20 | sourceType: 'module',
21 | parser: tsEslintParser,
22 | parserOptions: {
23 | project: './tsconfig.eslint.json',
24 | tsconfigRootDir: __dirname,
25 | },
26 | },
27 | },
28 | ];
29 |
--------------------------------------------------------------------------------
/server/wrangler.toml:
--------------------------------------------------------------------------------
1 | main = "dist/index.js"
2 |
3 | compatibility_date = "2024-08-26"
4 | compatibility_flags = [ "nodejs_compat" ]
5 |
6 | [env.dev]
7 | vars = { ALLOWED_HOST = "*", ENV = "dev" }
8 | [[env.dev.kv_namespaces]]
9 | binding = "LOCAL_DB"
10 | id = "local-db"
11 |
12 | [env.test]
13 | vars = { ALLOWED_HOST = "*", ENV = "test" }
14 | [[env.test.kv_namespaces]]
15 | binding = "TEST_DB"
16 | id = "test-db"
17 |
18 | [env.stage]
19 | name = "todo-rename-stage"
20 | workers_dev = true
21 | vars = { ALLOWED_HOST = "todo:rename to stage host", ENV = "stage" }
22 | [[env.stage.kv_namespaces]]
23 | binding = "DB"
24 | id = "e0c5eee53ed34ff69c4d8303f818adca"
25 |
26 | [env.prod]
27 | name = "todo-rename-prod"
28 | workers_dev = true
29 | vars = { ALLOWED_HOST = "todo:rename to prod host", ENV = "prod" }
30 | [[env.prod.kv_namespaces]]
31 | binding = "DB"
32 | id = "2431c6957e9e4a7cb4fa61f284793b98"
33 |
--------------------------------------------------------------------------------
/e2e/eslint.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | import path from 'path';
3 | import { fileURLToPath } from 'url';
4 | import globals from 'globals';
5 | import rootConfig from '../eslint.config.js';
6 | import tsEslintParser from '@typescript-eslint/parser';
7 |
8 | // mimic CommonJS variables -- not needed if using CommonJS
9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 |
12 | export default [
13 | ...rootConfig,
14 | {
15 | // Note: there should be no other properties in this object
16 | ignores: ['eslint.config.js'],
17 | },
18 | {
19 | languageOptions: {
20 | ecmaVersion: 2022,
21 | sourceType: 'module',
22 | globals: { ...globals.browser },
23 | parser: tsEslintParser,
24 | parserOptions: {
25 | project: './tsconfig.eslint.json',
26 | tsconfigRootDir: __dirname,
27 | },
28 | },
29 | },
30 | ];
31 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sentryVitePlugin } from '@sentry/vite-plugin';
2 | import type { PluginOption } from 'vite';
3 | import { defineConfig, loadEnv } from 'vite';
4 | import react from '@vitejs/plugin-react-swc';
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig(({ mode }) => {
8 | const env = loadEnv(mode, process.cwd(), '');
9 | return {
10 | plugins: [
11 | react() as PluginOption[],
12 | sentryVitePlugin({
13 | org: 'todo:replace',
14 | project: 'todo:replace',
15 | authToken: env.SENTRY_AUTH_TOKEN,
16 | }),
17 | ],
18 | define: {
19 | 'import.meta.env.GIT_BRANCH': JSON.stringify(process.env.GITHUB_REF_NAME),
20 | 'import.meta.env.GIT_SHA': JSON.stringify(process.env.GITHUB_SHA),
21 | 'import.meta.env.E2E': JSON.stringify(process.env.E2E),
22 | },
23 | build: {
24 | sourcemap: true,
25 | },
26 | };
27 | });
28 |
--------------------------------------------------------------------------------
/bin/update-json.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node, commonjs */
2 |
3 | import fs from 'fs';
4 | import path from 'path';
5 |
6 | export function updateJson(pathname, properties) {
7 | const jsonPath = path.join(process.cwd(), pathname);
8 | try {
9 | const data = fs.readFileSync(jsonPath, 'utf8');
10 | const json = JSON.parse(data);
11 |
12 | for (const [key, val] of Object.entries(properties)) {
13 | if (val === '[[DELETE]]') {
14 | delete json[key];
15 | } else {
16 | json[key] = val;
17 | }
18 | }
19 | const updatedContent = JSON.stringify(json, null, 2);
20 |
21 | fs.writeFileSync(jsonPath, updatedContent, 'utf8');
22 | console.log(`✔ Successfully updated ${pathname}`);
23 | return true;
24 | } catch (err) {
25 | if (err instanceof SyntaxError) {
26 | console.error(`! Error parsing ${pathname}:`, err);
27 | } else {
28 | console.error(`! Error reading or writing ${pathname}:`, err);
29 | }
30 | return false;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@create-react-spa-cloudflare/server",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "scripts": {
6 | "deploy:stage": "pnpm wrangler deploy --minify src/index.ts --env stage --var GITHUB_REF_NAME:$GITHUB_REF_NAME --var GITHUB_SHA:$GITHUB_SHA",
7 | "deploy:prod": "pnpm wrangler deploy --minify src/index.ts --env prod --var GITHUB_REF_NAME:$GITHUB_REF_NAME --var GITHUB_SHA:$GITHUB_SHA",
8 | "dev": "pnpm wrangler dev --live-reload src/index.ts --env dev",
9 | "dev:test": "pnpm wrangler dev --live-reload src/index.ts --env test --port 8788",
10 | "lint": "eslint .",
11 | "test": "vitest run",
12 | "test:watch": "vitest",
13 | "typecheck": "pnpm exec tsc --build"
14 | },
15 | "dependencies": {
16 | "@hono/zod-validator": "^0.5.0",
17 | "zod": "^3.24.4"
18 | },
19 | "devDependencies": {
20 | "@cloudflare/vitest-pool-workers": "^0.8.26",
21 | "@cloudflare/workers-types": "^4.20250507.0",
22 | "vitest": "3.1.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/README.sentry/README.sentry.md:
--------------------------------------------------------------------------------
1 | 1. [Sign up for Sentry](https://sentry.io/welcome/) for free!
2 |
3 | 2. From the Projects tab, Click "+ Create Project"
4 |
5 | 
6 |
7 | 3. Select "React", name your project, and click "Create Project"
8 |
9 | 
10 |
11 | 4. Select your new project to enter the project overview page
12 |
13 | 
14 |
15 | 5. Click the gear icon in the top right to edit project settings
16 |
17 | 
18 |
19 | 6. In the "Client Keys (DSN)" tab, copy your DSN.
20 |
21 | 
22 |
23 | 7. Paste this into the `SENTRY_DSN` const in [client/src/services/monitor.tsx](../client/src/services/monitor.tsx)
24 |
25 | 8. Generate an auth token for sourcemap uploads:
26 | https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/vite/#configuration
27 |
28 | 
29 |
30 | 9. Add your `SENTRY_AUTH_TOKEN` in [client/.env](../client/.env) and in your GitHub Actions Secrets (in your GitHub repo's settings tab.)
31 |
32 | 10. Add your `org` and `project` values to Sentry Vite plugin in [client/vite.config.ts](../client/vite.config.ts)
33 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | import path from 'path';
3 | import { fileURLToPath } from 'url';
4 | import js from '@eslint/js';
5 | import tsEslint from 'typescript-eslint';
6 | import tsEslintParser from '@typescript-eslint/parser';
7 |
8 | // mimic CommonJS variables -- not needed if using CommonJS
9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 |
12 | export default [
13 | js.configs.recommended,
14 | ...tsEslint.configs.recommended,
15 | {
16 | // Note: there should be no other properties in this object
17 | ignores: ['dist', 'eslint.config.js'],
18 | },
19 | {
20 | languageOptions: {
21 | ecmaVersion: 2022,
22 | sourceType: 'module',
23 | parser: tsEslintParser,
24 | parserOptions: {
25 | project: './tsconfig.eslint.json',
26 | tsconfigRootDir: __dirname,
27 | },
28 | },
29 | rules: {
30 | 'default-case': 'error',
31 | '@typescript-eslint/consistent-type-exports': 'warn',
32 | '@typescript-eslint/consistent-type-imports': 'warn',
33 | '@typescript-eslint/no-unused-vars': 'warn',
34 | '@typescript-eslint/no-unused-expressions': [
35 | 'warn',
36 | { allowShortCircuit: true },
37 | ],
38 | },
39 | settings: {
40 | 'max-warnings': 0,
41 | },
42 | },
43 | ];
44 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from 'hono';
2 | import { cors } from 'hono/cors';
3 | import { HTTPException } from 'hono/http-exception';
4 | import { testRoute } from './test';
5 | import type { Env } from './types';
6 |
7 | const app = new Hono<{ Bindings: Env }>()
8 | .use('*', async (c, next) => {
9 | await next();
10 | c.res.headers.set('X-Git-Branch', c.env.GITHUB_REF_NAME || '');
11 | c.res.headers.set('X-Git-Commit', c.env.GITHUB_SHA || '');
12 | })
13 | .use('*', async (c, next) => {
14 | const allowedHost = c.env.ALLOWED_HOST;
15 | const origin =
16 | allowedHost === '*' ? '*' : new URL(c.req.header('referer') || '').origin;
17 | if (origin.endsWith(allowedHost)) {
18 | return cors({
19 | origin,
20 | allowMethods: ['GET', 'POST', 'OPTIONS'],
21 | allowHeaders: ['Content-Type', 'baggage', 'sentry-trace'],
22 | exposeHeaders: ['Content-Type'],
23 | })(c, next);
24 | }
25 | // If referer is not allowed, fail the request
26 | throw new HTTPException(403, { message: 'Forbidden' });
27 | })
28 | .get('/', async (c) => c.text('ok', 200))
29 | .notFound(() => {
30 | throw new HTTPException(404, { message: 'Not Found' });
31 | })
32 | .onError((err, c) => {
33 | console.error(err);
34 | if (err instanceof HTTPException) {
35 | return c.json({ message: err.message }, err.status);
36 | }
37 | return c.json({ message: 'Unknown server error', cause: err }, 500);
38 | })
39 | .route('/test', testRoute);
40 |
41 | export default app;
42 |
--------------------------------------------------------------------------------
/client/eslint.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | import globals from 'globals';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 | import rootConfig from '../eslint.config.js';
6 | import tsEslintParser from '@typescript-eslint/parser';
7 | import reactHooks from 'eslint-plugin-react-hooks';
8 | import reactRefresh from 'eslint-plugin-react-refresh';
9 |
10 | // mimic CommonJS variables -- not needed if using CommonJS
11 | const __filename = fileURLToPath(import.meta.url);
12 | const __dirname = path.dirname(__filename);
13 |
14 | export default [
15 | ...rootConfig,
16 | {
17 | // Note: there should be no other properties in this object
18 | ignores: ['src/server-types.d.ts', 'eslint.config.js', 'postcss.config.js'],
19 | },
20 | {
21 | languageOptions: {
22 | ecmaVersion: 2022,
23 | sourceType: 'module',
24 | globals: { ...globals.browser },
25 | parser: tsEslintParser,
26 | parserOptions: {
27 | project: './tsconfig.eslint.json',
28 | tsconfigRootDir: __dirname,
29 | },
30 | },
31 | plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
32 | rules: {
33 | 'react-refresh/only-export-components': [
34 | 'warn',
35 | { allowConstantExport: true },
36 | ],
37 | 'no-restricted-imports': [
38 | 'error',
39 | {
40 | paths: [
41 | {
42 | name: '@sentry/react',
43 | message: '@sentry/react should only be lazy imported',
44 | },
45 | ],
46 | },
47 | ],
48 | },
49 | },
50 | ];
51 |
--------------------------------------------------------------------------------
/client/src/services/monitor.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import { lazy, Suspense } from 'react';
3 | import { MonitorContext } from './monitor.use-monitor';
4 | import { ENV, getServerUrl } from '../config';
5 |
6 | const SENTRY_DSN = 'todo: replace with sentry dsn';
7 |
8 | const SentryLazy = lazy(() =>
9 | import('@sentry/react').then((SentryReact) => {
10 | SentryReact.init({
11 | dsn: SENTRY_DSN,
12 | environment: ENV,
13 | integrations: [
14 | SentryReact.browserTracingIntegration(),
15 | SentryReact.replayIntegration(),
16 | ],
17 | tracesSampleRate: 1.0,
18 | tracePropagationTargets: ['localhost', getServerUrl()],
19 | // limit replay sampling
20 | replaysSessionSampleRate: ENV === 'prod' ? 0.1 : 0,
21 | replaysOnErrorSampleRate: ENV === 'prod' ? 1.0 : 0,
22 | });
23 | console.log('monitor initialized');
24 | const SentryProvider = ({ children }: { children: ReactNode }) => {
25 | return (
26 |
27 |
30 | {children}
31 |
32 |
33 | );
34 | };
35 | return {
36 | default: SentryProvider,
37 | };
38 | }),
39 | );
40 |
41 | export const MonitorProvider = ({ children }: { children: ReactNode }) => {
42 | return (
43 | {children}>}>
44 | {children}
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@create-react-spa-cloudflare/client",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "scripts": {
6 | "build": "pnpm run build:server-types && tsc -b && vite build",
7 | "build:server-types": "tsc -p ../server/tsconfig.shared.json --declaration --emitDeclarationOnly --outFile ./src/server-types.d.ts",
8 | "build:test": "tsc -b && E2E=true vite build",
9 | "deploy:stage": "pnpm wrangler pages deploy ./dist --project-name todo-rename --branch \"stage\" --commit-hash \"$GITHUB_SHA\" --commit-message \"stage deployment\"",
10 | "deploy:prod": "pnpm wrangler pages deploy ./dist --project-name todo-rename",
11 | "dev": "vite --host 0.0.0.0",
12 | "lint": "eslint .",
13 | "test": "vitest run",
14 | "test:watch": "vitest",
15 | "preview": "vite preview",
16 | "typecheck": "pnpm exec tsc --build"
17 | },
18 | "dependencies": {
19 | "@sentry/react": "^9.16.1",
20 | "@sentry/vite-plugin": "^3.4.0",
21 | "@tanstack/react-query": "^5.75.5",
22 | "classnames": "^2.5.1",
23 | "react": "^19.1.0",
24 | "react-dom": "^19.1.0"
25 | },
26 | "devDependencies": {
27 | "@tailwindcss/postcss": "^4.1.5",
28 | "@testing-library/jest-dom": "^6.6.3",
29 | "@testing-library/react": "^16.3.0",
30 | "@types/react": "^19.1.3",
31 | "@types/react-dom": "^19.1.3",
32 | "@vitejs/plugin-react-swc": "^3.9.0",
33 | "eslint-plugin-react-hooks": "^5.2.0",
34 | "eslint-plugin-react-refresh": "^0.4.20",
35 | "jsdom": "^26.1.0",
36 | "postcss": "^8.5.3",
37 | "tailwindcss": "^4.1.5",
38 | "vite": "^6.3.5",
39 | "vitest": "^3.1.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @theme {
4 | --animate-fade-in: fadeIn 0.3s ease-out forwards;
5 | --animate-fade-out: fadeOut 0.3s ease-out forwards;
6 | --animate-scale-in: scaleIn 0.3s ease-out forwards;
7 | --animate-scale-out: scaleOut 0.3s ease-out forwards;
8 |
9 | --font-sans: Ubuntu, sans-serif;
10 |
11 | @keyframes fadeIn {
12 | 0% {
13 | opacity: 0;
14 | }
15 | 100% {
16 | opacity: 1;
17 | }
18 | }
19 | @keyframes fadeOut {
20 | 0% {
21 | opacity: 1;
22 | }
23 | 100% {
24 | opacity: 0;
25 | }
26 | }
27 | @keyframes scaleIn {
28 | 0% {
29 | transform: scale(0.95);
30 | opacity: 0;
31 | }
32 | 100% {
33 | transform: scale(1);
34 | opacity: 1;
35 | }
36 | }
37 | @keyframes scaleOut {
38 | 0% {
39 | transform: scale(1);
40 | opacity: 1;
41 | }
42 | 100% {
43 | transform: scale(0.95);
44 | opacity: 0;
45 | }
46 | }
47 | }
48 |
49 | /*
50 | The default border color has changed to `currentcolor` in Tailwind CSS v4,
51 | so we've added these compatibility styles to make sure everything still
52 | looks the same as it did with Tailwind CSS v3.
53 |
54 | If we ever want to remove these styles, we need to add an explicit border
55 | color utility to any element that depends on these defaults.
56 | */
57 | @layer base {
58 | *,
59 | ::after,
60 | ::before,
61 | ::backdrop,
62 | ::file-selector-button {
63 | border-color: var(--color-gray-200, currentcolor);
64 | }
65 | }
66 |
67 | @layer base {
68 | html {
69 | font-family: Ubuntu, system-ui, sans-serif;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-react-spa-cloudflare",
3 | "type": "module",
4 | "version": "0.0.26",
5 | "description": "Starter package for a React SPA with Cloudflare Pages, Workers, and KV",
6 | "bin": "./bin/cli.js",
7 | "scripts": {
8 | "preinstall": "npx only-allow pnpm",
9 | "client": "pnpm --filter client",
10 | "server": "pnpm --filter server",
11 | "build": "pnpm -r build",
12 | "clean": "rm -rf node_modules && pnpm -r exec rm -rf node_modules && pnpm i",
13 | "dev": "pnpm -r dev",
14 | "e2e": "pnpm exec playwright test",
15 | "e2e:ui": "pnpm exec playwright test --ui",
16 | "format": "pnpm exec prettier --write **/*.{js,ts,tsx,json,css,md}",
17 | "lint": "pnpm -r lint",
18 | "test": "pnpm -r test",
19 | "test:watch": "pnpm -r test:watch",
20 | "typecheck": "pnpm -r typecheck"
21 | },
22 | "keywords": [
23 | "react",
24 | "spa",
25 | "cloudflare"
26 | ],
27 | "author": "@codenickycode",
28 | "license": "MIT",
29 | "repository": {
30 | "type": "git",
31 | "url": "https://github.com/codenickycode/create-react-spa-cloudflare.git"
32 | },
33 | "dependencies": {
34 | "hono": "^4.7.8"
35 | },
36 | "devDependencies": {
37 | "@eslint/js": "^9.26.0",
38 | "@playwright/test": "^1.52.0",
39 | "@types/eslint": "^9.6.1",
40 | "@types/node": "^22.7.4",
41 | "@typescript-eslint/parser": "^8.32.0",
42 | "eslint": "^9.26.0",
43 | "globals": "^16.1.0",
44 | "prettier": "^3.5.3",
45 | "typescript": "^5.8.3",
46 | "typescript-eslint": "^8.32.0",
47 | "wrangler": "^4.14.3"
48 | },
49 | "engines": {
50 | "node": ">=22.9.0",
51 | "pnpm": ">=9"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | /**
4 | * Read environment variables from file.
5 | * https://github.com/motdotla/dotenv
6 | */
7 | // import dotenv from 'dotenv';
8 | // dotenv.config({ path: path.resolve(__dirname, '.env') });
9 |
10 | /**
11 | * See https://playwright.dev/docs/test-configuration.
12 | */
13 | export default defineConfig({
14 | testDir: './e2e',
15 | /* Fail the build on CI if you accidentally left test.only in the source code */
16 | forbidOnly: !!process.env.CI,
17 | fullyParallel: false, // because we are testing a clean db each time
18 | retries: process.env.CI ? 2 : 0,
19 | workers: 1,
20 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
21 | reporter: 'html',
22 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
23 | use: {
24 | /* Base URL to use in actions like `await page.goto('/')`. */
25 | baseURL: 'http://localhost:4173',
26 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
27 | trace: 'on-first-retry',
28 | },
29 |
30 | /* Configure projects for major browsers */
31 | projects: [
32 | {
33 | name: 'Desktop Chrome',
34 | use: { ...devices['Desktop Chrome'] },
35 | },
36 | {
37 | name: 'Mobile Chrome',
38 | use: { ...devices['Pixel 5'] },
39 | },
40 | ],
41 |
42 | /* Run your local dev server before starting the tests */
43 | webServer: {
44 | command:
45 | 'pnpm run server dev:test & pnpm run client build:test && pnpm run client preview',
46 | url: 'http://localhost:4173',
47 | reuseExistingServer: !process.env.CI,
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/client/src/server-types.d.ts:
--------------------------------------------------------------------------------
1 | declare module "types" {
2 | import type { KVNamespace } from '@cloudflare/workers-types';
3 | import type { Context as HonoContext } from 'hono';
4 | export interface Env {
5 | ALLOWED_HOST: string;
6 | DB: KVNamespace;
7 | LOCAL_DB: KVNamespace;
8 | TEST_DB: KVNamespace;
9 | ENV: 'dev' | 'prod' | 'stage' | 'test';
10 | GITHUB_REF_NAME: string;
11 | GITHUB_SHA: string;
12 | }
13 | export type Context = HonoContext<{
14 | Bindings: Env;
15 | }>;
16 | }
17 | declare module "test" {
18 | import type { Env } from "types";
19 | export const testRoute: import("hono/hono-base").HonoBase<{
20 | Bindings: Env;
21 | }, {
22 | "/reset": {
23 | $post: {
24 | input: {};
25 | output: {};
26 | outputFormat: string;
27 | status: import("hono/utils/http-status").StatusCode;
28 | };
29 | };
30 | }, "/">;
31 | }
32 | declare module "index" {
33 | import type { Env } from "types";
34 | const app: import("hono/hono-base").HonoBase<{
35 | Bindings: Env;
36 | }, ({
37 | "*": {};
38 | } & {
39 | "/": {
40 | $get: {
41 | input: {};
42 | output: "ok";
43 | outputFormat: "text";
44 | status: 200;
45 | };
46 | };
47 | }) | import("hono/types").MergeSchemaPath<{
48 | "/reset": {
49 | $post: {
50 | input: {};
51 | output: {};
52 | outputFormat: string;
53 | status: import("hono/utils/http-status").StatusCode;
54 | };
55 | };
56 | }, "/test">, "/">;
57 | export default app;
58 | }
59 | declare module "types.shared" {
60 | import type app from "index";
61 | export type ServerApi = typeof app;
62 | }
63 |
--------------------------------------------------------------------------------
/client/src/example.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Spinner } from './components/ui.spinner';
3 | import { Modal } from './components/ui.modal';
4 | import { useQuery } from '@tanstack/react-query';
5 | import { getServerUrl } from './config';
6 | import { hc } from 'hono/client';
7 | import { useMonitor } from './services/monitor.use-monitor';
8 | import type { ServerApi } from 'types.shared';
9 |
10 | const serverUrl = getServerUrl();
11 |
12 | const { $get } = hc(serverUrl)['index'];
13 |
14 | const useServerOkQuery = () => {
15 | const { captureException } = useMonitor();
16 | return useQuery({
17 | queryKey: ['hello-from-server'],
18 | queryFn: async () => {
19 | return $get()
20 | .then(async (res) => {
21 | if (!res.ok) {
22 | throw res;
23 | }
24 | return res.text();
25 | })
26 | .catch((error) => {
27 | captureException(error);
28 | throw error;
29 | });
30 | },
31 | });
32 | };
33 |
34 | export const Example = () => {
35 | const [isModalOpen, setIsModalOpen] = useState(false);
36 | const serverOkQuery = useServerOkQuery();
37 | return (
38 |
39 |
Hello from React!
40 |
41 |
Server connection: {serverOkQuery.isSuccess ? '✅' : '❌'}
42 |
43 |
44 |
45 | Spinner: spinner example
46 |
47 |
48 |
49 |
55 | setIsModalOpen(false)}>
56 | I am a modal!
57 |
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/client/src/components/ui.modal.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { ReactNode } from 'react';
3 | import { useEffect, useState } from 'react';
4 |
5 | interface ModalProps {
6 | isOpen: boolean;
7 | onClose: () => void;
8 | children: ReactNode;
9 | className?: string;
10 | closeOnEsc?: boolean;
11 | }
12 |
13 | export const Modal = ({
14 | isOpen,
15 | onClose,
16 | children,
17 | closeOnEsc = true,
18 | className,
19 | }: ModalProps) => {
20 | const [isAnimating, setIsAnimating] = useState(false);
21 |
22 | // Closes modal on 'esc'. This needs to be smarter if you will have more than
23 | // one modal open at a time. We do not currently have that requirement.
24 | useEffect(() => {
25 | const closeModal = (e: KeyboardEvent) => {
26 | if (closeOnEsc && e.key === 'Escape') {
27 | onClose();
28 | }
29 | };
30 | document.addEventListener('keydown', closeModal);
31 | return () => {
32 | document.removeEventListener('keydown', closeModal);
33 | };
34 | }, [closeOnEsc, onClose]);
35 |
36 | useEffect(() => {
37 | if (isOpen) {
38 | setIsAnimating(true);
39 | }
40 | }, [isOpen]);
41 |
42 | const handleAnimationEnd = () => {
43 | if (!isOpen) {
44 | setIsAnimating(false);
45 | }
46 | };
47 |
48 | if (!isOpen && !isAnimating) {
49 | return null;
50 | }
51 |
52 | return (
53 |
61 |
e.stopPropagation()}
70 | >
71 |
77 | {children}
78 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Create React SPA Cloudflare
2 |
3 | A starter project for building a pnpm monorepo with a client-side React SPA and a server-side Cloudflare Worker with KV storage. This project includes comprehensive testing, linting, and CI/CD setup, along with Sentry integration for error tracking.
4 |
5 | ## Features
6 |
7 | - [React](https://react.dev) SPA for the client-side application
8 | - [Tailwind CSS](https://tailwindcss.com/) for styling
9 | - [Tanstack Query](https://tanstack.com/query/latest) for client http request state management
10 | - [Hono](https://hono.dev/) server-side api framework
11 | - [Prettier](https://prettier.io/) for code formatting
12 | - [ESLint](https://eslint.org/) for linting
13 | - [Vitest](https://vitest.dev/) for unit testing
14 | - [Playwright](https://playwright.dev/) for end-to-end testing
15 | - [TypeScript](https://www.typescriptlang.org/) for type checking
16 | - [Cloudflare](https://cloudflare.com) Pages for hosting the client, Worker with KV storage for hosting the server
17 | - [GitHub](https://github.com) workflows for CI and staging deployment
18 | - [Sentry](https://sentry.io/) integration for client-side error tracking
19 | - [pnpm](https://pnpm.io) for performant monorepo package management
20 |
21 | ## Installation
22 |
23 | ### Pre-requisites
24 |
25 | 1. [git](https://git-scm.com/downloads)
26 | 2. [pnpm](https://pnpm.io/installation) v9 and above
27 | 3. [node](https://nodejs.org/en/download/package-manager/current) v22.9.0 and above
28 |
29 | ### Install
30 |
31 | 1. Ensure you're using the correct version of Node:
32 | ```sh
33 | nvm use 22.9.0
34 | ```
35 | 2. If necessary, install the correct version of pnpm:
36 | ```sh
37 | npm i -g pnpm@9
38 | ```
39 | 3. Run the installation script:
40 | ```sh
41 | pnpm create react-spa-cloudflare@latest my-app
42 | ```
43 | 4. Check the console output for any warnings. The command will succeed unless the initial download fails.
44 |
45 | ## Getting Started
46 |
47 | 1. Navigate to your project directory and start the development server:
48 |
49 | ```sh
50 | cd my-app
51 | pnpm run dev
52 | ```
53 |
54 | 2. Navigate to the app at http://localhost:5173
55 | 3. You should see some hello text, components, and verified server connection
56 | 4. Follow your project's README for further setup instructions
57 |
58 | ## Debug
59 |
60 | ### ENOENT Error
61 |
62 | If you encounter an ENOENT error when running the create command, make sure to include a version:
63 |
64 | ```sh
65 | # ❌ Wrong! It's missing a version
66 | pnpm create react-spa-cloudflare
67 |
68 | # ✅ Correct!
69 | pnpm create react-spa-cloudflare@latest
70 | ```
71 |
--------------------------------------------------------------------------------
/client/src/example.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest';
2 | import {
3 | render,
4 | screen,
5 | fireEvent,
6 | waitForElementToBeRemoved,
7 | } from '@testing-library/react';
8 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
9 | import { Example } from './example';
10 |
11 | // Mock only the react-query hook
12 | vi.mock('@tanstack/react-query', async (importOriginal) => {
13 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports
14 | const mod = await importOriginal();
15 | return {
16 | ...mod,
17 | useQuery: vi.fn().mockReturnValue({ data: null }),
18 | };
19 | });
20 |
21 | describe('Example Component', () => {
22 | const queryClient = new QueryClient();
23 |
24 | const renderComponent = () =>
25 | render(
26 |
27 |
28 | ,
29 | );
30 |
31 | it('renders the component with correct initial state', () => {
32 | renderComponent();
33 |
34 | expect(screen.getByText('Hello from React!')).toBeDefined();
35 | expect(screen.getByText('Server connection: ❌')).toBeDefined();
36 | expect(screen.getByText('spinner example')).toBeDefined();
37 | expect(screen.getByText('show modal')).toBeDefined();
38 | expect(screen.queryByText('I am a modal!')).toBeNull();
39 | });
40 |
41 | it('opens the modal when the button is clicked', () => {
42 | renderComponent();
43 |
44 | const showModalButton = screen.getByRole('button', { name: 'show modal' });
45 | fireEvent.click(showModalButton);
46 |
47 | expect(screen.getByText('I am a modal!')).toBeDefined();
48 | });
49 |
50 | it('closes the modal when clicking outside', () => {
51 | renderComponent();
52 |
53 | const showModalButton = screen.getByRole('button', { name: 'show modal' });
54 | fireEvent.click(showModalButton);
55 |
56 | expect(screen.getByText('I am a modal!')).toBeDefined();
57 |
58 | // Simulate clicking outside the modal
59 | fireEvent.mouseDown(document);
60 |
61 | waitForElementToBeRemoved(screen.queryByText('I am a modal!'));
62 | });
63 |
64 | it('renders the spinner', () => {
65 | renderComponent();
66 |
67 | const spinnerText = screen.getByText('spinner example');
68 | expect(spinnerText).toBeDefined();
69 |
70 | // Check if the spinner is actually spinning
71 | // This assumes the Spinner component adds a class or attribute when spinning
72 | const spinnerParent = spinnerText.closest('[aria-busy="true"]');
73 | expect(spinnerParent).toBeDefined();
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/.github/workflows/stage.yml:
--------------------------------------------------------------------------------
1 | name: stage
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | target:
7 | description: 'Deploy target (client, server, or both)'
8 | required: true
9 | default: 'both'
10 | type: choice
11 | options:
12 | - client
13 | - server
14 | - both
15 | # note: push is required to register this workflow
16 | push:
17 |
18 | concurrency:
19 | group: ${{ github.workflow }}-${{ github.ref }}
20 | cancel-in-progress: true
21 |
22 | permissions:
23 | actions: write
24 | contents: read
25 |
26 | jobs:
27 | deploy-client:
28 | name: 🚀 Deploy client to stage
29 | if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'client' || github.event.inputs.target == 'both') }}
30 | runs-on: ubuntu-latest
31 | env:
32 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
33 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
34 | GITHUB_REF_NAME: ${{ github.ref_name }}
35 | GITHUB_SHA: ${{ github.sha }}
36 | steps:
37 | - name: ⬇️ Checkout repo
38 | uses: actions/checkout@v4
39 |
40 | - uses: pnpm/action-setup@v3
41 | name: Install pnpm
42 | with:
43 | version: 9
44 | run_install: false
45 |
46 | - name: ⎔ Setup node
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: 22.9.0
50 |
51 | - name: 📥 Install deps
52 | run: pnpm install
53 |
54 | - name: ⚙️ Build
55 | run: pnpm build
56 |
57 | - name: 🚀 Deploy client to stage
58 | run: pnpm run client deploy:stage
59 |
60 | deploy-server:
61 | name: 🚀 Deploy server to stage
62 | if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'server' || github.event.inputs.target == 'both') }}
63 | runs-on: ubuntu-latest
64 | env:
65 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
66 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
67 | GITHUB_REF_NAME: ${{ github.ref_name }}
68 | GITHUB_SHA: ${{ github.sha }}
69 | steps:
70 | - name: ⬇️ Checkout repo
71 | uses: actions/checkout@v4
72 |
73 | - uses: pnpm/action-setup@v3
74 | name: Install pnpm
75 | with:
76 | version: 9
77 | run_install: false
78 |
79 | - name: ⎔ Setup node
80 | uses: actions/setup-node@v4
81 | with:
82 | node-version: 22.9.0
83 |
84 | - name: 📥 Install deps
85 | run: pnpm install
86 |
87 | - name: ⚙️ Build
88 | run: pnpm build
89 |
90 | - name: 🚀 Deploy server to stage
91 | run: pnpm run server deploy:stage
92 |
--------------------------------------------------------------------------------
/server/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import { testClient } from 'hono/testing';
3 | import app from './index';
4 |
5 | const mockEnv = {
6 | ALLOWED_HOST: '*',
7 | ENV: 'test',
8 | };
9 |
10 | describe('GET /', () => {
11 | it('should return status 200', async () => {
12 | const response = await testClient(app, mockEnv).index.$get();
13 | expect(response.status).toBe(200);
14 | });
15 | it('should return "ok" text', async () => {
16 | const response = await testClient(app, mockEnv).index.$get();
17 | expect(await response.text()).toBe('ok');
18 | });
19 | });
20 |
21 | describe('Not Found', () => {
22 | it('should return status 404', async () => {
23 | const response = await app.request('/foo', { method: 'GET' }, mockEnv);
24 | expect(response.status).toBe(404);
25 | });
26 | it('should return "Not Found" message', async () => {
27 | const response = await app.request('/foo', { method: 'GET' }, mockEnv);
28 | expect(await response.json()).toEqual({ message: 'Not Found' });
29 | });
30 | });
31 |
32 | describe.each(['stage', 'prod'])('when in %s', (ENV) => {
33 | it('should allow specified host, ignoring subdomains (since our preview branches have hash subdomains)', async () => {
34 | const response = await testClient(app, {
35 | ...mockEnv,
36 | ENV,
37 | ALLOWED_HOST: 'foo.com',
38 | }).index.$get(undefined, {
39 | headers: { referer: 'https://lj98w4f.foo.com' },
40 | });
41 | expect(response.status).toBe(200);
42 | });
43 | it('should allow any referer when env.ALLOWED_HOST is "*"', async () => {
44 | const response = await testClient(app, {
45 | ...mockEnv,
46 | ENV,
47 | ALLOWED_HOST: '*',
48 | }).index.$get(undefined, {
49 | headers: { referer: 'https://foo.com' },
50 | });
51 | expect(response.status).toBe(200);
52 | });
53 | it('should not allow any referer when env.ALLOWED_HOST is set', async () => {
54 | const response = await testClient(app, {
55 | ...mockEnv,
56 | ENV,
57 | ALLOWED_HOST: 'bar.com',
58 | }).index.$get(undefined, {
59 | headers: { referer: 'https://foo.com' },
60 | });
61 | expect(response.status).toBe(403);
62 | });
63 | });
64 |
65 | describe('unsupported method', () => {
66 | it.each(['PUT', 'PATCH', 'DELETE'])(
67 | '%s should return a 404',
68 | async (method) => {
69 | const response = await app.request('/', { method }, mockEnv);
70 | expect(response.status).toBe(404);
71 | },
72 | );
73 | it.each(['PUT', 'PATCH', 'DELETE'])(
74 | '%s should return "Not Found"',
75 | async (method) => {
76 | const response = await app.request('/', { method }, mockEnv);
77 | expect(await response.json()).toEqual({ message: 'Not Found' });
78 | },
79 | );
80 | });
81 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 |
3 | logs
4 | _.log
5 | npm-debug.log_
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 | .pnpm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 |
13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
14 |
15 | # Runtime data
16 |
17 | pids
18 | _.pid
19 | _.seed
20 | \*.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 |
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 |
28 | coverage
29 | \*.lcov
30 |
31 | # nyc test coverage
32 |
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 |
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 |
41 | bower_components
42 |
43 | # node-waf configuration
44 |
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 |
49 | build/Release
50 |
51 | # Dependency directories
52 |
53 | node_modules/
54 | jspm_packages/
55 |
56 | # Snowpack dependency directory (https://snowpack.dev/)
57 |
58 | web_modules/
59 |
60 | # TypeScript cache
61 |
62 | \*.tsbuildinfo
63 |
64 | # Optional npm cache directory
65 |
66 | .npm
67 |
68 | # Optional eslint cache
69 |
70 | .eslintcache
71 |
72 | # Optional stylelint cache
73 |
74 | .stylelintcache
75 |
76 | # Microbundle cache
77 |
78 | .rpt2_cache/
79 | .rts2_cache_cjs/
80 | .rts2_cache_es/
81 | .rts2_cache_umd/
82 |
83 | # Optional REPL history
84 |
85 | .node_repl_history
86 |
87 | # Output of 'npm pack'
88 |
89 | \*.tgz
90 |
91 | # Yarn Integrity file
92 |
93 | .yarn-integrity
94 |
95 | # dotenv environment variable files
96 |
97 | .env
98 | .env.development.local
99 | .env.test.local
100 | .env.production.local
101 | .env.local
102 |
103 | # parcel-bundler cache (https://parceljs.org/)
104 |
105 | .cache
106 | .parcel-cache
107 |
108 | # Next.js build output
109 |
110 | .next
111 | out
112 |
113 | # Nuxt.js build / generate output
114 |
115 | .nuxt
116 | dist
117 |
118 | # Gatsby files
119 |
120 | .cache/
121 |
122 | # Comment in the public line in if your project uses Gatsby and not Next.js
123 |
124 | # https://nextjs.org/blog/next-9-1#public-directory-support
125 |
126 | # public
127 |
128 | # vuepress build output
129 |
130 | .vuepress/dist
131 |
132 | # vuepress v2.x temp and cache directory
133 |
134 | .temp
135 | .cache
136 |
137 | # Docusaurus cache and generated files
138 |
139 | .docusaurus
140 |
141 | # Serverless directories
142 |
143 | .serverless/
144 |
145 | # FuseBox cache
146 |
147 | .fusebox/
148 |
149 | # DynamoDB Local files
150 |
151 | .dynamodb/
152 |
153 | # TernJS port file
154 |
155 | .tern-port
156 |
157 | # Stores VSCode versions used for testing VSCode extensions
158 |
159 | .vscode-test
160 |
161 | # yarn v2
162 |
163 | .yarn/cache
164 | .yarn/unplugged
165 | .yarn/build-state.yml
166 | .yarn/install-state.gz
167 | .pnp.\*
168 |
169 | # wrangler project
170 |
171 | .wrangler/
172 |
--------------------------------------------------------------------------------
/README.project.md:
--------------------------------------------------------------------------------
1 | # React SPA Cloudflare
2 |
3 | This project is a pnpm monorepo with a client-side React SPA and a server-side Cloudflare Worker with KV storage. This project includes comprehensive testing, linting, and CI/CD setup, along with Sentry integration for error tracking.
4 |
5 | ## Features
6 |
7 | - [React](https://react.dev) SPA for the client-side application
8 | - [Tailwind CSS](https://tailwindcss.com/) for styling
9 | - [Tanstack Query](https://tanstack.com/query/latest) for client http request state management
10 | - [Hono](https://hono.dev/) server-side api framework
11 | - [Prettier](https://prettier.io/) for code formatting
12 | - [ESLint](https://eslint.org/) for linting
13 | - [Vitest](https://vitest.dev/) for unit testing
14 | - [Playwright](https://playwright.dev/) for end-to-end testing
15 | - [TypeScript](https://www.typescriptlang.org/) for type checking
16 | - [Cloudflare](https://cloudflare.com) Pages for hosting the client, Worker with KV storage for hosting the server
17 | - [GitHub](https://github.com) workflows for CI and staging deployment
18 | - [Sentry](https://sentry.io/) integration for client-side error tracking
19 | - [pnpm](https://pnpm.io) for performant monorepo package management
20 |
21 | ## Getting Started
22 |
23 | ### Pre-requisites
24 |
25 | 1. [pnpm](https://pnpm.io/installation) v9 and above
26 | 2. [node](https://nodejs.org/en/download/package-manager/current) v22.9.0 and above
27 |
28 | ### Installation
29 |
30 | 1. Ensure you're using the correct version of Node:
31 |
32 | ```sh
33 | nvm use 22.9.0
34 | ```
35 |
36 | 2. If necessary, install the correct version of pnpm:
37 |
38 | ```sh
39 | npm i -g pnpm@9
40 | ```
41 |
42 | 3. Install dependencies
43 |
44 | ```sh
45 | pnpm i
46 | ```
47 |
48 | ### Deployment Configuration
49 |
50 | 1. Follow instructions in [README.cloudflare](./README.cloudflare/README.cloudflare.md)
51 |
52 | 2. Follow instructions in [README.sentry](./README.sentry/README.sentry.md)
53 |
54 | ### Deploying a branch to stage
55 |
56 | 1. Click the "Actions" tab
57 | 2. Select the "stage" workflow
58 | 3. Open the dropdown for "Run workflow" and select the branch you wish to deploy
59 | 4. Choose your deploy target (client, server, both)
60 | 5. Click "Run workflow"
61 |
62 | The client app will deploy to the preview url, and the server will deploy to your staging worker.
63 |
64 | ## Scripts
65 |
66 | - `pnpm run dev`: Start the development server
67 | - `pnpm run lint`: Run ESLint
68 | - `pnpm run test`: Run unit tests with Vitest
69 | - `pnpm run typecheck`: Run TypeScript type checking
70 | - `pnpm run format`: Format code with Prettier
71 | - `pnpm run e2e`: Run end-to-end tests with Playwright
72 |
73 | Some convenience scripts for shortcuts:
74 |
75 | - `pnpm run clean`: Execute a clean install of package dependencies
76 | - `pnpm run client