A minimal monorepo for a full-stack TypeScript application.
- Frontend - React and Vite with TanStack Router and Tailwind CSS, in
packages/frontend. - Backend - tRPC on Express, on Firebase Cloud Functions, in
packages/backend. - Shared code - types and arktype schemas, in
packages/common.
Monorepo tooling is provided by Turborepo.
- Basic routing setup with some example pages and protected routes
- Basic database setup with Firestore
- Minimal login/logout flow
- Authentication state provided through the router context
- Demo procedures, queries, and mutations
- Sample unit and E2E tests
Install these tools first:
| Tool | Version |
|---|---|
| Node.js | 22 or later |
| pnpm | 11 or later |
| Java | 21 or later (the Auth and Firestore emulators need it) |
You do not need a Firebase account to start. The template uses the project ID
demo-vtf-template. Firebase treats a demo- project as an offline project.
Before deploying, make sure to replace all instances of demo-vtf-template
with your actual Firebase project ID.
pnpm installpnpm devThis command starts three processes:
- Vite, on http://127.0.0.1:5173.
- The esbuild watcher, which bundles the backend after each change.
- The Firebase emulators. The Emulator UI is on http://127.0.0.1:4000.
pnpm checks # lint, format, types, and unit tests
pnpm test:e2e # Playwright, against the emulatorsYou can also run each check alone:
pnpm lint
pnpm format # write the changes
pnpm format:check # only report the problems
pnpm typecheck
pnpm testpnpm test:e2e starts the development environment if it is not running.
TanStack Query is the standard library for fetching data from your frontend and managing related state. It provides query and mutation hooks that simplify the data fetching lifecycle by handling caching and loading + error states for you.
Instead of fetching in a useEffect, always use query and mutation hooks to
manage state between your frontend and backend.
TanStack Router is a typesafe page router. It uses file based routing, meaning the route map of your app is defined by the directory structure of your pages.
TanStack Router also provides powerful validation, data loading, context management, and type checking features that ensure your routing is more robust. For example, linking to a non-existent page in your app becomes a type error that you can catch at build time.
tRPC is a typesafe remote procedure call framework. It allows you to define procedures on your backend (think of these as functions) and call them from your frontend.
tRPC differentiates itself from other frameworks by ensuring that you can only call your backend with input that adheres to the expected schema. Likewise, tRPC makes your frontend aware of the exact schema that each procedure will return.
Combined, this provides a better developer experience (your editor will show you the types each procedure accepts and returns) and rules out an entire class of bugs at build time (you can no longer give your backend bad data!).
tRPC is tightly integrated with TanStack Query, allowing you to easily turn your procedures into queries and mutations. You'll see examples of this in the template.
ArkType is a schema validator that prioritizes performance and similarity to TypeScript syntax. It is used at the boundary between the frontend and backend to ensure data is the correct shape and structure as it's passed across the wire.
It is highly recommended that you define all your data models as schemas first, then
infer their associated type with .infer. This reduces duplication and eliminates drift
between the schema and its associated TypeScript type.
ArkType adheres to the standard schema spec. Thus, you can drop in any equivalent schema validator if you prefer, like Zod.
oxc provides a collection of fast tooling for JavaScript and TypeScript.
This template uses oxlint for linting and oxfmt for formatting. oxlint is mostly
compatible with eslint and oxfmt is fully compatible with prettier.
The frontend always calls the relative path /api/trpc.
- In development, Vite sends
/apito the Functions emulator. - In production, Firebase Hosting sends
/api/**to theapifunction.
Express receives the same path in the two conditions. The tRPC client does not examine the environment.
TanStack Router reads the files in packages/frontend/src/routes. The name of
the file and its path gives the URL.
- Ensure the dev environment is running
- Make a file, for example
src/routes/about.tsx. - Vite will autogenerate the route scaffold in the file for you.
The Vite plugin writes src/routeTree.gen.ts again after each change.
Lint rules ban relative import path s. Use one of these aliases instead:
| Alias | Points to |
|---|---|
@common/* |
packages/common/src/* |
@backend/* |
packages/backend/src/* |
@frontend/* |
packages/frontend/src/* |
@e2e/* |
e2e/* |
import { auth } from "@frontend/lib/firebase.ts"; // good
import { auth } from "../lib/firebase.ts"; // error- Put the input schema in
packages/common/src/schemas.ts. - Add the procedure to
packages/backend/src/routers/index.ts. UsepublicProcedurefor open access. UseprotectedProcedureto make a Firebase ID token necessary. - Call the procedure from the frontend with
trpc.<name>.queryOptions().
The editor shows the new types immediately. No build step is necessary, because each package exports its TypeScript source.
For a test of the new procedure, copy the pattern in
packages/backend/src/routers/index.test.ts. These tests call the procedures
directly. They do not need the emulators.
-
Make a project in the Firebase console.
-
Turn on Authentication, Firestore, Hosting, and Cloud Functions.
-
Set the project for the Firebase CLI:
pnpm exec firebase use --add -
Copy
.env.exampleto.env. -
Put your web app configuration in
.env.
pnpm deployThe command does two steps. First it builds the two packages. Then it sends them to Firebase.
esbuild puts the backend and @repo/common into one file,
packages/backend/dist/index.js. The build also writes a small
package.json next to it. That file has no workspace:* dependency, so
Firebase can install it. This is necessary, because Firebase cannot read a
pnpm workspace.
.github/workflows/deploy.yaml does the same steps after each push to main.
Add these values to the repository first:
- Replace all instances of
demo-vtf-templatewith your real project ID. - Secret
FIREBASE_SERVICE_ACCOUNT- a service account key, in JSON. - Secret
VITE_FIREBASE_API_KEY. - Variables
FIREBASE_PROJECT_ID,VITE_FIREBASE_AUTH_DOMAIN,VITE_FIREBASE_PROJECT_ID, andVITE_FIREBASE_APP_ID.
.github/workflows/checks.yaml runs six jobs on each pull request. GitHub does
not block a merge until you make the jobs necessary. Do this one time:
gh api -X POST repos/:owner/:repo/rulesets --input - <<'EOF'
{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
},
"rules": [
{ "type": "pull_request" },
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "Typecheck" },
{ "context": "Lint" },
{ "context": "Format" },
{ "context": "Unit Tests" },
{ "context": "E2E Tests" },
{ "context": "Full Build" }
]
}
}
]
}
EOFpackages/common Types and arktype schemas. No build step.
packages/backend tRPC router, Express app, auth middleware.
packages/frontend React application, routes, and tRPC client.
e2e Playwright tests.
firebase.json Emulators, Hosting rewrite, and function source.
turbo.json The build, typecheck, and lint tasks.
vitest.config.ts One unit-test runner for all packages.