الهيكل النهائي
jobs-monorepo/├── apps/│ ├── web/ # React + Vite + TanStack Router/Query│ └── api/ # NestJS├── packages/│ ├── ui/ # Design System + Storybook│ ├── types/ # zod schemas + inferred types│ ├── tsconfig/ # base.json, react.json, nest.json│ └── eslint-config/ # flat configs├── turbo.json├── pnpm-workspace.yaml└── package.jsonهنستخدم دومين قريب من الشغل: Job Postings — الـ API بيستقبل وظيفة جديدة، والـ Front عنده فورم بيعمل validate بنفس القواعد بالظبط.
tsconfigconfigs
typeszod
ui
web
apiيستخدم types
@repo/tsconfig: tsconfig واحد يحكم الكل
packages/tsconfig/package.json
{ "name": "@repo/tsconfig", "version": "0.0.0", "private": true, "files": ["*.json"]}packages/tsconfig/base.json
{ "compilerOptions": { "strict": true, "target": "ES2022", "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "declaration": true, "noUncheckedIndexedAccess": true }}packages/tsconfig/react.json
{ "extends": "./base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true }}packages/tsconfig/nest.json
{ "extends": "./base.json", "compilerOptions": { "module": "CommonJS", "moduleResolution": "Node", "emitDecoratorMetadata": true, "experimentalDecorators": true, "sourceMap": true }}وفي أي app:
apps/web/tsconfig.json
{ "extends": "@repo/tsconfig/react.json", "include": ["src"]}متنساش تضيف "@repo/tsconfig": "workspace:*" في devDependencies بتاعة كل app.
@repo/eslint-config (Flat Config)
packages/eslint-config/base.js
import js from "@eslint/js";import tseslint from "typescript-eslint";export const base = [ js.configs.recommended, ...tseslint.configs.recommended, { ignores: ["dist/**", ".turbo/**"] },];packages/eslint-config/react.js
import reactHooks from "eslint-plugin-react-hooks";import { base } from "./base.js";export const react = [ ...base, { plugins: { "react-hooks": reactHooks }, rules: reactHooks.configs.recommended.rules, },];apps/web/eslint.config.js
import { react } from "@repo/eslint-config/react";export default [...react];@repo/types: zod schemas مشتركة
دي أحلى حاجة في الـ monorepo للـ fullstack: schema واحدة = validation في الـ API + validation في الفورم + TypeScript types. لو حد غيّر قاعدة، الاتنين يتغيّروا مع بعض، والـ typecheck يمسك أي مكان اتكسر.
packages/types/src/job.ts
import { z } from "zod";export const jobTypeEnum = z.enum(["full_time", "part_time", "contract"]);export const createJobSchema = z.object({ title: z.string().min(3, "العنوان قصير"), description: z.string().min(20), type: jobTypeEnum, salaryMin: z.number().int().positive().optional(), salaryMax: z.number().int().positive().optional(), remote: z.boolean().default(false),}).refine( (j) => !j.salaryMin || !j.salaryMax || j.salaryMin <= j.salaryMax, { message: "الحد الأدنى أكبر من الأقصى", path: ["salaryMax"] },);export type CreateJobInput = z.infer<typeof createJobSchema>;packages/types/src/index.ts
export * from "./job";packages/types/package.json
{ "name": "@repo/types", "version": "0.0.0", "private": true, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "tsup src/index.ts --format cjs,esm --dts --watch" }, "dependencies": { "zod": "^3.23.8" }, "devDependencies": { "@repo/tsconfig": "workspace:*", "tsup": "^8.3.0", "typescript": "^5.6.0" }}في الـ API (NestJS)
apps/api/src/common/zod-validation.pipe.ts
import { BadRequestException, PipeTransform } from "@nestjs/common";import type { ZodSchema } from "zod";export class ZodValidationPipe implements PipeTransform { constructor(private readonly schema: ZodSchema) {} transform(value: unknown) { const result = this.schema.safeParse(value); if (!result.success) { throw new BadRequestException(result.error.flatten()); } return result.data; }}apps/api/src/jobs/jobs.controller.ts
import { Body, Controller, Post } from "@nestjs/common";import { createJobSchema, type CreateJobInput } from "@repo/types";import { ZodValidationPipe } from "../common/zod-validation.pipe";import { JobsService } from "./jobs.service";@Controller("jobs")export class JobsController { constructor(private readonly jobs: JobsService) {} @Post() create(@Body(new ZodValidationPipe(createJobSchema)) dto: CreateJobInput) { return this.jobs.create(dto); }}في الـ Front (React + react-hook-form)
apps/web/src/features/jobs/CreateJobForm.tsx
import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { createJobSchema, type CreateJobInput } from "@repo/types";import { Button, TextField } from "@repo/ui";import { useCreateJob } from "./useCreateJob"; // TanStack Query mutationexport function CreateJobForm() { const form = useForm<CreateJobInput>({ resolver: zodResolver(createJobSchema) }); const createJob = useCreateJob(); return ( <form onSubmit={form.handleSubmit((data) => createJob.mutate(data))}> <TextField label="Title" {...form.register("title")} error={form.formState.errors.title?.message} /> <Button type="submit" loading={createJob.isPending}>Publish</Button> </form> );}@repo/ui + Storybook
packages/ui/package.json
{ "name": "@repo/ui", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts", "./styles.css": "./src/styles.css" }, "scripts": { "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "lint": "eslint .", "typecheck": "tsc --noEmit" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "devDependencies": { "@repo/tsconfig": "workspace:*", "@repo/eslint-config": "workspace:*" }}هنا اخترنا الطريقة التانية: Just-in-Time package — الـ exports بيشاور على .ts مباشرة من غير build. ده شغال تمام لأن المستهلك الوحيد هو web (Vite) وهو اللي بيعمل transpile.
| Just-in-Time (src مباشرة) | Compiled (dist) | |
|---|---|---|
| Setup | أبسط حاجة | محتاج tsup/vite lib |
| HMR | فوري 🔥 | محتاج watch |
| الكاش في Turbo | مفيش build يتكيّش | يتكيّش |
| ينفع مع NestJS/Node؟ | ❌ | ✅ |
| استخدامه هنا | ui | types |
cd packages/uipnpm dlx storybook@latest init # اختار React + Vitepnpm storybook
packages/ui/src/Button/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";import { Button } from "./Button";const meta: Meta<typeof Button> = { title: "Actions/Button", component: Button, args: { children: "Publish job" },};export default meta;export const Primary: StoryObj<typeof Button> = { args: { variant: "primary" } };export const Loading: StoryObj<typeof Button> = { args: { loading: true } };وضيف في turbo.json:
turbo.json (جزء)
"build-storybook": { "dependsOn": ["^build"], "outputs": ["storybook-static/**"]}الربط النهائي
package.json (root)
{ "name": "jobs-monorepo", "private": true, "packageManager": "pnpm@9.12.0", "scripts": { "dev": "turbo dev", "build": "turbo build", "lint": "turbo lint", "typecheck": "turbo typecheck", "test": "turbo test" }, "devDependencies": { "turbo": "^2.1.0", "typescript": "^5.6.0" }}pnpm installpnpm dev --filter=web... # web + ui + types (dev watch)pnpm dev --filter=api... # api + typespnpm turbo typecheck lint # كله مرة واحدة
Checklist الأسبوع التالت
🧠 اختبر نفسك
١) NestJS بيضرب بـ SyntaxError: Unexpected token 'export' لما يستورد @repo/types. ليه؟
الـ package بتشاور على ESM أو
.ts و Nest شغال CJS. ابنيها بـ tsup cjs+esm وظبّط exports.٢) "Invalid hook call" ظهر بعد ما ضفت @repo/ui. أول حاجة تبص عليها؟
React موجودة في
dependencies بتاعة ui بدل peerDependencies → نسختين React.٣) فين تحط typescript-eslint كـ dependency؟
في
@repo/eslint-config نفسها، مش في كل app.