Skip to content

Commit 2f51331

Browse files
authored
Merge pull request #5 from forge42dev/hono-server
Hono server / SEO + improvements
2 parents 3ae639f + 05907e9 commit 2f51331

22 files changed

Lines changed: 9308 additions & 13206 deletions

.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Add your env variables here
2+
DEPLOYMENT_ENV="staging"

.env.test

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
# Add your env variables here
1+
# Add your env variables here
2+
DEPLOYMENT_ENV="staging"

.vscode/settings.json

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{
2-
"editor.defaultFormatter": "biomejs.biome",
32
"editor.formatOnSave": true,
43
"editor.formatOnType": false,
54
"editor.renderWhitespace": "all",
@@ -25,10 +24,19 @@
2524
"[yaml]": {
2625
"editor.defaultFormatter": "redhat.vscode-yaml"
2726
},
28-
"[typescriptreact]": {
27+
"biome.enabled": true,
28+
"editor.defaultFormatter": "biomejs.biome",
29+
"[javascript][typescript][typescriptreact][javascriptreact][json][jsonc][vue][astro][svelte][css][graphql]": {
2930
"editor.defaultFormatter": "biomejs.biome"
3031
},
31-
"[typescript]": {
32-
"editor.defaultFormatter": "biomejs.biome"
32+
"explorer.fileNesting.patterns": {
33+
"*.ts": "${basename}.*.${extname}",
34+
".env": ".env.*",
35+
"*.tsx": "${basename}.*.${extname},${basename}.*.ts",
36+
"package.json": "*.json, *.yml, *.config.js, *.config.ts, *.yaml",
37+
"readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
38+
"Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
39+
"README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
40+
"Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*"
3341
}
3442
}

README.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ It includes a basic setup for a project with Remix.run and:
2121
- lefthook hooks
2222
- CI checks for quality control
2323
- remix-development-tools
24+
- Hono server
25+
- .env var handling for server and client
26+
- SEO robots.txt, sitemap-index and sitemap built in.
2427

2528
## Internationalization
2629

@@ -32,21 +35,44 @@ Features included out of the box:
3235
- language switcher
3336
- language detector (uses the request to detect the language, falls back to your fallback language)
3437

35-
## How to use
38+
## Hono server
3639

37-
1. Initialize the repository with our CLI:
38-
```bash
39-
npx f42 init -t base-stack -o ./your-project-name-here
40+
This stack uses Hono for the server. More information about Hono can be found [here](https://honojs.dev/).
41+
Another important thing to note is that we use a dependency called `react-router-hono-server` which is a wrapper for Hono that allows us to use Hono in our React Router application.
42+
43+
The server comes preconfigured with:
44+
- i18next middleware
45+
- caching middleware for assets
46+
- easily extendable global application context
47+
- .env injection into context
48+
49+
In order to add your own middleware, extend the context, or anything along those lines, all you have to do is edit the server
50+
inside the `entry.server.tsx` file.
51+
52+
## .env handling
53+
54+
This stack parses your `.env` file and injects it into the server context. For the client side, in the `root.tsx` file, we use the `useLoaderData` hook to get the `clientEnv` from the server and set it as a global variable on the `window` called `env`.
55+
If you need to access the env variables in both environments, you can create a polyEnv helper like this:
56+
```ts
57+
// app/utils/env.ts
58+
// This will return the process.env on the server and window.env on the client
59+
export const polyEnv = typeof process !== "undefined" ? process.env : window.env;
4060
```
61+
The server will fail at runtime if you don't set your `.env` file properly.
62+
63+
## Getting started
64+
65+
1. Fork the repository
66+
4167
2. Install the dependencies:
4268
```bash
43-
npm install
69+
pnpm install
4470
```
4571
3. Read through the README.md files in the project to understand our decisions.
4672

4773
4. Run the cleanup script:
4874
```bash
49-
npm run cleanup
75+
pnpm cleanup
5076
```
5177

5278
This will remove everything in the project related to the base-stack like README.md etc.

app/entry.server.tsx

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import { resolve } from "node:path"
21
import { PassThrough } from "node:stream"
3-
import type { EntryContext } from "@remix-run/node"
2+
import type { AppLoadContext, EntryContext } from "@remix-run/node"
43
import { RemixServer } from "@remix-run/react"
5-
import { Response } from "@remix-run/web-fetch"
4+
import type { Context } from "hono"
65
import { createInstance } from "i18next"
7-
import Backend from "i18next-fs-backend"
86
import { isbot } from "isbot"
97
import { renderToPipeableStream } from "react-dom/server"
108
import { I18nextProvider, initReactI18next } from "react-i18next"
9+
import { createHonoServer } from "react-router-hono-server/node"
10+
import { i18next } from "remix-hono/i18next"
11+
import { getClientEnv, initEnv } from "./env.server"
1112
import i18n from "./localization/i18n" // your i18n configuration file
12-
import i18next, { returnLanguageFromRequest } from "./localization/i18n.server"
13+
import i18nextOpts from "./localization/i18n.server"
1314
import { resources } from "./localization/resource"
1415

1516
const ABORT_DELAY = 5000
@@ -18,16 +19,16 @@ export default async function handleRequest(
1819
request: Request,
1920
responseStatusCode: number,
2021
responseHeaders: Headers,
21-
remixContext: EntryContext
22+
remixContext: EntryContext,
23+
appContext: AppLoadContext
2224
) {
2325
const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady"
2426
const instance = createInstance()
25-
const lng = await returnLanguageFromRequest(request)
26-
const ns = i18next.getRouteNamespaces(remixContext)
27+
const lng = appContext.lang
28+
const ns = i18nextOpts.getRouteNamespaces(remixContext)
2729

2830
await instance
2931
.use(initReactI18next) // Tell our instance to use react-i18next
30-
.use(Backend) // Setup our backend
3132
.init({
3233
...i18n, // spread the configuration
3334
lng, // The locale we detected above
@@ -49,7 +50,8 @@ export default async function handleRequest(
4950
responseHeaders.set("Content-Type", "text/html")
5051

5152
resolve(
52-
new Response(body, {
53+
// @ts-expect-error - We purposely do not define the body as existent so it's not used inside loaders as it's injected there as well
54+
appContext.body(body, {
5355
headers: responseHeaders,
5456
status: didError ? 500 : responseStatusCode,
5557
})
@@ -71,3 +73,41 @@ export default async function handleRequest(
7173
setTimeout(abort, ABORT_DELAY)
7274
})
7375
}
76+
77+
// Code below used to initialize our own Hono server!
78+
// Setup the .env vars
79+
const env = initEnv()
80+
81+
const getLoadContext = async (c: Context) => {
82+
// get the locale from the context
83+
const locale = i18next.getLocale(c)
84+
// get t function for the default namespace
85+
const t = await i18next.getFixedT(c)
86+
87+
const clientEnv = getClientEnv()
88+
return {
89+
lang: locale,
90+
t,
91+
env,
92+
clientEnv,
93+
// We do not add this to AppLoadContext type because it's not needed in the loaders, but it's used above to handle requests
94+
body: c.body,
95+
}
96+
}
97+
98+
interface LoadContext extends Awaited<ReturnType<typeof getLoadContext>> {}
99+
100+
/**
101+
* Declare our loaders and actions context type
102+
*/
103+
declare module "@remix-run/node" {
104+
interface AppLoadContext extends Omit<LoadContext, "body"> {}
105+
}
106+
107+
export const server = await createHonoServer({
108+
configure(server) {
109+
server.use("*", i18next(i18nextOpts))
110+
},
111+
defaultLogger: false,
112+
getLoadContext,
113+
})

app/env.server.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { z } from "zod"
2+
3+
const envSchema = z.object({
4+
NODE_ENV: z.enum(["development", "production", "test"]),
5+
DEPLOYMENT_ENV: z.enum(["staging", "production"]),
6+
})
7+
8+
type APP_ENV = z.infer<typeof envSchema>
9+
let env: APP_ENV
10+
/**
11+
* Helper method used for initializing .env vars in your entry.server.ts file. It uses
12+
* zod to validate your .env and throws if it's not valid.
13+
* @returns Initialized env vars
14+
*/
15+
export const initEnv = () => {
16+
const envData = envSchema.safeParse(process.env)
17+
18+
if (!envData.success) {
19+
console.error("❌ Invalid environment variables:", envData.error.flatten().fieldErrors)
20+
throw new Error("Invalid environment variables")
21+
}
22+
23+
env = envData.data
24+
25+
// Do not log the message when running tests
26+
if (env.NODE_ENV !== "test") {
27+
console.log("✅ Environment variables loaded successfully")
28+
}
29+
return envData.data
30+
}
31+
32+
/**
33+
* Helper method for you to return client facing .env vars, only return vars that are needed on the client.
34+
* Otherwise you would expose your server vars to the client if you returned them from here as this is
35+
* directly sent in the root to the client and set on the window.env
36+
* @returns Subset of the whole process.env to be passed to the client and used there
37+
*/
38+
export const getClientEnv = () => {
39+
const serverEnv = env
40+
return {
41+
NODE_ENV: serverEnv.NODE_ENV,
42+
}
43+
}
44+
45+
type CLIENT_ENV = ReturnType<typeof getClientEnv>
46+
47+
declare global {
48+
interface Window {
49+
env: CLIENT_ENV
50+
}
51+
namespace NodeJS {
52+
interface ProcessEnv extends APP_ENV {}
53+
}
54+
}

app/library/icon/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Icon generation and spritesheets
22

3-
This directory is the output directory for the icons. The icons are generated from the `resources/icons` directory.
3+
This directory is the output directory for the icons. The icons are generated from the `resources/icons` directory.
44

5-
The icons are generated using the `scripts/icons.ts` script.
5+
The icons are generated using the `vite-plugin-icons-spritesheet` package.
66

7-
All the icons are generated as symbols inside of a spritesheet svg element and the `Icon.tsx`
7+
All the icons are generated as symbols inside of a spritesheet svg element and the `Icon.tsx`
88
component uses the spritesheet to display the icons.
99

1010
The `Icon.tsx` component is a simple component that takes a `name` prop and displays the icon. It is fully

app/localization/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Localization
2+
3+
Localization works by using the `i18next` package. Everything is configured inside of this folder.
4+
The localization works by using the `/resources/locales` folder. This folder contains all the translations for the different languages. You can add new translations by adding new files to this folder and then changing the `resources.ts` file to include the new language.
5+
6+
The server part is set up in the `entry.server.tsx` file, and the client part, conversely, is in the `entry.client.tsx` file and also the `root.tsx` file.
7+
8+
The language is changed by setting the `lng` search parameter in the url.
9+
10+
## Server-side
11+
12+
Due to the fact that the server does not care about loading in additional resources as they are not send over the wire we
13+
pass in `resources` to the `i18next` instance. This provides all the languages to your server which allows it to render
14+
the correct language on the server.
15+
16+
## Client-side
17+
18+
The client-side is a bit more complicated. We do not want to load in all the languages on the client side as it would
19+
be a lot of requests. Instead, we use the fetch backend to load in the language files on the client side. We have a resource route inside of the `routes` directory which is in charge of loading in the resources. This route is called `resource.locales` and it is used to load in the languages. The `resource.locales` route is set up to only load in the languages and namespaces that are needed. In production we cache these responses and in development we don't cache them.

app/localization/i18n.server.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { resolve } from "node:path"
22
import { RemixI18Next } from "remix-i18next/server"
33
import i18n from "~/localization/i18n" // your i18n configuration file
4-
import type { Language } from "~/localization/resource"
54

65
const i18next = new RemixI18Next({
76
detection: {
@@ -19,8 +18,3 @@ const i18next = new RemixI18Next({
1918
})
2019

2120
export default i18next
22-
23-
export const returnLanguageFromRequest = async (request: Request) => {
24-
const lang = await i18next.getLocale(request)
25-
return lang as Language
26-
}

app/localization/resource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import english from "../../resources/locales/en/common.json"
33

44
const languages = ["en", "bs"] as const
55
export const supportedLanguages = [...languages]
6-
export type Language = (typeof languages)[number]
6+
type Language = (typeof languages)[number]
77

88
type Resource = {
99
common: typeof english

0 commit comments

Comments
 (0)