├── .editorconfig ├── .gitattributes ├── .gitignore ├── .gitpod.yml ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.md ├── README.md ├── apps └── website │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── logo-dark.svg │ └── logo-light.svg │ ├── react-router.config.js │ ├── src │ ├── client │ │ ├── components │ │ │ └── layout │ │ │ │ ├── error.tsx │ │ │ │ └── root.tsx │ │ ├── entry.client.tsx │ │ ├── entry.server.bun.tsx │ │ ├── entry.server.node.tsx │ │ ├── entry.server.tsx │ │ ├── lib │ │ │ └── util.ts │ │ ├── root.tsx │ │ ├── routes.ts │ │ ├── routes │ │ │ ├── home.tsx │ │ │ └── not-found.tsx │ │ ├── styles │ │ │ └── tailwind.css │ │ └── theme │ │ │ ├── index.ts │ │ │ ├── route.ts │ │ │ ├── script.tsx │ │ │ └── toggle.tsx │ ├── env.server.ts │ └── server │ │ ├── index.ts │ │ ├── middleware │ │ └── clientIp.ts │ │ └── routes │ │ └── index.ts │ ├── tsconfig.json │ └── vite.config.js ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── stylelint.config.js └── vercel.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{*.md,*.mdx}] 12 | indent_size = unset 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # General Text Files 2 | *.md text eol=lf 3 | *.yaml text eol=lf 4 | *.yml text eol=lf 5 | *.json text eol=lf 6 | *.html text eol=lf 7 | *.css text eol=lf 8 | *.sass text eol=lf 9 | *.scss text eol=lf diff=css 10 | *.cnf text eol=lf 11 | *.conf text eol=lf 12 | *.config text eol=lf 13 | *.editorconfig text eol=lf 14 | .env text eol=lf 15 | .env.* text eol=lf 16 | .npmrc text eol=lf 17 | .gitattributes text eol=lf 18 | .gitconfig text eol=lf 19 | *.*ignore text eol=lf 20 | 21 | # JS/TS Files 22 | *.ts text eol=lf 23 | *.tsx text eol=lf 24 | *.mts text eol=lf 25 | *.js text eol=lf 26 | *.jsx text eol=lf 27 | *.cjs text eol=lf 28 | package.json text eol=lf 29 | package-lock.json text eol=lf -diff 30 | pnpm-lock.yaml text eol=lf -diff 31 | .prettierrc text eol=lf 32 | 33 | # Lock Files 34 | *.lock text eol=lf -diff 35 | 36 | # Image Files 37 | *.png binary 38 | *.jpg binary 39 | *.jpeg binary 40 | *.ico binary 41 | *.gif binary 42 | *.tiff binary 43 | *.webp binary 44 | 45 | # Audio/Video Files 46 | *.mp3 binary 47 | *.ogg binary 48 | *.mov binary 49 | *.mp4 binary 50 | *.swf binary 51 | *.webm binary 52 | *.avi binary 53 | 54 | # Archive Files 55 | *.7z binary 56 | *.rar binary 57 | *.tar binary 58 | *.zip binary 59 | 60 | # Font Files 61 | *.ttf binary 62 | *.eot binary 63 | *.otf binary 64 | *.woff binary 65 | *.woff2 binary 66 | 67 | # SVG File 68 | *.svg text eol=lf 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## macOS 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | Icon 6 | ._* 7 | .DocumentRevisions-V100 8 | .fseventsd 9 | .Spotlight-V100 10 | .TemporaryItems 11 | .Trashes 12 | .VolumeIcon.icns 13 | .com.apple.timemachine.donotpresent 14 | .AppleDB 15 | .AppleDesktop 16 | Network Trash Folder 17 | Temporary Items 18 | .apdisk 19 | 20 | ## Linux 21 | *~ 22 | .fuse_hidden* 23 | .directory 24 | .Trash-* 25 | .nfs* 26 | 27 | ## WebStorm 28 | .idea/**/workspace.xml 29 | .idea/**/tasks.xml 30 | .idea/**/usage.statistics.xml 31 | .idea/**/dictionaries 32 | .idea/**/shelf 33 | .idea/**/aws.xml 34 | .idea/**/contentModel.xml 35 | .idea/**/dataSources/ 36 | .idea/**/dataSources.ids 37 | .idea/**/dataSources.local.xml 38 | .idea/**/sqlDataSources.xml 39 | .idea/**/dynamic.xml 40 | .idea/**/uiDesigner.xml 41 | .idea/**/dbnavigator.xml 42 | *.iws 43 | 44 | ## Visual Studio Code 45 | .vscode/* 46 | !.vscode/settings.json 47 | !.vscode/tasks.json 48 | !.vscode/launch.json 49 | !.vscode/extensions.json 50 | !.vscode/*.code-snippets 51 | .history/ 52 | *.vsix 53 | 54 | # Vercel 55 | .vercel 56 | 57 | # Vite 58 | .vite-inspect 59 | vite.config.*.timestamp-* 60 | 61 | # Tsup 62 | .tsup 63 | 64 | # React Router 65 | .react-router 66 | 67 | # ESLint 68 | .eslintcache 69 | 70 | # Environment Files 71 | .env 72 | .env.* 73 | 74 | # Others 75 | node_modules 76 | build 77 | dist 78 | tsconfig.tsbuildinfo 79 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - before: curl -fsSL https://bun.sh/install | bash && source /home/gitpod/.bashrc && bun --version 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | strict-peer-dependencies=false 3 | package-manager-strict=false 4 | 5 | prefer-workspace-packages=true 6 | link-workspace-packages=true 7 | recursive-install=true 8 | 9 | save-workspace-protocol=false 10 | shamefully-hoist=true -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "shardulm94.trailing-spaces", 4 | "editorconfig.editorconfig", 5 | "stylelint.vscode-stylelint", 6 | "bradlc.vscode-tailwindcss", 7 | "usernamehw.errorlens", 8 | "dbaeumer.vscode-eslint" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.sortOrder": "type", 3 | "explorer.fileNesting.patterns": { 4 | "package.json": "pnpm-workspace.yaml, pnpm-lock.yaml" 5 | }, 6 | "npm.packageManager": "pnpm", 7 | "search.exclude": { 8 | "pnpm-lock.yaml": true 9 | }, 10 | "typescript.tsdk": "node_modules/typescript/lib", 11 | "javascript.preferences.importModuleSpecifier": "project-relative", 12 | "typescript.preferences.importModuleSpecifier": "project-relative", 13 | "typescript.enablePromptUseWorkspaceTsdk": true, 14 | "typescript.tsc.autoDetect": "off", 15 | "css.validate": false, 16 | "scss.validate": false, 17 | "less.validate": false, 18 | "scss.lint.unknownAtRules": "ignore", 19 | "css.lint.unknownAtRules": "ignore", 20 | "files.eol": "\n", 21 | "files.autoGuessEncoding": true, 22 | "files.trimFinalNewlines": true, 23 | "files.trimTrailingWhitespace": true, 24 | "files.autoSave": "onFocusChange", 25 | "prettier.enable": false, 26 | "editor.formatOnSave": false, 27 | "editor.formatOnPaste": false, 28 | "editor.formatOnSaveMode": "modificationsIfAvailable", 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": "always", 31 | "source.fixAll.ts": "explicit", 32 | "source.organizeImports": "never", 33 | "source.sortImports": "never" 34 | }, 35 | "editor.quickSuggestions": { 36 | "strings": "on" 37 | }, 38 | "eslint.enable": true, 39 | "eslint.useFlatConfig": true, 40 | "eslint.options": { 41 | "overrideConfigFile": "eslint.config.js" 42 | }, 43 | "eslint.workingDirectories": [ 44 | { 45 | "mode": "location" 46 | } 47 | ], 48 | "eslint.validate": [ 49 | "vue", 50 | "html", 51 | "yaml", 52 | "toml", 53 | "json", 54 | "jsonc", 55 | "json5", 56 | "markdown", 57 | "javascript", 58 | "typescript", 59 | "javascriptreact", 60 | "typescriptreact" 61 | ], 62 | "tailwindCSS.includeLanguages": { 63 | "typescript": "javascript", 64 | "typescriptreact": "javascript" 65 | }, 66 | "tailwindCSS.experimental.classRegex": [ 67 | ["(?:cn)\\(([^\\);]*)[\\);]", "[`'\"]([^'\"`,;]*)[`'\"]"] 68 | ], 69 | "stylelint.enable": true, 70 | "stylelint.configFile": "stylelint.config.js", 71 | "stylelint.packageManager": "pnpm", 72 | "stylelint.validate": ["css"], 73 | "[scss][css][tailwindcss]": { 74 | "editor.defaultFormatter": "stylelint.vscode-stylelint", 75 | "editor.codeActionsOnSave": { 76 | "source.fixAll.stylelint": "always" 77 | } 78 | }, 79 | "workbench.editor.customLabels.patterns": { 80 | "**/client/**/*": "${dirname}/${filename} [client]", 81 | "**/client/**/*.server.*": "${dirname}/${filename} [server]", 82 | "**/server/**/*": "${dirname}/${filename} [server]" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) `2024-2025` `lazuee` 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the “Software”), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-router v7 2 | -------------------------------------------------------------------------------- /apps/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "build": "cross-env NODE_ENV=production react-router build", 7 | "dev": "cross-env NODE_ENV=development react-router dev", 8 | "start": "cross-env NODE_ENV=production node ./build/server/index.js", 9 | "typecheck": "react-router typegen && tsc" 10 | }, 11 | "dependencies": { 12 | "@react-router/node": "^7.6.2", 13 | "hono": "^4.7.11", 14 | "is-ip": "^5.0.1", 15 | "isbot": "^5.1.28", 16 | "react": "^19.1.0", 17 | "react-dom": "^19.1.0", 18 | "react-router": "^7.6.2", 19 | "usehooks-ts": "^3.1.1" 20 | }, 21 | "devDependencies": { 22 | "@lazuee/react-router-hono": "^1.1.6", 23 | "@react-router/dev": "^7.6.2", 24 | "@tailwindcss/vite": "^4.1.8", 25 | "@types/node": "22.13.0", 26 | "@types/react": "^19.1.6", 27 | "@types/react-dom": "^19.1.6", 28 | "clsx": "^2.1.1", 29 | "cross-env": "^7.0.3", 30 | "tailwind-merge": "^3.3.0", 31 | "tailwindcss": "^4.1.8", 32 | "typescript": "^5.8.3", 33 | "vite": "^6.3.5", 34 | "vite-tsconfig-paths": "^5.1.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lazuee/react-router-hono-template/8e852aa43cf71aee76ec6f8f7400cfe95b2b891b/apps/website/public/favicon.ico -------------------------------------------------------------------------------- /apps/website/public/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 | -------------------------------------------------------------------------------- /apps/website/public/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 | -------------------------------------------------------------------------------- /apps/website/react-router.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | /** @param {import("@react-router/dev/config").Config} config */ 4 | function defineConfig(config) { 5 | return config; 6 | } 7 | 8 | export default defineConfig({ 9 | appDirectory: "src/client", 10 | future: { 11 | unstable_optimizeDeps: true, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/website/src/client/components/layout/error.tsx: -------------------------------------------------------------------------------- 1 | import { isRouteErrorResponse, useRouteError } from "react-router"; 2 | 3 | import { canUseDOM } from "~/client/lib/util"; 4 | 5 | export function ErrorLayout() { 6 | const parsed = parsedError(); 7 | if (!parsed.isClient) return null; 8 | 9 | const errorMessage = parsed.isRouteError 10 | ? `${parsed.error.status} - ${parsed.error.data || parsed.error.statusText}` 11 | : parsed.isError 12 | ? "Uncaught Exception" 13 | : "Unknown Error"; 14 | 15 | const errorStack = parsed.isError && parsed.error?.stack; 16 | 17 | return ( 18 |
19 |
20 |

