Skip to content

Migration Guide

Disponible en français

From TanStack CLI scaffold to production-ready Fenod patterns.

The TanStack CLI (pnpm create @tanstack/start@latest) gives you a working foundation. This guide transforms it into a production-ready codebase with:

  • Slices architecture (feature-based organization)
  • Type-safe error handling
  • Environment validation
  • Proper testing structure
  • CI/CD pipeline

After running pnpm create @tanstack/start@latest my-app --add-ons oRPC,drizzle,better-auth,shadcn,tanstack-query,cloudflare, you have:

my-app/
├── app/ # TanStack Start app
│ ├── routes/ # File-based routes
│ └── client.tsx # Client entry
├── app.config.ts # TanStack Start config
├── package.json
├── tsconfig.json
└── ...

Create a shared package for types, utilities, and constants used across packages.

Terminal window
mkdir -p packages/shared/src
packages/shared/package.json
{
"name": "@my-app/shared",
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./errors": "./src/errors.ts",
"./env": "./src/env.ts"
}
}
packages/shared/src/index.ts
export * from './errors';
export * from './env';

Update workspace references:

packages/api/package.json
{
"dependencies": {
"@my-app/shared": "workspace:*"
}
}
apps/web/package.json
{
"dependencies": {
"@my-app/shared": "workspace:*"
}
}

packages/shared/src/errors.ts
import { ORPCError } from '@orpc/server';
export const ErrorCode = {
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
VALIDATION: 'VALIDATION',
CONFLICT: 'CONFLICT',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
export class AppError extends ORPCError {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly details?: Record<string, unknown>
) {
super(code, message);
}
toJSON() {
return {
code: this.code,
message: this.message,
details: this.details,
};
}
}
export const Errors = {
unauthorized: (message = 'Authentication required') =>
new AppError(ErrorCode.UNAUTHORIZED, message),
forbidden: (message = 'Access denied') =>
new AppError(ErrorCode.FORBIDDEN, message),
notFound: (resource: string) =>
new AppError(ErrorCode.NOT_FOUND, `${resource} not found`),
validation: (field: string, message: string) =>
new AppError(ErrorCode.VALIDATION, message, { field }),
conflict: (message: string) =>
new AppError(ErrorCode.CONFLICT, message),
};

packages/shared/src/env.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
});
export type Env = z.infer<typeof envSchema>;
export function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
}
return result.data;
}

Create .env.example:

Terminal window
# Copy .env.example from repo root
cp /path/to/fenod-tech-stack/.env.example .env.example
cp .env.example .env

packages/api/src/
├── routers/
│ ├── users.ts
│ ├── posts.ts
│ └── index.ts
├── context.ts
└── index.ts
packages/api/src/
├── routers/
│ ├── user/
│ │ ├── index.ts
│ │ ├── router.ts
│ │ └── service.ts
│ ├── post/
│ │ ├── index.ts
│ │ ├── router.ts
│ │ └── service.ts
│ └── index.ts
├── middleware/
│ ├── auth.ts
│ └── ownership.ts
├── context.ts
└── index.ts

1. Create slice directory:

Terminal window
mkdir -p packages/api/src/routers/user

2. Split router into router.ts and service.ts:

packages/api/src/routers/users.ts
import { o, publicProcedure, protectedProcedure } from '../orpc';
import { db } from '../db';
import { users } from '@my-app/db/schema';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
export const userRouter = {
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const [user] = await db.select().from(users).where(eq(users.id, input.id));
if (!user) throw new Error('User not found');
return user;
}),
update: protectedProcedure
.input(z.object({
name: z.string().optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input, ctx }) => {
const [user] = await db
.update(users)
.set({ ...input, updatedAt: new Date() })
.where(eq(users.id, ctx.user.id))
.returning();
return user;
}),
};
packages/api/src/routers/user/service.ts
import { db } from '../../db';
import { users } from '@my-app/db/schema';
import { eq } from 'drizzle-orm';
import { Errors } from '@my-app/shared/errors';
export async function getById(id: string) {
const [user] = await db.select().from(users).where(eq(users.id, id));
if (!user) throw Errors.notFound('User');
return user;
}
export async function update(userId: string, data: { name?: string; email?: string }) {
const [user] = await db
.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.id, userId))
.returning();
if (!user) throw Errors.notFound('User');
return user;
}
packages/api/src/routers/user/router.ts
import { publicProcedure, protectedProcedure } from '../../orpc';
import { z } from 'zod';
import * as userService from './service';
export const userRouter = {
getById: publicProcedure
.input(z.object({ id: z.string() }))
.handler(({ input }) => userService.getById(input.id)),
update: protectedProcedure
.input(z.object({
name: z.string().optional(),
email: z.string().email().optional(),
}))
.handler(({ input, context }) => userService.update(context.user.id, input)),
};
packages/api/src/routers/user/index.ts
export { userRouter } from './router';
export * as userService from './service';

