A full-stack TypeScript template with React, Hono, and tRPC.
Node 24 or newer; .nvmrc pins the version CI and pm2 use.
cp .env.example .env.local
nvm install
npm install
npm startThis starts the client at localhost:8080, the server at localhost:3000, and the email previewer at
localhost:9999. The client proxies /api and /trpc to the server.
Third-party integrations follow a ports-and-adapters pattern so providers can be swapped, and faked in tests:
src/server/services— provider-agnostic services. Each service is acreate*factory that declares the interfaces it depends on (e.g. the email service needs anEmailRendererand anEmailTransport, the auth service needs aPasswordHasher). Services never read env config directly.src/server/adapters— one file per provider, implementing a service interface (sendgrid.ts,argon2.ts,console.ts, ...). Swapping providers means writing a new adapter; services and actions don't change.src/server/index.ts— the composition root, and the only module that readsconfigat runtime. It decides which adapter each service uses (e.g. emails go to the console in development and SendGrid in production).vite.config.tsreadsconfigtoo, at build time, for the dev proxy target andBASE_PATH.createApppasses the services (andconfig) to every tRPC action via context, so actions useopts.ctx.email.send(...)and tests can inject fakes.
The database is the deliberate exception: createDatabase is a factory (so tests can run an isolated in-memory
SQLite instance), but models are used directly in Active Record style — Sequelize itself is the abstraction over
database providers.
The client mirrors the same idea. createApi in src/client/services/api.ts builds the tRPC and React Query
clients and declares what it depends on: a fetch (so tests can route requests to an in-memory server) and an
onUnauthorized callback (so an expired or revoked session signs the user out everywhere, wherever the failure
surfaces). src/client/index.tsx is the client's composition root and wires those to the real network and app
state; it reads import.meta.env.BASE_URL, never config. Auth state transitions live in the useAuth hook
(src/client/hooks/use-auth.ts) — pages call auth.login(...)/auth.register(...)/auth.logout() and never touch
the user state directly.
Request bodies on /trpc/* are capped at 10 MB, matching client_max_body_size in the NGINX example below so the
proxy and the app reject the same payloads. tRPC buffers inputs into memory before handlers run, so every accepted
byte is resident: routes that need genuinely large bodies (e.g. file uploads) should take their own route-scoped
bodyLimit and stream the body rather than raising this cap.
createApp also serves /api/health, which runs SELECT 1 and answers 503 when the database is unreachable.
Login, registration, and password-reset requests are rate limited per IP and per email. The default store
(createMemoryRateLimitStore) counts inside one process, so running more than one instance needs a shared store —
write a Redis adapter and pass it to createRateLimiter. The IP behind those keys comes from x-real-ip or the
rightmost x-forwarded-for entry while TRUST_PROXY is on; turn it off when the server faces the internet
directly, or a client can forge the header and get a fresh rate-limit bucket per request.
To add a service:
- Define the service factory and its adapter interfaces in
src/server/services/<name>.ts. - Implement a provider in
src/server/adapters/<provider>.ts. - Add it to
AppServicesinsrc/server/services/app.tsand wire it up insrc/server/index.ts.
npm testVitest specs live in src/test, with shared fakes in src/test/helpers.ts, and run as two projects: server
(*.test.ts, node environment) and client (*.test.tsx, jsdom). Services are unit tested against fake
adapters, and actions.test.ts runs the full login and password-reset flows through a tRPC caller backed by an
in-memory SQLite database and an in-memory email transport.
Client specs render the real React app against the real server: createApi is given a fetch that routes
requests straight to createApp's Hono instance and keeps session cookies like a browser, so client.test.tsx
covers register, login, logout, session revocation, and the full password-reset flow (including reading the reset
link out of the captured email) end to end without opening a socket.
src/config/index.ts validates every variable with Zod at import time, so a missing or malformed value fails the
build and the boot rather than the first request.
Dotenvx stores encrypted variables in version control, which makes deployment and
sharing configs easier. APP_ENV picks which encrypted file feeds the schema, and defaults to production:
.env.dev— encrypted development values, committed. Loaded whenAPP_ENV=development..env.uat— encrypted staging values, committed. Loaded whenAPP_ENV=staging..env.prod— encrypted production values, committed. Loaded otherwise..env.local— your own plaintext overrides, git-ignored, and wins on every value,APP_ENVincluded.
The template ships none of the encrypted files. Create each from .env.example, then encrypt it with
npm run env:encrypt:dev, :uat, or :prod before committing. npm run env:decrypt:* leaves the file in
plaintext, so encrypt it again before the next commit. .env.keys holds the private keys and must never be
committed.
npm run build
pm2 startOrReload ecosystem.config.cjs --time --update-envThe build parses config through vite.config.ts, so every variable must resolve at build time: from the file
APP_ENV selects with its key at hand (.env.keys locally, DOTENV_PRIVATE_KEY_PROD or DOTENV_PRIVATE_KEY_UAT
in CI), from .env.local, or from the shell. The built server reads that file again at boot, resolved from the
deploy root next to build. A staging deploy needs APP_ENV=staging on both the build and the server process.
.github/workflows/ci.yml type-checks, tests, and builds on the GitHub runner, rsyncs build, node_modules,
package.json, ecosystem.config.cjs, and .nvmrc to the server, then reloads pm2. It needs five repository
secrets: DOTENV_PRIVATE_KEY_PROD, HOST, USERNAME, PRIVATE_KEY (the SSH deploy key), and DEPLOY_PATH. The
workflow never copies .env.prod or .env.keys, so put those on the server yourself, or set the variables in
ecosystem.config.cjs.
Because node_modules is copied as-is, the runner and the server must share the same OS and CPU architecture so
native modules such as argon2 stay compatible. If they differ, run npm rebuild on the server after syncing, or
change the workflow to run npm ci on the server instead.
Example NGINX config:
server {
server_name _;
listen 80;
listen [::]:80;
client_max_body_size 10m;
index index.html;
root /var/www/project-name/build/client;
location / {
try_files $uri $uri/ /index.html =404;
}
location ~ ^/(api|trpc) {
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:3000;
}
}client_max_body_size matches the 10 MB /trpc/* cap, so a payload rejected by one is rejected by the other.
Raise both together, and only for routes that stream their own uploads. The proxy block sends no Upgrade/Connection headers because the template serves no
WebSockets — add them behind a map $http_upgrade when it does, so ordinary requests keep their keepalive.