21 | {errorMessage} 22 |

23 | {errorStack && ( 24 | <> 25 |

26 | An error occurred while loading this page. 27 |

28 |
29 |               {errorStack}
30 |             
31 | 32 | )} 33 |
34 |
35 | ); 36 | } 37 | 38 | export function parsedError() { 39 | const isClient = canUseDOM(); 40 | const error = useRouteError(); 41 | 42 | if (isClient) { 43 | if (isRouteErrorResponse(error)) { 44 | return { error, isClient, isRouteError: true }; 45 | } else if (error instanceof Error) { 46 | return { error, isClient, isError: true }; 47 | } else { 48 | return { error: error as Error, isClient, isUnknown: true }; 49 | } 50 | } 51 | 52 | return { 53 | error: error as Error, 54 | isClient: false, 55 | isServer: true, 56 | isError: true, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /apps/website/src/client/components/layout/root.tsx: -------------------------------------------------------------------------------- 1 | import { Links, Meta, Scripts, ScrollRestoration } from "react-router"; 2 | 3 | import { useTheme } from "~/client/theme"; 4 | import { ThemeScript } from "~/client/theme/script"; 5 | 6 | export function RootLayout({ children }: React.PropsWithChildren) { 7 | const theme = useTheme(); 8 | 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {children} 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/website/src/client/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { startTransition, StrictMode } from "react"; 2 | 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | import { HydratedRouter } from "react-router/dom"; 6 | 7 | function hydrate() { 8 | startTransition(() => { 9 | hydrateRoot( 10 | document, 11 | 12 | 13 | , 14 | ); 15 | }); 16 | } 17 | 18 | if (window.requestIdleCallback) { 19 | window.requestIdleCallback(hydrate); 20 | } else { 21 | window.setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /apps/website/src/client/entry.server.bun.tsx: -------------------------------------------------------------------------------- 1 | import { readableStreamToString } from "@react-router/node"; 2 | import { isbot } from "isbot"; 3 | import * as reactDomServer from "react-dom/server"; 4 | import { ServerRouter, type HandleDocumentRequestFunction } from "react-router"; 5 | 6 | export const streamTimeout = 10_000; 7 | 8 | const handleDocumentRequest: HandleDocumentRequestFunction = async ( 9 | request, 10 | responseStatusCode, 11 | responseHeaders, 12 | routerContext, 13 | _appLoadContext, 14 | ) => { 15 | let shellRendered = false; 16 | const userAgent = request.headers.get("user-agent"); 17 | const abortController = new AbortController(); 18 | request.signal.addEventListener("abort", abortController.abort); 19 | 20 | const stream = await reactDomServer.renderToReadableStream( 21 | , 22 | { 23 | signal: abortController.signal, 24 | onError(error: unknown) { 25 | responseStatusCode = 500; 26 | // Log streaming rendering errors from inside the shell. Don't log 27 | // errors encountered during initial shell rendering since they'll 28 | // reject and get logged in handleDocumentRequest. 29 | if (shellRendered) { 30 | console.error(error); 31 | } 32 | }, 33 | }, 34 | ); 35 | 36 | // Abort the rendering stream after the `streamTimeout` so it has tine to 37 | // flush down the rejected boundaries 38 | setTimeout(() => abortController.abort(), streamTimeout + 1000); 39 | 40 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 41 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 42 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 43 | await stream.allReady; 44 | } 45 | 46 | shellRendered = true; 47 | 48 | return readableStreamToString(stream).then((html) => { 49 | responseHeaders.set("Content-Type", "text/html; charset=utf-8"); 50 | 51 | return new Response(html, { 52 | status: responseStatusCode, 53 | headers: responseHeaders, 54 | }); 55 | }); 56 | }; 57 | 58 | export default handleDocumentRequest; 59 | -------------------------------------------------------------------------------- /apps/website/src/client/entry.server.node.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "node:stream"; 2 | 3 | import { 4 | createReadableStreamFromReadable, 5 | readableStreamToString, 6 | } from "@react-router/node"; 7 | import { isbot } from "isbot"; 8 | import * as reactDomServer from "react-dom/server"; 9 | import { ServerRouter, type HandleDocumentRequestFunction } from "react-router"; 10 | 11 | export const streamTimeout = 10_000; 12 | 13 | const handleDocumentRequest: HandleDocumentRequestFunction = ( 14 | request, 15 | responseStatusCode, 16 | responseHeaders, 17 | routerContext, 18 | _appLoadContext, 19 | ) => { 20 | return new Promise((resolve, reject) => { 21 | let shellRendered = false; 22 | const userAgent = request.headers.get("user-agent"); 23 | 24 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 25 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 26 | const readyOption: keyof reactDomServer.RenderToPipeableStreamOptions = 27 | (userAgent && isbot(userAgent)) || routerContext.isSpaMode 28 | ? "onAllReady" 29 | : "onShellReady"; 30 | 31 | const { pipe, abort } = reactDomServer.renderToPipeableStream( 32 | , 33 | { 34 | [readyOption]() { 35 | shellRendered = true; 36 | const body = new PassThrough(); 37 | const stream = createReadableStreamFromReadable(body); 38 | 39 | readableStreamToString(stream).then((html) => { 40 | responseHeaders.set("Content-Type", "text/html; charset=utf-8"); 41 | 42 | resolve( 43 | new Response(html, { 44 | status: responseStatusCode, 45 | headers: responseHeaders, 46 | }), 47 | ); 48 | }); 49 | 50 | pipe(body); 51 | }, 52 | onShellError(error: unknown) { 53 | reject(error); 54 | }, 55 | onError(error: unknown) { 56 | responseStatusCode = 500; 57 | // Log streaming rendering errors from inside the shell. Don't log 58 | // errors encountered during initial shell rendering since they'll 59 | // reject and get logged in handleDocumentRequest. 60 | if (shellRendered) { 61 | console.error(error); 62 | } 63 | }, 64 | }, 65 | ); 66 | 67 | // Abort the rendering stream after the `streamTimeout` so it has tine to 68 | // flush down the rejected boundaries 69 | setTimeout(abort, streamTimeout + 1000); 70 | }); 71 | }; 72 | 73 | export default handleDocumentRequest; 74 | -------------------------------------------------------------------------------- /apps/website/src/client/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import * as reactDomServer from "react-dom/server"; 2 | import * as bunEntry from "./entry.server.bun"; 3 | import * as nodeEntry from "./entry.server.node"; 4 | 5 | const isNode = "renderToPipeableStream" in reactDomServer; 6 | const entry = isNode ? nodeEntry : bunEntry; 7 | 8 | export const streamTimeout = entry.streamTimeout; 9 | export default entry.default; 10 | -------------------------------------------------------------------------------- /apps/website/src/client/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | 3 | import { useState } from "react"; 4 | import { twMerge } from "tailwind-merge"; 5 | import { useIsomorphicLayoutEffect } from "usehooks-ts"; 6 | 7 | export function canUseDOM() { 8 | const [isClient, setIsClient] = useState(false); 9 | 10 | useIsomorphicLayoutEffect(() => { 11 | setIsClient(true); 12 | }, []); 13 | 14 | return isClient; 15 | } 16 | 17 | export function cn(...inputs: ClassValue[]) { 18 | return twMerge(clsx(inputs)); 19 | } 20 | 21 | export function safeRedirect( 22 | to: FormDataEntryValue | string | null | undefined, 23 | ) { 24 | if ( 25 | !to || 26 | typeof to !== "string" || 27 | !to.startsWith("/") || 28 | to.startsWith("//") 29 | ) { 30 | return "/"; 31 | } 32 | return to; 33 | } 34 | -------------------------------------------------------------------------------- /apps/website/src/client/root.tsx: -------------------------------------------------------------------------------- 1 | import "./styles/tailwind.css"; 2 | 3 | import { Outlet, type ShouldRevalidateFunctionArgs } from "react-router"; 4 | import { type Route } from "./+types/root"; 5 | import { ErrorLayout } from "./components/layout/error"; 6 | 7 | import { RootLayout } from "./components/layout/root"; 8 | import { getTheme } from "./theme/route"; 9 | 10 | export function shouldRevalidate({ 11 | formData, 12 | defaultShouldRevalidate, 13 | }: ShouldRevalidateFunctionArgs) { 14 | return formData?.get("theme") ? true : defaultShouldRevalidate; 15 | } 16 | 17 | export async function loader({ request }: Route.LoaderArgs) { 18 | const theme = await getTheme(request); 19 | 20 | return { 21 | theme, 22 | }; 23 | } 24 | 25 | export default function App() { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | export function ErrorBoundary() { 34 | return ( 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/website/src/client/routes.ts: -------------------------------------------------------------------------------- 1 | import { index, route, type RouteConfig } from "@react-router/dev/routes"; 2 | 3 | const routes: RouteConfig = [ 4 | index("routes/home.tsx"), 5 | route("/theme", "theme/route.ts"), 6 | route("*", "routes/not-found.tsx"), 7 | ]; 8 | 9 | export default routes; 10 | -------------------------------------------------------------------------------- /apps/website/src/client/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement } from "react"; 2 | 3 | import { useLoaderData, type MetaFunction } from "react-router"; 4 | 5 | import { ThemeToggle } from "~/client/theme/toggle"; 6 | 7 | import { type Route } from "./+types/home"; 8 | 9 | export const meta: MetaFunction = () => { 10 | return [ 11 | { title: "New React Router App" }, 12 | { name: "description", content: "Welcome to React Router!" }, 13 | ]; 14 | }; 15 | 16 | export function loader({ context }: Route.LoaderArgs) { 17 | const { env } = context; 18 | 19 | return { env }; 20 | } 21 | 22 | export default function Page() { 23 | const { env } = useLoaderData(); 24 | 25 | return ( 26 |
27 |
28 |
29 |

30 | Welcome to React Router 31 |

32 |
33 | React Router 38 | React Router 43 |
44 |
45 | 89 |
90 |
91 | ); 92 | } 93 | 94 | const resources = [ 95 | { 96 | href: "https://reactrouter.com/dev", 97 | text: "React Router Docs", 98 | icon: ( 99 | 107 | 112 | 113 | ), 114 | }, 115 | { 116 | href: "https://rmx.as/discord", 117 | text: "Join Discord", 118 | icon: ( 119 | 127 | 131 | 132 | ), 133 | }, 134 | ]; 135 | -------------------------------------------------------------------------------- /apps/website/src/client/routes/not-found.tsx: -------------------------------------------------------------------------------- 1 | export function loader() { 2 | throw new Response("Page not found", { status: 404 }); 3 | } 4 | 5 | export default () => null; 6 | -------------------------------------------------------------------------------- /apps/website/src/client/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); 4 | 5 | @theme { 6 | --font-sans: -apple-system, blinkmacsystemfont, "Segoe UI", "Noto Sans", helvetica, arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 7 | --font-mono: ui-monospace, sfmono-regular, sf mono, menlo, consolas, liberation mono, monospace; 8 | } 9 | 10 | html, 11 | body { 12 | overflow-x: hidden; 13 | 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-font-smoothing: antialiased; 16 | text-size-adjust: 100%; 17 | text-rendering: optimizelegibility !important; 18 | 19 | @apply h-full bg-zinc-100 font-sans text-black dark:bg-zinc-950 dark:text-zinc-100; 20 | } 21 | -------------------------------------------------------------------------------- /apps/website/src/client/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { useNavigation, useRouteLoaderData } from "react-router"; 2 | 3 | import { type Route } from "../+types/root"; 4 | 5 | export enum Theme { 6 | LIGHT = "light", 7 | DARK = "dark", 8 | } 9 | 10 | export const isValidTheme = (theme: any): theme is Theme => 11 | theme && Object.values(Theme).includes(theme); 12 | 13 | export const useTheme = (): Theme => { 14 | let theme = useNavigation().formData?.get("theme"); 15 | theme ||= 16 | useRouteLoaderData("root")?.theme; 17 | 18 | return isValidTheme(theme) ? theme : Theme.DARK; 19 | }; 20 | -------------------------------------------------------------------------------- /apps/website/src/client/theme/route.ts: -------------------------------------------------------------------------------- 1 | import { createCookie, redirect, type ActionFunctionArgs } from "react-router"; 2 | 3 | import { safeRedirect } from "~/client/lib/util"; 4 | import { isValidTheme, Theme } from "."; 5 | 6 | const themeCookie = createCookie("theme", { 7 | maxAge: 60 * 60 * 24 * 365, 8 | httpOnly: true, 9 | sameSite: "lax", 10 | secrets: ["r0ut3r"], 11 | }); 12 | 13 | export const getTheme = async (request: Request) => { 14 | const cookie = await themeCookie.parse(request.headers.get("Cookie")); 15 | return isValidTheme(cookie?.theme) ? (cookie.theme as Theme) : Theme.DARK; 16 | }; 17 | 18 | export const action = async ({ request }: ActionFunctionArgs) => { 19 | const formData = await request.formData(); 20 | const theme = formData.get("theme"); 21 | if (!isValidTheme(theme)) throw new Response("Bad Request", { status: 400 }); 22 | 23 | return redirect(safeRedirect(formData.get("redirect")), { 24 | headers: { 25 | "Set-Cookie": await themeCookie.serialize({ theme }), 26 | }, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/website/src/client/theme/script.tsx: -------------------------------------------------------------------------------- 1 | import { useIsomorphicLayoutEffect } from "usehooks-ts"; 2 | 3 | import { type Theme } from "./"; 4 | 5 | export const ThemeScript = ({ theme }: { theme: Theme }) => { 6 | useIsomorphicLayoutEffect(() => { 7 | document.documentElement.dataset.theme = theme; 8 | }, [theme]); 9 | 10 | return ( 11 |