3. Update root router:

packages/api/src/routers/index.ts
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = {
user: userRouter,
post: postRouter,
};
export type AppRouter = typeof appRouter;

packages/api/src/middleware/auth.ts
import { o } from '../orpc';
import { Errors } from '@my-app/shared/errors';
export const authMiddleware = o.middleware(async ({ context, next }) => {
if (!context.user) {
throw Errors.unauthorized();
}
return next({
context: {
...context,
user: context.user, // Now guaranteed non-null
},
});
});
export const protectedProcedure = o.procedure.use(authMiddleware);
packages/api/src/middleware/ownership.ts
import { o } from '../orpc';
import { Errors } from '@my-app/shared/errors';
export function withOwnership<T extends { userId: string }>(
getResource: (id: string) => Promise<T | undefined>
) {
return o.middleware(async ({ context, input, next }) => {
const resource = await getResource((input as { id: string }).id);
if (!resource) {
throw Errors.notFound('Resource');
}
if (resource.userId !== context.user!.id) {
throw Errors.forbidden('You do not own this resource');
}
return next({
context: { ...context, resource },
});
});
}

packages/api/src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { AppError, ErrorCode } from '@my-app/shared/errors';
import { appRouter } from './routers';
const app = new Hono();
app.use('*', cors());
// Global error handler
app.onError((err, c) => {
console.error(JSON.stringify({
type: 'error',
path: c.req.path,
error: err.message,
timestamp: new Date().toISOString(),
}));
if (err instanceof AppError) {
return c.json(err.toJSON(), getHttpStatus(err.code));
}
return c.json({
code: ErrorCode.INTERNAL_ERROR,
message: 'An unexpected error occurred',
}, 500);
});
function getHttpStatus(code: string): number {
const statusMap: Record<string, number> = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
VALIDATION: 400,
CONFLICT: 409,
};
return statusMap[code] ?? 500;
}
export default app;

Terminal window
pnpm add -D vitest @vitest/coverage-v8 @testing-library/react jsdom
vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'jsdom',
globals: true,
include: ['**/*.test.{ts,tsx}'],
},
});

Add test file next to service:

packages/api/src/routers/user/service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getById } from './service';
describe('userService', () => {
it('throws not found for missing user', async () => {
await expect(getById('nonexistent')).rejects.toThrow('not found');
});
});
package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}

// tsconfig.json (root)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@my-app/api/*": ["packages/api/src/*"],
"@my-app/db/*": ["packages/db/src/*"],
"@my-app/shared/*": ["packages/shared/src/*"]
}
}
}

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test:run
- run: pnpm build

my-app/
├── .github/
│ └── workflows/
│ └── ci.yml
├── apps/
│ └── web/
│ ├── src/
│ │ ├── components/
│ │ │ └── error-boundary.tsx
│ │ ├── lib/
│ │ │ └── api/
│ │ │ ├── client.ts
│ │ │ └── error-handler.ts
│ │ └── routes/
│ └── package.json
├── packages/
│ ├── api/
│ │ ├── src/
│ │ │ ├── routers/
│ │ │ │ ├── user/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── router.ts
│ │ │ │ │ ├── service.ts
│ │ │ │ │ └── service.test.ts
│ │ │ │ └── index.ts
│ │ │ ├── middleware/
│ │ │ │ ├── auth.ts
│ │ │ │ └── ownership.ts
│ │ │ ├── context.ts
│ │ │ └── index.ts
│ │ └── package.json
│ ├── db/
│ │ ├── src/
│ │ │ └── schema/
│ │ ├── migrations/
│ │ └── package.json
│ └── shared/
│ ├── src/
│ │ ├── errors.ts
│ │ ├── env.ts
│ │ └── index.ts
│ └── package.json
├── .env.example
├── .gitignore
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.json
├── turbo.json
└── vitest.config.ts

  • Create packages/shared package
  • Add error handling utilities
  • Add environment validation
  • Create .env.example
  • Migrate routers to slices pattern
  • Separate router and service layers
  • Add auth middleware
  • Add ownership middleware
  • Add global error handler
  • Enable strict TypeScript
  • Add path aliases
  • Setup Vitest
  • Add initial tests
  • Setup CI pipeline
  • Configure deployment (see DEPLOYMENT.md)
  • Add E2E tests (see TESTING.md)
  • Setup monitoring
  • Deploy to staging
  • Deploy to production

Run pnpm install after adding workspace dependencies.

Use protectedProcedure which guarantees ctx.user exists.

Services should not import from routers. Keep the dependency direction: router → service → db.

Add vite-tsconfig-paths plugin to vitest config.