A full-stack TypeScript template with React, Hono, and tRPC.
cp .env.example .env.local
npm install
npm startThis starts the client at localhost:8080 and the server at localhost:3000.
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).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. 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 1 MB because tRPC buffers inputs into memory before handlers run. If you
add routes that need large bodies (e.g. file uploads), give them their own route-scoped bodyLimit and stream the
body rather than buffering it.
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.
Dotenvx is used for storing encrypted environment variables in version control. This makes deployment and sharing configs easier. Ensure .env.keys is never committed, and that when decrypting the .env.* files, they are encrypted before committing them. Use .env.local to override existing variables with your own values.
npm run build
pm2 startOrReload ecosystem.config.cjs --time --update-envThe CI workflow builds on the GitHub runner, rsyncs build and node_modules to the server, then reloads pm2. 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 500m;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://localhost:3000;
}
}