├── .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 |
24 |
--------------------------------------------------------------------------------
/apps/website/public/logo-light.svg:
--------------------------------------------------------------------------------
1 |
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 |

38 |

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 |
113 | ),
114 | },
115 | {
116 | href: "https://rmx.as/discord",
117 | text: "Join Discord",
118 | icon: (
119 |
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 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/apps/website/src/client/theme/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Form, useLocation } from "react-router";
2 |
3 | import { Theme, useTheme } from ".";
4 |
5 | export function ThemeToggle() {
6 | const { pathname, search } = useLocation();
7 | const theme = useTheme();
8 | const isDark = theme === Theme.DARK;
9 | const nextTheme = isDark ? Theme.LIGHT : Theme.DARK;
10 |
11 | return (
12 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/website/src/env.server.ts:
--------------------------------------------------------------------------------
1 | import { env } from "node:process";
2 |
3 | declare global {
4 | interface ProcessEnv {
5 | NODE_ENV?: "production" | "development";
6 | readonly VERCEL_ENV?: "production" | "preview" | "development";
7 | }
8 | }
9 |
10 | const getEnv = (key: T) => env?.[key] as (typeof env)[T];
11 |
12 | export const APP_PORT = getEnv("PORT") || "3000";
13 | export const APP_URL = getEnv("APP_URL") || `localhost:${APP_PORT}`;
14 | export const GITPOD_WORKSPACE_URL = getEnv("GITPOD_WORKSPACE_URL")?.replace(
15 | "https://",
16 | `${APP_PORT}-`,
17 | );
18 | export const GITHUB_CODESPACE_URL = getEnv("CODESPACE_NAME")
19 | ? `${getEnv("CODESPACE_NAME")}-${APP_PORT}.app.github.dev`
20 | : undefined;
21 | export const VERCEL_URL = getEnv("VERCEL_PROJECT_PRODUCTION_URL");
22 | export const SITE_URL =
23 | GITPOD_WORKSPACE_URL || GITHUB_CODESPACE_URL || VERCEL_URL || APP_URL;
24 | export const NODE_ENV =
25 | getEnv("VERCEL_ENV") || getEnv("NODE_ENV") || "development";
26 |
27 | export const IS_PRODUCTION_BUILD = NODE_ENV === "production";
28 | export const IS_GITPOD_WORKSPACE = !!GITPOD_WORKSPACE_URL;
29 | export const IS_GITHUB_CODESPACE = !!GITHUB_CODESPACE_URL;
30 | export const IS_VERCEL = !!VERCEL_URL;
31 | export const IS_LOCALHOST = APP_URL.startsWith("localhost");
32 | export const IS_HOSTED =
33 | IS_GITPOD_WORKSPACE || IS_GITHUB_CODESPACE || IS_VERCEL || !IS_LOCALHOST;
34 |
--------------------------------------------------------------------------------
/apps/website/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import { type ReactRouterHono } from "@lazuee/react-router-hono";
2 |
3 | import { prettyJSON } from "hono/pretty-json";
4 | import * as env from "~/env.server";
5 | import { clientIp } from "./middleware/clientIp";
6 | import routes from "./routes";
7 |
8 | declare module "react-router" {
9 | export interface AppLoadContext {
10 | readonly env: typeof env;
11 | }
12 | }
13 |
14 | declare module "react-router" {
15 | interface LoaderFunctionArgs {
16 | context: AppLoadContext;
17 | }
18 | }
19 |
20 | const reactRouterHono: ReactRouterHono = {
21 | getLoadContext(ctx) {
22 | return {
23 | clientIp: ctx.var.clientIp,
24 | env,
25 | };
26 | },
27 | server(app) {
28 | app.use("*", prettyJSON({ space: 4 }), clientIp());
29 | app.route("/", routes);
30 | },
31 | };
32 |
33 | export default reactRouterHono;
34 |
--------------------------------------------------------------------------------
/apps/website/src/server/middleware/clientIp.ts:
--------------------------------------------------------------------------------
1 | import { type Env } from "hono";
2 |
3 | import { createMiddleware } from "hono/factory";
4 | import { isIP } from "is-ip";
5 |
6 | declare module "hono" {
7 | interface ContextVariableMap {
8 | clientIp?: string;
9 | }
10 | }
11 |
12 | declare module "react-router" {
13 | export interface AppLoadContext {
14 | readonly clientIp?: string;
15 | }
16 | }
17 |
18 | declare module "react-router" {
19 | interface LoaderFunctionArgs {
20 | context: AppLoadContext;
21 | }
22 | }
23 |
24 | export function clientIp() {
25 | return createMiddleware(async (ctx, next) => {
26 | const ipAddress = headerNames
27 | .flatMap((headerName) => {
28 | const value = ctx.req.header(headerName);
29 | if (!value) return [];
30 | if (headerName === "Forwarded") return parseForwardedHeader(value);
31 | return value.includes(",")
32 | ? value.split(",").map((ip) => ip.trim())
33 | : [value];
34 | })
35 | .find((ip) => ip && isIP(ip));
36 |
37 | ctx.set("clientIp", ipAddress);
38 |
39 | await next();
40 | });
41 | }
42 |
43 | const headerNames = Object.freeze([
44 | "X-Client-IP",
45 | "X-Forwarded-For",
46 | "HTTP-X-Forwarded-For",
47 | "Fly-Client-IP",
48 | "CF-Connecting-IP",
49 | "Fastly-Client-Ip",
50 | "True-Client-Ip",
51 | "X-Real-IP",
52 | "X-Cluster-Client-IP",
53 | "X-Forwarded",
54 | "Forwarded-For",
55 | "Forwarded",
56 | "DO-Connecting-IP",
57 | "oxygen-buyer-ip",
58 | ] as const);
59 |
60 | function parseForwardedHeader(value?: string) {
61 | return (
62 | value
63 | ?.split(";")
64 | .find((part) => part.trim().startsWith("for="))
65 | ?.slice(4) ?? undefined
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/apps/website/src/server/routes/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 |
3 | const app = new Hono();
4 |
5 | app.get("/ping", (c) => c.text("pong"));
6 |
7 | export default app;
8 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "jsx": "react-jsx",
6 | "module": "ES2022",
7 | "moduleResolution": "bundler",
8 |
9 | "baseUrl": ".",
10 | "paths": {
11 | "~/*": ["src/*"]
12 | },
13 | "rootDirs": [".", "./.react-router/types"],
14 | "types": ["@react-router/node", "vite/client"],
15 |
16 | "resolveJsonModule": true,
17 | "allowJs": true,
18 | "noEmit": true,
19 | "isolatedModules": true,
20 | "esModuleInterop": true,
21 | "forceConsistentCasingInFileNames": true,
22 | "strict": true,
23 | "skipLibCheck": true
24 | },
25 | "include": [
26 | "**/*",
27 | "**/.server/**/*",
28 | "**/.client/**/*",
29 | ".react-router/types/**/*"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/apps/website/vite.config.js:
--------------------------------------------------------------------------------
1 | //@ts-check
2 | import { env } from "node:process";
3 |
4 | import { reactRouterHono } from "@lazuee/react-router-hono";
5 | import { reactRouter } from "@react-router/dev/vite";
6 |
7 | import tailwindcss from "@tailwindcss/vite";
8 |
9 | import { defineConfig } from "vite";
10 | import tsconfigPaths from "vite-tsconfig-paths";
11 |
12 | const port = Number.parseInt(env?.PORT || "3000");
13 |
14 | export default defineConfig({
15 | build: {
16 | assetsInlineLimit: 0,
17 | chunkSizeWarningLimit: 1024,
18 | rollupOptions: {
19 | output: { minifyInternalExports: true },
20 | },
21 | },
22 | esbuild: {
23 | format: "esm",
24 | mangleCache: {},
25 | },
26 | plugins: [
27 | tailwindcss(),
28 | reactRouterHono({
29 | serverFile: "src/server/index.ts",
30 | }),
31 | reactRouter(),
32 | tsconfigPaths(),
33 | ],
34 | server: {
35 | port,
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | //@ts-check
2 | import { defineESLintConfig } from "@ntnyq/eslint-config";
3 |
4 | export default defineESLintConfig({
5 | ignores: ["**/README.md/*.ts"],
6 | importX: {
7 | typescript: true,
8 | overrides: {
9 | "import-x/consistent-type-specifier-style": ["error", "prefer-inline"],
10 | "import-x/no-duplicates": ["error", { "prefer-inline": true }],
11 | },
12 | },
13 | jsdoc: {
14 | overrides: {
15 | "jsdoc/no-types": "off",
16 | },
17 | },
18 | pnpm: {
19 | overridesJsonRules: {
20 | "pnpm/json-enforce-catalog": "off",
21 | "pnpm/json-prefer-workspace-settings": "off",
22 | },
23 | },
24 | typescript: {
25 | overrides: {
26 | "@typescript-eslint/no-use-before-define": "off",
27 | },
28 | parserOptions: {
29 | project: ["apps/*/tsconfig.json", "packages/*/tsconfig.json"],
30 | },
31 | },
32 | yml: {
33 | overrides: {
34 | "yml/quotes": ["error", { prefer: "double" }],
35 | },
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "private": true,
4 | "workspaces": [
5 | "apps/*",
6 | "packages/*"
7 | ],
8 | "scripts": {
9 | "build": "pnpm run build:package && pnpm run build:app",
10 | "build:app": "pnpm run -r --stream --if-present --parallel --filter {apps/**} --workspace-concurrency=Infinity /^build.*/",
11 | "build:package": "pnpm run -r --stream --if-present --parallel --filter {packages/**} --workspace-concurrency=Infinity /^build.*/",
12 | "dev": "pnpm run -r --stream --if-present --parallel --filter {apps/website} --workspace-concurrency=Infinity /^dev.*/",
13 | "lint": "pnpm run --stream --if-present /^lint:.*/",
14 | "lint:eslint": "eslint . --fix",
15 | "lint:stylelint": "stylelint --ignore-path .gitignore \"{apps,packages}/**/*.{css,scss}\"",
16 | "start": "pnpm run -r --stream --if-present --parallel --filter {apps/website} --workspace-concurrency=Infinity /^start.*/"
17 | },
18 | "devDependencies": {
19 | "@ntnyq/eslint-config": "^4.3.0",
20 | "cross-env": "^7.0.3",
21 | "eslint": "^9.28.0",
22 | "stylelint": "^16.20.0",
23 | "stylelint-config-clean-order": "^7.0.0",
24 | "stylelint-config-standard-scss": "^15.0.1",
25 | "stylelint-config-tailwindcss": "^1.0.0",
26 | "stylelint-scss": "^6.12.0"
27 | },
28 | "pnpm": {
29 | "overrides": {
30 | "react": "^19.1.0",
31 | "react-dom": "^19.1.0"
32 | },
33 | "onlyBuiltDependencies": [
34 | "@tailwindcss/oxide",
35 | "esbuild",
36 | "unrs-resolver"
37 | ]
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "packages/*"
3 | - "apps/*"
4 |
--------------------------------------------------------------------------------
/stylelint.config.js:
--------------------------------------------------------------------------------
1 | //@ts-check
2 |
3 | /**
4 | * @param {Omit &
5 | * { rules?: Partial } &
6 | * { rules?: Partial } &
7 | * { rules?: Partial<{ [K in keyof typeof import("stylelint")["default"]["rules"] as `${string & K}`]: any }>}} config
8 | */
9 | function defineConfig(config) {
10 | return config;
11 | }
12 |
13 | export default defineConfig({
14 | extends: ["stylelint-config-tailwindcss", "stylelint-config-clean-order"],
15 | rules: {
16 | "no-descending-specificity": null,
17 | "selector-class-pattern": null,
18 | "at-rule-no-unknown": [
19 | true,
20 | {
21 | ignoreAtRules: [
22 | "tailwind",
23 | "import",
24 | "config",
25 | "theme",
26 | "utility",
27 | "plugin",
28 | "apply",
29 | "variant",
30 | "screen",
31 | "source",
32 | ],
33 | },
34 | ],
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "framework": "vite",
4 | "installCommand": "pnpm install --no-frozen-lockfile",
5 | "buildCommand": "pnpm build",
6 | "cleanUrls": true,
7 | "github": {
8 | "enabled": true,
9 | "autoJobCancelation": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------