Skip to content
CodeSook
CodeSook

Turborepo Guide EP1


turborepo ep1 cover
#Turborepo#Monorepo#Typescript
CodeSookPublish: 10th March 2025

What is Monorepo

Monorepo (Monolithic Repository) เป็นกลยุทธ์ในการจัดการซอร์สโค้ดแบบหนึ่งที่รวมโค้ดทั้งหมดของ project ไว้ในที่เดียว แทนที่จะแยกเป็นหลายๆ repository แยกกัน ลองนึกภาพว่าแทนที่จะมี repository แยกกันสำหรับแต่ละ apps หรือ libraries คุณจะมี repository เดียวที่เก็บโค้ดทั้งหมดไว้ด้วยกัน

องค์กรใหญ่ๆ อย่าง Google, Facebook, Microsoft และอีกหลายบริษัทใช้ Monorepo หรือเปล่าก็ไม่รู้ ผมไม่เคยทำงานที่นั่น ฮ่าๆ 😆😆😆 (แล้วจะยกมาเพื่อ? ฮ่าๆ)

Why do we need Monorepo

ทำไมถึงควรใช้ Monorepo? การใช้ Monorepo มีข้อดีหลายประการที่ช่วยให้การสร้าง software ง่ายขึ้น

  1. Share codes เมื่อทุกอย่างอยู่ในที่เดียวกัน การแชร์โค้ดระหว่างโปรเจกต์ทำได้สะดวกมาก ไม่ต้องผ่านระบบการจัดการแพคเกจที่ซับซ้อน ทีมสามารถสร้าง libraries แล้ว ใช้งานได้ทันทีในทุก apps
  2. Atomic Changes เมื่อต้องแก้ไขโค้ดที่มีผลกระทบต่อหลาย apps Monorepo ช่วยให้คุณทำการเปลี่ยนแปลงทั้งหมดใน commit เดียว ทำให้มั่นใจได้ว่าทุกอย่างยังทำงานร่วมกันได้อย่างถูกต้อง

Before start

ในบทความชุด ชุดนี้ เพื่อนๆจะไม่ได้แค่เรียนรู้การใช้งาน Turborepo เพียงอย่างเดียวนะครับ

สิ่งที่นอกเหนือจากการใช้ Turborepo ในสไตล์ผม ก็เช่น

  • จะได้รู้วิธีการสร้าง libraries ใช้เอง,
  • การ bundle libraries,
  • export files ไปให้ apps หรือ packages อื่นๆเอาไปใช้งาน เราจะใส่ type อย่างไร,
  • การ bundle แบบครบจบในไฟล์เดียวทำอย่างไร,
  • การ setup eslint ที่จะใช้กับ Monorepo,
  • ถ้าไม่อยากใช้ Turborepo ก็ยังจะไปใช้ Moonrepo ที่เป็น Monorepo managment แทนได้เลย ใช้วิธีการเหมือนกันเด๊ะๆ เขาเป็นคู่แข่งกัน (เอาจริงๆผมชอบ Moon มากกว่าด้วย แต่เขายังทำระบบ remote caching ไม่เสร็จ) ก็เลยเลือกใช้ Vercel Turborepo ไปก่อน
  • การใช้ Dagger แบบนิดหน่อย
  • การใช้ Github actions แบบนิดหน่อย ผมก็พึ่งเริ่มหัดใช้
  • รู้จักวิธีใช้งาน rslib library เพื่อ build packages กับ apps ของเรา

บทความจะแบ่งเป็น 3 ep นะครับ

  1. EP1 จะเกี่ยวกับการสร้าง Turborepo ใส่ apps ต่างๆ ใส่ packages ต่างๆ
  2. EP2 จะสร้าง dockerfile สำหรับ apps ต่างๆของเรา โดยใช้ Dagger ดีไหมนะ
  3. EP3 จะสร้างตัวอย่าง pipeline สำหรับ CD ง่ายๆ (อาจจะเป็น Cloudbuild หรีือ Github actions เดี๋ยวดูก่อน ผมยังใหม่กับ Github actions น่ะครับ)

ก่อนจะไปเริ่ม ผมจะเล่าให้ฟังก่อนว่าจะพาทำอะไรบ้างใน blog นี้

ผมจะสร้าง Monorepo โดยใช้ Turborepo นะครับ โดยใช้ Nodejs กับ pnpm นะครับ ถ้าใครใช้ Bun จะยังไม่ support นะครับ ผมเห็นเขาเขียนว่า Beta ถ้าใครจะลองก็จัดไป

จริงๆถ้าเอาแค่ Monorepo จะใช้ pnpm workspace ก็ได้แหละ แต่ที่ผมเลือก Turborepo เพราะจะใช้ Remote caching ด้วยน่ะครับ
แต่ผมจะไม่ได้ใช้ caching server ของ Vercel นะครับ ส่วน caching จะไม่ได้พูดถึงใน blog นี้นะครับ ถ้าใครสนใจก็บอกได้ เดี๋ยวผมเขียน blog ให้

ใน Monorepo ผมจะมี
apps

  1. Backend จะใช้ 🔥 Hono
  2. Frontend จะใช้ 🌴 Tanstack start นะครับ ตรงนี้ต้อง note ไว้นะครับว่า Tanstack start ยังเป็น Beta อยู่ และใน blog นี้ผมเน้นไปที่ Turborepo นะครับ ไม่ใช่ตัวของ Tanstack start

packages

  1. ESLint config เราจะ setup eslint config ไว้ในที่เดียวตรง package นี้ ทำเป็น preset ไว้ เอาไว้ให้ apps กับ packages อื่นๆเรียกใช้ได้ง่ายๆ เพราะทุกๆ apps ทุกๆ packages เราก็มักจะใช้ config เดียวกัน ผมเขียน config เป็น .ts ด้วยนะ
    หลายคนอาจจะไม่เคยใช้ .ts กับ eslint config เพราะมันต้องมีขั้นตอนเพิ่มเข้ามา แต่ก็นิดเดียวเองนะ
  2. Rslib config ผมจะใช้ Rslib ในการ build ทั้ง apps และ packages
    ทำไมผมถึงเลือก Rslib เพราะว่ามันเร็ว ฮ่าๆ หรือเพื่อนๆจะใช้ตัวอื่นก็ได้นะ เช่น tsup, pkgroll วิธีการเหมือนกันเลย ทีแรกผมจะใช้ unbuild เพราะชอบที่เขาใช้ jiti แต่พึ่งเห็นว่าเขาจะ remove jiti ออกก็เลยเปลี่ยนดีกว่า
  3. Prisma เอา prisma มาสร้าง packages แล้วให้ Hono เรียกใช้ หรือถ้ามี Backend อีกตัวก็จะแชร์ prisma package ร่วมกัน แต่การที่ Backend 2 ตัวแชร์ Database ร่วมกันผมจะไม่พูดถึงนะครับ อย่างที่บอกว่าจะพาทำ Turorepo
  4. UI Components ผมจะเอา Shadcn components มาไว้ที่ Package แทน เผื่อว่ามี Frontend อีกตัวต้องการใช้ Components เหมือนกัน จะได้ไม่ต้องทำใหม่
  5. Hono App จริงๆผมจะเอา Hono app มาไว้ที่ Package นี่แหละ แต่เป็น Hono ที่ไม่ได้ start server เดี๋ยวจะให้ Hono ตัวที่เป็น App เป็นคน start server
    ทำไมต้องเอา Hono app มาไว้ที่ Package ด้วย เหตุผลเดียวของผมคือ ผมอยากได้ E2E Type-safe คือจะให้ Frontend ดึง Type ที่ได้จาก Hono app ไปใช้งาน ทำให้ Frontend รู้ได้เลยว่า มี API Routes อะไรบ้าง ต้องส่ง request ยังไง และจะได้รับ Response อะไรบ้าง

global ผมมี setup สำหรับ ใช้กับภาพรวมของ Monorepo ด้วย

  1. Git hooks using Husky จริงอยากใช้ Lefthook มากกว่า แต่มีน้องๆในทีมที่ใช้ Windows แล้วมันไม่ทำงาน ผมก็ไม่รู้จะแก้ยังไง ฮ่าๆ
  2. Gitmoji ก็จะใส่ emoji ใน git commit message ด้วยอะ
  3. Lintstage เราจะไม่ ตรวจสอบ lint กับทุกๆไฟล์ ตรวจเฉพาะไฟล์ที่แก้ไขไปก็พอ

Dockerfile ผมจะเขียน Dockerfile เป็นตัวอย่างให้ด้วย ส่วนนี้จะแยกเป็นอีก Blog นึงเป็น EP2 นะครับ

  1. Dagger มันจะพิเศษตรงที่ผมจะใช้ Dagger นะครับ
  2. Dockerfile หรืออาจจะใช้แค่ Dockerfile

CICD Pipeline ผมจะเขียน CICD Pipeline เป็นตัวอย่างให้ด้วย ส่วนนี้ก็จแยกเป็นอีก Blog นึงเป็น EP3 นะครับ

  1. Github actions ผมยังใหม่กับ Github actions ถ้าผิดพลาดยังไงรบกวนชี้แนะด้วยครับ

Init Turborepo

เริ่มสร้าง Turborepo project กันเลย

Terminal window
pnpm dlx create-turbo@latest

เราจะได้ files & folders มาแบบนี้

Terminal window
.
├── apps
├── docs # Nextjs
└── web # Nextjs
├── package.json
├── packages
├── eslint-config
├── typescript-config
└── ui
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── turbo.json

จะเห็นว่า ใน folder apps จะมี docs กับ web มาด้วย ซึ่งทั้่งคู่เป็น Nextjs ผมจะไม่ใช้ ดังนั้นผมจะลบมันทิ้งไปเลย

Terminal window
.
├── apps
├── docs
└── web
├── package.json
├── packages
├── eslint-config
├── typescript-config
└── ui
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── turbo.json

turbo.json

มาดูไฟล์ turbo.json กันก่อนเลย

{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"check-types": {
"dependsOn": ["^check-types"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}

ผมเพิ่ม dist เข้าไป และเอา .next/**, !.next/cache/** ออกไป

  • $schema แค่บอก IDE ว่า ไฟล์ json นี้มี config อะไรบ้าง มันจะมี auto complete มาช่วยเราด้วย

  • ui มี value ได้ 2 แบบ

      1. stream ด้วยความที่เราใช้ monorepo เรามักจะรันหลายๆ apps พร้อมๆกัน การใช้ stream จะเป็นการบอกให้ Turborepo เอาสิ่งที่ apps พ่นลง console มากองรวมกันใน Terminal อันเดียว ใครพ่นอะไรมาเวลาไหนมันจะเรียงลำดับให้เลยตามเวลาก่อนหลัง
      1. tui แทนที่จะพ่นทุกอย่างจากทุก apps มาใน Terminal ตัวเดียวกัน Turborepo จะมีหน้าต่างแยกเป็นราย app เลย เราก็กดลูกศรเลือกเอาว่าอยากดู console ของ app ไหน
  • tasks อันนี้จะมีรายละเอียดเยอะหน่อย

    • build
    • lint
    • check-types
    • dev

ทั้ง 4 ตัว คือชื่อของ tasks เราสามารถตั้งชื่อเองได้เลย มันจะเป็นคำสั่งที่เราจต้องไปใช้คู่กับคำสั่ง pnpm turbo run <task's name>
และชื่อ tasks นี้จะต้องตรงกับชื่อคำสั่ง scripts ที่อยู่ใน package.json ด้วย สมมติเราสั่ง pnpm turbo run dev ตัว Turborepo จะไปไล่ดูว่ามี apps หรือ packages ไหนบ้าง ที่มีคำสั่ง dev อยู่ใน package.json.scripts บ้าง

ถัดมา มาดู properties ของ task แต่ละตัวกัน

  • dependsOn ตัวอย่างเช่น dependsOn: ["^build"] อันนี้เป็นการบอก turborepo ว่า ถ้าก่อนที่จะทำงาน task build (ชื่อ task นี้) ให้ไปดูว่า packages ไหน apps ไหนต้องรันก่อนหลังแล้ว turbo จะเรียงการทำงานตามลำดับให้ ให้สังเกตว่าจะมีเครื่องหมาย ^ อยู่ด้านหน้าด้วย ส่วนแบบไม่มีเครื่องหมาย หรือเครื่องหมายอื่นๆ ผมไม่ได้ใช้เลย
  • inputs คือไฟล์และรูปแบบที่ Turborepo จับตาดูเพื่อพิจารณาว่าจำเป็นต้องรัน task ซ้ำหรือไม่ ตัวอย่างเช่น task ชื่อ “build” มี “inputs”: [“$TURBO_DEFAULT$”, “.env*”] นั่นหมายความว่า task “build” จะดูที่ไฟล์ .env* (ชื่อไฟล์ที่ขึ้นต้นด้วย .env ตามด้วยคำว่าอะไรก็ได้) ถ้ามีการเปลี่ยนแปลงก็จะทำ task build ใหม่
  • outputs บอก Turborepo ว่าให้เก็บ cache ที่ files ไหน folders ไหน สามารถใส่เครื่องหมาย ! เพื่อบอกว่าห้ามเก็บ cache ที่ files นี้ folders นั้น
  • cache บอกว่าต้องเก็บ cache ไหม เรามักจะใช้กับ task “dev” นี่แหละเพราะว่ามันไม่ได้ generate อะไร แค่ start server แล้วทำงานไปยาวๆ
  • persistent มักจะใส่ใน task “dev” เพื่อบอก Turborepo ว่า task “dev” จะทำงานไปยาวๆเลย ไม่ต้องรอคำสั่งให้ทำงานเสร็จ ไปทำ task “dev” ของ apps หรือ packages อื่นๆได้เลย

Setup Eslint

การ setup ESLint ใน Turborepo ผมเสียเวลากับมันนานมาก สุดท้ายก็มาเป็นท่าที่พาเพื่อนๆทำนี่แหละ
ตอนนี้ก็ยังไม่แน่ใจว่ามันโอเคจริงๆใช่ไหม แต่มัน Work กับผมละนะ

มาเริ่มกันเลย

Install Eslint at the workspace level

ที่ workspace level นะ install ด้วยคำสั่ง

Terminal window
pnpm add -w -D eslint jiti

ผมใส่ jiti ด้วยเพื่อที่จะได้ใช้ .ts กับ eslint config ได้

ที่ package.json ด้านนอกสุด จะมี devDependencies แบบนี้

package.json
{
"name": "blog",
"private": true,
"type": "module",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
},
"devDependencies": {
"eslint": "^9.20.1",
"jiti": "^2.4.2",
"prettier": "^3.5.0",
"turbo": "^2.4.4",
"typescript": "5.7.3"
},
"packageManager": "[email protected]",
"engines": {
"node": ">=18"
}
}

Init via Antfu’s eslint

เราจะสร้าง eslint config ไว้ใน packages แล้ว shared ไปที่ apps และ packages ต่างๆ เผื่อว่าในแต่ละ apps หรือ packages ต้องการ config ไม่เหมือนกัน

เรามี folder packages/eslint-config อยู่แล้ว

Terminal window
.
├── apps
├── eslint.config.mjs
├── mise.toml
├── package.json
├── packages
├── eslint-config
├── base.js
├── next.js
├── package.json
├── react-internal.js
└── README.md
├── typescript-config
└── ui
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── turbo.json

ให้ cd เข้ามาที่ folder packages/eslint-config

Terminal window
cd packages/eslint-config
pnpm dlx @antfu/eslint-config@latest

เลือกตามนี้เลย

Terminal window
@antfu/eslint-config v4.3.0
There are uncommitted changes in the current repository, are you sure to continue?
Yes
Select a framework:
React
Select a extra utils:
Formatter
Update .vscode/settings.json for better VS Code experience?
Yes
Bumping @antfu/eslint-config to v4.3.0
Added packages ────────────────────────────────────────────────────────────────────────────────────────────╮
eslint-plugin-format, @eslint-react/eslint-plugin, eslint-plugin-react-hooks, eslint-plugin-react-refresh
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Changes wrote to package.json
Created eslint.config.mjs
Updated .vscode/settings.json
Setup completed
Now you can update the dependencies by run pnpm install and run eslint . --fix

Install ESLint and it’s dependencies

Terminal window
pnpm add -D eslint-plugin-perfectionist eslint-plugin-oxlint jiti -w

ใน package.json จะมี dependencies แบบนี้

eslint-config/package.json
package.json
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@next/eslint-plugin-next": "^15.1.6",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-turbo": "^2.4.0",
"globals": "^15.15.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.0",
"@antfu/eslint-config": "^4.3.0",
"@eslint-react/eslint-plugin": "^1.26.2",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-oxlint": "^0.15.12",
"eslint-plugin-perfectionist": "^4.9.0",
"eslint-plugin-react-refresh": "^0.4.19",
"jiti": "^2.4.2"
}
}

แล้วก็ set type=module แบบนี้
จริงๆเขา set มาให้แล้ว แต่ก็เผื่อไว้ละกัน

Terminal window
pnpm pkg set type=module

create new eslit config

เราจะสร้าง eslint config แบบของเรา (ของผมคนเดียว เพื่อนๆก็ปรับเอาตามชอบนะครับ)

สร้าง file antfu.ts กับ eslint.config.ts

Terminal window
touch antfu.ts
touch eslint.config.ts

จะได้แบบนี้

Terminal window
.
├── eslint-config
├── antfu.ts
├── base.js
├── eslint.config.ts
├── next.js
├── package.json
├── react-internal.js
└── README.md

file antfu.ts จะเป็น file ที่เก็บ config ของเรา
ส่วน eslint.config.ts อันนี้จะเป็น file ที่เอาไว้บอก ESLint lsp ว่าเรา config อะไรบ้าง โดยจะมีผลแค่ใน package eslint-config นี้เท่านั้น

antfu.ts
antfu.ts
import antfu from "@antfu/eslint-config"
import oxlint from "eslint-plugin-oxlint"
import perfectionist from "eslint-plugin-perfectionist"
const antfuConfig = antfu(
{
formatters: true,
react: true,
stylistic: {
indent: 2,
quotes: "double",
semi: false,
},
typescript: true,
},
{
plugins: [
perfectionist.configs["recommended-alphabetical"],
oxlint.configs["flat/all"],
],
rules: {
"no-console": "warn",
"perfectionist/sort-objects": "error",
"react/no-nested-components": "off",
"ts/consistent-type-definitions": "off",
"unicorn/throw-new-error": "off",
"unused-imports/no-unused-imports": "error",
},
},
{
ignores: [
"**/*.json",
],
},
)
/** @type {import("eslint").Linter.Config} */
export default antfuConfig

ส่วน eslint.config.ts เราก็แค่ import มาจาก antfu.ts

eslint.config.ts
eslint.config.ts
import antfu from "./antfu"
export default antfu

export antfu.ts

เราจะต้อง export antfu.ts ที่เราสร้างไว้ เพื่อให้ apps หรือ packages อื่นๆเอาไปใช้ได้

ตรงนี้ไม่ใช่แค่ keyword export ในไฟล์นะครับ
เป็นการ export แบบที่ npm library จะต้องทำ

ยังอยู่ใน packages/eslint-config นะครับ มาที่ package.json

eslint-config/package.json
package.json
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js",
"./antfu": {
"default": "./antfu.ts",
"types": "./antfu.ts"
}
},
"devDependencies": {
...
}
}

use eslint config from antfu config in another package

setup ESLint ได้แล้ว เราลองเอาไปใช้ใน packages หรือ apps อื่นๆดู
ตอนนี้เรายังไม่ได้สร้าง apps หรือ packages เลย
แต่มี package ui ที่ติดมากับ Turborepo เราจะลองเอาไปใช้ในนี้ละ

แก้ไฟล์ eslint.config.mjs ให้เป็น eslint.config.ts

Terminal window
└── ui
├── eslint.config.ts
├── eslint.config.mjs
├── package.json
├── src
├── tsconfig.json
└── turbo

เราก็ต้อง install eslint-config ที่เราสร้างเองด้วย
โดยชื่อที่จะใช้ install จะไม่ใช่ชื่อ folder นะครับ
จะต้องเป็น name ที่อยู่ใน package.json ใน folder ของ eslint-config

ในที่นี้คือ @repo/eslint-config นั่นเอง

eslint-config/package.json
package.json
{
"name": "@repo/eslint-config",
3 collapsed lines
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js",
"./antfu": {
"default": "./antfu.ts",
"types": "./antfu.ts"
}
},
17 collapsed lines
"devDependencies": {
"@antfu/eslint-config": "^4.4.0",
"@eslint-react/eslint-plugin": "^1.30.0",
"@eslint/js": "^9.21.0",
"@next/eslint-plugin-next": "^15.2.1",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"eslint-plugin-turbo": "^2.4.4",
"globals": "^16.0.0",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.0"
}
}

มาที่ packages/ui ไฟล์ package.json ให้เพิ่ม internal dependencies แบบนี้

ui/package.json
package.json
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"exports": {
"./*": "./src/*.tsx"
},
"scripts": {
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@turbo/gen": "^2.4.0",
"@types/node": "^22.13.0",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"eslint": "^9.21.0",
"typescript": "5.7.3"
}
}

เราใส่ "@repo/eslint-config": "workspace:*" เข้าไปที่ devDependencies นะครับ
ด้านหลังที่เป็น workspace:* หมายถึงว่า package นี้อยู่ใน workspace นี้ ไม่ต้องไป download จาก npm registry

เสร็จแล้วก็สั่ง

Terminal window
pnpm i

จากนั้นมาแก้ file eslint.config.ts

ui/eslint.config.ts
eslint.config.ts
import config from "@repo/eslint-config/antfu"
/** @type {import("eslint").Linter.Config} */
export default config

เสร็จแล้วผมจะทดสอบที่ useEffect ดูว่ามี eslint เตือนหรือเปล่า

โดยสร้าง useState แล้วเอามาใช้ใน useEffect

ลองที่ component ไหนก็ได้ ผมจะลองที่ file button.tsx

Terminal window
.
├── eslint-config
├── typescript-config
└── ui
├── eslint.config.ts
├── package.json
├── src
├── button.tsx
├── card.tsx
└── code.tsx
├── tsconfig.json
└── turbo

จะได้แบบนี้

img01

ต่อไปเราจะเริ่มสร้าง Package ใช้เองกันแล้ว เริ่มที่ Prisma ก่อนเลย

Prisma Package - 1

create prisma package

เราจะมาสร้าง package ของเราเอง เริ่มด้วย Prisma เลย

เราสร้าง folder prisma ใน folder packages

Terminal window
mkdir packages/prisma

จากนั้นให้ cd เข้าไปที่ folder prisma เพื่อความสะดวกในการ install setup package ต่างๆ ในระหว่างที่เราสร้าง prisma package

Terminal window
cd packages/prisma

create package.json

แล้วสั่ง

Terminal window
pnpm init

จะได้ file package.json แบบนี้

prisma/package.json
package.json
{
"name": "prisma",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]"
}

เราจะแก้ name ให้เป็น @repo/prisma ใส่ "type": "module" เอา main ออกไปเลย

ด้วยคำสั่ง

Terminal window
pnpm pkg set type=module
pnpm pkg set name=@repo/prisma
pnpm pkg delete main

หรือถ้าใครจะทำด้วยการแก้ package.json ตรงๆก็ได้เช่นกัน

prisma/package.json
package.json
{
"name": "prisma",
"name": "@repo/prisma",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]"
}

setup eslint config for prisma

ใส่ eslint config package ของเรา แบบนี้

Terminal window
pnpm pkg set 'devDependencies.@repo/eslint-config=workspace:*'
prisma/package.json
package.json
{
"name": "@repo/prisma",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@repo/eslint-config": "workspace:*"
},
"packageManager": "[email protected]"
}
prisma/eslint.config.ts

สร้าง eslint config file

Terminal window
echo 'import antfu from "@repo/eslint-config/antfu"
export default antfu' > eslint.config.ts

จะได้แบบนี้
หรือเพื่อนๆจะสร้างไฟล์แล้วแล้วก๊อปวางก็ได้ แล้วแต่สะดวกเลยครับ

eslint.config.ts
import antfu from "@repo/eslint-config/antfu"
export default antfu

setup typescript config

ผมจะไม่ได้ใช้ package typescript-config ที่ turborepo เตรียมมาให้นะครับ
ผมจะใช้ lib ของคุณ Matt Pocock นะครับ เขาเตรียม config ที่ควรใช้มาให้เราแล้ว

Terminal window
pnpm add -D @total-typescript/tsconfig

แล้วก็สั่งคำสั่งนี้ เพื่อสร้าง file tsconfig.json แล้วใช้ config ของ total-typescript

Terminal window
echo '{
"extends": "@total-typescript/tsconfig/bundler/dom/app"
}
' > tsconfig.json

หรือใครจะสร้างไฟล์ tsconfig.json ขึ้นมาแล้วเอา config ไปใส่ ก็ได้ สุดท้ายเราจะได้ tsconfig.json แบบนี้

tsconfig.json
tsconfig.json
{
"extends": "@total-typescript/tsconfig/bundler/dom/app"
}

install and init prisma

Terminal window
pnpm add prisma @prisma/client
Terminal window
pnpm prisma init --datasource-provider postgresql

เราก็จะได้ folder prisma เพิ่มมาแบบนี้

Terminal window
.
├── eslint.config.ts
├── package.json
└── prisma
└── schema.prisma

ที่ไฟล์ schema.prisma
ผมเพิ่ม model user ไปแบบนี้

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
}

แล้วก็สั่ง

Terminal window
pnpm prisma generate

จะได้ประมาณนี้

Terminal window
pnpm prisma generate
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Installed the @prisma/client and prisma packages in your project
Generated Prisma Client (v6.4.1) to ./../../node_modules/.pnpm/@[email protected][email protected][email protected][email protected]/node_modules/@prisma/client in 40ms
Start by importing your Prisma Client (See: https://pris.ly/d/importing-client)
Tip: Curious about the SQL queries Prisma ORM generates? Optimize helps you enhance your visibility: https://pris.ly/tip-2-optimize

export prisma client

ถัดมาเราจะสร้าง prisma client แล้ว export ไปให้คนอื่นเรียกใช้งาน ผ่าน package prisma ของเราอันนี้

สร้าง file src/index.ts

Terminal window
mkdir src
touch src/index.ts

ในไฟล์มีแค่นี้เลย

src/index.ts
src/index.ts
import { PrismaClient } from "@prisma/client"
export const prismaClient = new PrismaClient()

แต่ๆ ถ้าใครจำได้ export แค่ตรงนี้ยังไม่พอที่จะให้ apps หรือ packages อื่นๆใน Turborepo เอาไปใช้งานได้
เราจะต้อง exports ที่ package.json ด้วย แต่ก่อนจะ exports ได้ต้อง compile มันซะก่อน

compile package using Rslib

เราใช้ typescript เขียน package prisma อันนี้รู้กันอยู่แล้ว
ก่อนที่เราจะให้ apps หรือ packages อื่นๆเรียกใช้ได้ เราต้อง compile ซะก่อน แล้วค่อย export สิ่งที่ได้จากการ compile ออกไป

เราจะต้อง compile prisma package ซะก่อน

เราจะใช้ Rslib ในการ compile นะครับ

การ compile เรามีเงื่อนไขหลายอย่าง Rslib ก็รองรับเงื่อนไขต่างๆหลายรูปแบบเลย การที่จะใช้คำสั่งเดียวยาวๆเพื่อใส่เงื่อนไขของเราทั้งหมดมันก็จะยาวไป
เราจะใช้ config file แทน เพื่อให้ง่ายต่อการ setup เงื่อนไขของเรา รวมถึงลดความผิดพลาดในการพิมพ์คำสั่งด้วยมือแล้วพิมพ์ผิด ตกหล่น

ฉนั้นสำหรับ Prisma package เราจะหยุดไว้ก่อน เราจะไปสร้าง rslib-config package กันก่อน เสร็จแล้วจะกลับมาทำ prisma package กันต่อ


rslib-config package

ใน terminal ใครยังอยู่ที่ package prisma ให้ cd กลับมาที่ workspace ก่อนนะครับ

Terminal window
cd ../..

สร้าง folder rslib-config

Terminal window
mkdir packages/rslib-config

จะได้แบบนี้

Terminal window
packages
├── eslint-config
├── prisma
├── typescript-config
├── ui
└── rslib-config

แล้ว cd เข้าไป

Terminal window
cd packages/rslib-config

create package.json

สร้าง package.json รอบนี้ผมใช้การ echo แทน ใครจะใช้ pnpm init ก็ได้ครับ

Terminal window
pnpm init
pnpm pkg set 'name=@repo/rslib-config'
pnpm pkg set 'type=module'
pnpm pkg delete main
pnpm pkg set 'devDependencies.@repo/eslint-config=workspace:*'
pnpm pkg set 'version=0.0.0'
pnpm pkg set 'description=rslib config package'
Terminal window
echo '{
"name": "@repo/rslib-config",
"version": "0.0.0",
"description": "rslib config package",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@repo/eslint-config": "workspace:*"
}
}
' > package.json

จากนั้นก็สั่ง

Terminal window
pnpm add @rslib/core -D

จะได้แบบนี้

package.json
{
"name": "@repo/rslib-config",
7 collapsed lines
"version": "0.0.0",
"description": "rslib config package",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@rslib/core": "^0.5.3"
}
}

create rslib config

สร้าง rslib config กัน ผมจะมี config แยกกันระหว่าง packages กับ apps นะครับ
เนื่องจากว่าส่วนของ app อยากให้เล็กที่สุดและส่วนของ lib จะต้องสร้าง typescript declaration file ด้วย

build config for packages

มาสร้าง build config สำหรับ packages กันก่อน

สร้าง file lib.config.ts

lib.config.ts
import { defineConfig } from "@rslib/core"
export default defineConfig({
lib: [
{
bundle: true,
dts: true,
format: "esm",
output: {
distPath: {
root: "./dist/esm",
},
minify: true,
sourceMap: true,
target: "web",
},
},
{
bundle: true,
dts: true,
format: "cjs",
output: {
distPath: {
root: "./dist/cjs",
},
minify: true,
sourceMap: true,
target: "web",
},
},
],
})

มาดูว่าเรา config อะไรไปบ้าง

  • bundle: true อันนี้เพื่อนๆเลือกเอาได้เลยว่าจะให้ bundle ทุกๆไฟล์ให้มาอยู่ในไฟล์เดียวกันไหม
  • dts: true คือให้สร้าง declaration typescript file ให้ด้วย คือเราจะได้ไฟล์ .d.ts มาด้วย เพื่อให้ app ที่เอา package ไปใช้รู้ได้ว่า type คืออะไร
  • format: "esm" หรือ "cjs" คือให้สร้างไฟล์ที่ได้มาใน format อะไร ถ้าเป็น esm ก็เป็น js สมัยใหม่จะใช้ import, export ส่วน cjs ก็จะเป็น js แบบเก่าหน่อย ผมไม่ได้ใช้แบบนี้เท่าไรเลย ไม่ได้ใช้มานานแล้วด้วย
  • output
    • distPath คือจะให้เอาไฟล์ที่ compile มาแล้วไปไว้ที่ไหน
    • target: "web" | "node" ก็ขึ้นอยู่ว่าเราจะเอาไฟล์ที่ได้ไปใช้ที่ไหน
    • sourcemap เอา sourcemap ด้วย
    • minify คือต้องการให้ทำให้ไฟล์ที่ได้มีขนาดเล็กไหม ถ้าต้องการตัวแปรต่างๆขอเราจะถูกเปลี่ยนชื่อให้มันสั้นๆโดยที่การทำงายยังเหมือนเดิม แค่ชื่อตัวแปรมันไม่สื่อว่าคืออะไร

build config for apps

สร้าง file app.config.ts`

app.config.ts
app.config.ts
import { defineConfig } from "@rslib/core"
export default defineConfig({
lib: [
{
bundle: true,
dts: false,
format: "esm",
output: {
distPath: {
root: "./dist/esm",
},
minify: true,
sourceMap: true,
target: "web",
},
},
],
})

ในส่วนของ app ผมต้องการแค่ esm และไม่ได้ต้องการ type declaration .d.ts แล้ว

export both configs

เรามา export ทั้งสอง configs ออกไปให้ packages หรือ apps ใช้กัน
เราจะต้อง exports ที่ package.json

Terminal window
pnpm pkg set 'exports[./lib]=./lib.config.ts'
pnpm pkg set 'exports[./app]=./app.config.ts'

จะได้ แบบนี้

rslib-config/package.json
package.json
{
"name": "@repo/rslib-config",
"version": "0.0.0",
"description": "rslib config package",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@rslib/core": "^0.5.3"
},
"exports": {
"./lib": "./lib.config.ts",
"./app": "./app.config.ts"
}
}

Prisma Package - 2

กลับมาที่ Prisma package กัน รอบนี้เราจะใช้ build config ที่ได้สร้างไว้ใน rslib-config เมื่อกี้นี้

cd into prisma package

Terminal window
cd packages/prisma

install @rslib/core package

ติดตั้ง @rslib/core package กันก่อน

Terminal window
pnpm add -D @rslib/core

add scripts, internal packages and exports in package.json

ต่อมาก็แก้ package.json เพิ่ม @repo/rslib-config เข้าไปแบบนี้

Terminal window
pnpm pkg set 'devDependencies.@repo/rslib-config=workspace:*'
pnpm pkg set 'scripts.build=rslib build'
pnpm pkg set 'scripts.dev=rslib build --watch'
pnpm pkg set 'scripts.check=tsc --noEmit'
pnpm pkg set 'exports[.].import.types=./dist/esm/src/index.d.ts'
pnpm pkg set 'exports[.].import.default=./dist/esm/index.js'
pnpm pkg set 'exports[.].require.types=./dist/cjs/src/index.d.ts'
pnpm pkg set 'exports[.].require.default=./dist/cjs/index.cjs'
  • เพิ่ม devDependencies ที่ชื่อว่า @repo/rslib-config: workspace:*
  • เพิ่ม scripts 3 ตัว
    1. build ก็สั่ง rslib build เฉยๆ เดี๋ยว rslib มันจะไปอ่าน file rslib.config.ts แล้ว build ให้เราเอง
    2. dev สั่ง rslib build --watch ก็สั่งให้ watch ด้วยเมื่อไฟล์มีการเปลี่ยนแปลง rslib ก็จะ build ให้เราใหม่
  • เพิ่ม exports โดยตรงนี้ก็เหมือนกับที่เราทำใน eslint-config หรือ unbuild-config แต่จะละเอียดกว่า
    • import ก็คือ javascript แบบ ESM
    • require ก็คือ javascript แบบ Commonjs
prisma/package.json
package.json
{
"name": "@repo/prisma",
2 collapsed lines
"version": "0.0.0",
"description": "",
"scripts": {
"build": "rslib build",
"dev": "rslib build --watch",
"check": "tsc --noEmit"
4 collapsed lines
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/rslib-config": "workspace:*",
"@rslib/core": "^0.5.3",
"@total-typescript/tsconfig": "^1.0.4"
4 collapsed lines
"typescript": "^5.8.2"
},
"dependencies": {
"@prisma/client": "^6.4.1",
"prisma": "^6.4.1"
},
"exports": {
".": {
"import": {
"types": "./dist/esm/src/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/src/index.d.ts",
"default": "./dist/cjs/index.cjs"
}
}
}
}

add more taks to turbo.json

พอเรามี app ตอนที่เรานั่งโค้ดเราก็จะสั่ง run dev เป็นปกติกันอยู่แล้วใช่มะ
แต่ว่าตอนนี้เราใช้ Monorepo แยกการทำงานบางอย่างมาไว้ใน packages แล้วแชร์กันในหลายๆ apps ใน packages เราก็ต้องสั่ง run dev เช่นกัน
สุดท้ายตอนที่เรานั่งโค้ดเราก็แค่สั่ง pnpm turbo run dev ก็พอแล้ว

ดังนั้นผมจะเพิ่มคำสั่งนี้ ในไฟล์ turbo.json ด้วย

turbo.json
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
23 collapsed lines
"build": {
"dependsOn": [
"^build"
],
"inputs": [
"$TURBO_DEFAULT$",
".env*"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"lint": {
"dependsOn": [
"^lint"
]
},
"check-types": {
"dependsOn": [
"^check-types"
]
},
"dev": {
"cache": false,
"persistent": true
}
}
}

จากนั้นออกมาที่ package.json ของ workspace

Terminal window
cd ../..

เราจะเพิ่ม script dev ใน package.json

Terminal window
pnpm pkg set 'scripts.dev=turbo run dev'

จะได้ แบบนี้

package.json
{
"name": "blog",
"private": true,
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types",
"dev": "turbo run dev"
},
"devDependencies": {
"eslint": "^9.21.0",
"eslint-plugin-oxlint": "^0.15.13",
"eslint-plugin-perfectionist": "^4.9.0",
"jiti": "^2.4.2",
"prettier": "^3.5.3",
"turbo": "^2.4.4",
"typescript": "5.8.2"
},
"packageManager": "[email protected]",
"engines": {
"node": ">=18"
}
}

create rslib.config.ts

สร้างไฟล์ rslib.config.ts พร้อมกับ import, export config ใน command เดียว ใครสะดวกเขียนเองก็ได้นะครับ

Terminal window
echo 'import config from "@repo/rslib-config/lib"
export default config
' > rslib.config.ts

จะได้ไฟล์ rslib.config.ts แบบนี้

rslib.config.ts
import config from "@repo/rslib-config/lib"
export default config

build prisma package

ลองสั่ง build ดู

Terminal window
pnpm build

น่าจะต้องได้อะไรประมาณนี้

Terminal window
pnpm build
> @repo/[email protected] build /Users/atiwatseenark/Documents/learn/turborepo/blog/packages/prisma
File (cjs) Size Gzip
dist/cjs/index.cjs 1.2 kB
-----
File (esm) Size Gzip
dist/esm/index.js 0.12 kB 0.12 kB

เราจะได้ folder dist เพิ่มมา มีไฟล์แบบนี้

Terminal window
dist
├── cjs
├── eslint.config.d.ts
├── index.cjs
├── index.cjs.map
├── rslib.config.d.ts
└── src
└── esm
├── eslint.config.d.ts
├── index.js
├── index.js.map
├── rslib.config.d.ts
└── src

ของด้านใน folder dist นี้แหละ ที่เราใส่ใน exports ที่ package.json

ตอนนี้ Prisma package ของเราพร้อมใช้งานแล้ว เราไปสร้าง Backend แล้วลองเรียใช้ Prisma package กัน


Create Hono app

เข้าไปที่ folder apps

จาก workspace root ก็ให้สั่ง

Terminal window
cd apps

จากนั้นก็สร้าง hono app ด้วยคำสั่งที่ผมไป copy มาจากหน้าเวป hono

Terminal window
pnpm create hono@latest

แล้วก็เลือกตามนี้

Terminal window
pnpm create hono@latest
create-hono version 0.15.3
? Target directory ./api
? Which template do you want to use? nodejs
? Do you want to install project dependencies? yes
? Which package manager do you want to use? pnpm
Cloning the template
Installing project dependencies
🎉 Copied project files
Get started with: cd ./api

เราจะได้ files มาแบบนี้

Terminal window
apps
└── api
├── package.json
├── README.md
├── src
└── tsconfig.json

add rslib package

จากนั้นก็เพิ่ม package rslib เพื่อเอาไว้ build app แบบนี้

Terminal window
pnpm add -D @rslib/core @total-typescript/tsconfig

update tsconfig.json

ใช้คำสั่งนี้หรือ copy code ด้านล่างก็ได้

Terminal window
echo '{
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"compilerOptions": {
"verbatimModuleSyntax": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
}
}
' > tsconfig.json

จะได้ tsconfig แบบนี้

tsconfig.json
tsconfig.json
{
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"compilerOptions": {
"verbatimModuleSyntax": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
}
}

update package.json

Terminal window
pnpm pkg set type=module
pnpm pkg set 'devDependencies.@repo/eslint-config=workspace:*'
pnpm pkg set 'devDependencies.@repo/rslib-config=workspace:*'
pnpm pkg set 'dependencies.@repo/prisma=workspace:*'
pnpm pkg set 'scripts.build=rslib build'
pnpm pkg set 'scripts.dev=tsx watch --include \'../../packages/*/dist/**/*.js\' src/index.ts'
pnpm pkg set 'scripts.start=node ./dist/esm/index.js'

ที่ script dev ผมจะใส่ option --include ไปที่ tsx ด้วย เพื่อให้ tsx ทำการ reload เมื่อมีอะไรใน packages เปลี่ยนแปลง
ในที่นี้ผมใส่แบบเหมารวม ข้อเสียคือถ้า packages ไหนไม่เกี่ยวกับ api app ของเรา เมื่อ packages นั้นมีการแก้ไข code ด้านใน จะทำให้ apps ของเรา reload ไปด้วย
ถ้าจะให้มีประสิทธิภาพ ควรจะใส่ --include (มันใส่ได้หลายอัน) เฉพาะ packages ที่เกี่ยวข้อง แต่ script ก็จะยาวหน่อยนะ
เช่น ตัวอย่างนี้

Terminal window
pnpm pkg set 'scripts.dev=tsx watch --include \'../../packages/prisma/dist/**/*.ts\' --include \'../../packages/kafka/dist/**/*.ts\' src/index.ts'

เราจะได้ package.json มาแบบนี้

api/package.json
package.json
{
"name": "api",
"type": "module",
"main": "./dist/esm/index.js",
"scripts": {
"dev": "tsx watch --include '../../packages/*/dist/**/*.js' src/index.ts",
"build": "rslib build",
"start": "node ./dist/esm/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"@repo/prisma": "workspace:*",
"hono": "^4.7.4"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/rslib-config": "workspace:*",
"@rslib/core": "^0.5.3",
"@total-typescript/tsconfig": "^1.0.4",
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
}
}

จะเห็นว่า name มี value เป็น api นั่นหมายความว่า Hono backend ของเราอันนี้มีชื่อว่า api นะครับ (ชื่อ folder ไม่ใช่ชื่อของ app เรานะ)

เพิ่ม packages แล้วก็ต้องสั่ง

Terminal window
pnpm i

create build config

เราจะต้องสร้าง rslib.config.ts เพื่อเอาไว้ build api ของเราด้วย

Terminal window
echo '
import config from "@repo/rslib-config/app"
export default config
' > rslib.config.ts

จะได้ไฟล์ rslib.config.ts แบบนี้

rslib.config.ts
rslib.config.ts
import config from "@repo/rslib-config/app"
export default config

use our prisma package

src/index.ts
src/index.ts
import { serve } from "@hono/node-server"
import { prismaClient } from "@repo/prisma"
import { Hono } from "hono"
const app = new Hono()
app.get("/", (c) => {
return c.text("Hello Hono!")
})
app.get("/users", async (c) => {
const users = await prismaClient.user.findMany()
return c.json(users)
})
serve({
fetch: app.fetch,
port: 3000,
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})

เราจะเห็นว่ามี autocomplete กับ type บอกให้เราครบถ้วน

img2 img3

มาทดสอบกันหน่อย

Terminal window
pnpm dev

ลองยิง requst

Terminal window
curl http://localhost:3000/users

จะได้ Error ว่าหา Database ไม่เจอ แบบนี้หมายความว่า Prisma ทำงานแล้ว เพราะมันถามหา database แล้ว

Terminal window
Can't reach database server at `localhost:5432`
Please make sure your database server is running at `localhost:5432`.

ใน blog นี้ผมจะไม่ได้ setup data นะครับ เพราะมันจะนอกเรื่อง Turborepo ไปละ

Move Hono app to package

อย่างที่บอกไปว่าผมจะไม่ใช้ Hono เป็น app แต่จะทำเป็น pacakge แทน
วิธีการก็ไม่ยากเลย ย้าย code ทั้งหมดมา แค่ไม่ต้องสั่ง serve()

มาค่อยๆทำไปพร้อมๆกัน

ใน Terminal อยู่ที่ workspace root นะครับ ผม copy folder apps/api มาไว้ที่ pacakges/api ด้วยคำสั่ง

Terminal window
cp -R apps/api packages/api

จะได้

packages/api
├── dist
│ └── esm
├── package.json
├── README.md
├── rslib.config.ts
├── src
│ └── index.ts
└── tsconfig.json

เราจะมาแก้ไข package.json กันก่อน

Terminal window
pnpm pkg set name=@repo/api
pnpm pkg delete 'devDependencies.tsx'
pnpm pkg delete 'main'
pnpm pkg delete 'dependencies.@hono/node-server'
pnpm pkg set 'scripts.dev=rslib build --watch'
pnpm pkg set 'exports[.].import.types=./dist/esm/src/index.d.ts'
pnpm pkg set 'exports[.].import.default=./dist/esm/index.js'
pnpm pkg set 'exports[.].require.types=./dist/cjs/src/index.d.ts'
pnpm pkg set 'exports[.].require.default=./dist/cjs/index.cjs'

ทำอะไรไปบ้าง

  • ผมแก้ name ให้เป็น @repo/api
  • ลบ tsx ออกจาก devDependencies
  • ลบ main
  • ลบ @hono/node-server ออกจาก dependencies
  • เพิ่ม exports เหมือนกับที่ทำที่ @repo/prisma เลย

จะได้แบบนี้

packages/api/package.json
{
"name": "@repo/api",
"type": "module",
"scripts": {
"dev": "rslib build --watch",
"build": "rslib build",
"start": "node ./dist/esm/index.js"
},
"dependencies": {
"@repo/prisma": "workspace:*",
"hono": "^4.7.4"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/rslib-config": "workspace:*",
"@rslib/core": "^0.5.3",
"@total-typescript/tsconfig": "^1.0.4",
"@types/node": "^20.11.17"
},
"exports": {
".": {
"import": {
"types": "./dist/esm/src/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/src/index.d.ts",
"default": "./dist/cjs/index.cjs"
}
}
}
}

update rslib.config.ts

เราจะต้องแก้ build config ด้วย เพราะเราเปลี่ยนเป็น package แล้ว

rslib.config.ts
rslib.config.ts
import config from "@repo/rslib-config/lib"
export default config

update index.ts

เราจะ export const routes ออกไป ไม่ใช่ app นะ แล้วใช้ method chaining (คือใช้ .get().get().post() ต่อกันไปเรื่อยๆ ไม่ได้ใช้ app.get(), app.get() แล้ว) เพื่อให้ Hono Client มองเห็น type ทั้งหมดได้

ผมเพิ่ม route อีกเส้นนึงด้วย ส่ง json ตรงๆไม่ได้เรียกใช้ prisma เพราะไม่ได้มี database จริงๆ

สร้าง type App แล้ว export ออกไปเช่นกัน

ส่วน serve เราจะไม่ใช้แล้ว เดี๋ยวให้ api ใน apps เป็นคนเรียกใช้

src/index.ts
src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { prismaClient } from "@repo/prisma"
const app = new Hono()
export const routes = app
.get('/', (c) => {
return c.text('Hello Hono!')
})
.get("/users", async (c) => {
const users = await prismaClient.user.findMany()
console.log({ users })
return c.json(users)
})
.get("/cameras", (c) => {
return c.json([
{
brand: "Canon",
model: "R1",
},
{
brand: "Sony",
model: "A9",
},
{
brand: "Nikon",
model: "Z9",
},
])
})
export type App = typeof routes
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})

ลอง build ดูซะหน่อย

Terminal window
pnpm build
> @repo/api@ build /Users/atiwatseenark/Documents/learn/turborepo/blog/packages/api
> rslib build
Rslib v0.5.3
ready built in 0.10 s (esm)
ready built in 0.10 s (cjs)
start Generating DTS... (esm)
start Generating DTS... (cjs)
ready DTS generated in 0.93 s (esm)
ready DTS generated in 0.92 s (cjs)
File (cjs) Size Gzip
dist/cjs/index.cjs 1.5 kB
-----
File (esm) Size Gzip
dist/esm/index.js 0.38 kB 0.27 kB

ก็ผ่านนะ

ต่อไป เราจะกลับมาที่ apps/api กัน

use Hono package in Api App

delete existing api app

ผมจะลบ folder api ออกจาก apps เลย แล้วจะเริ่มสร้างขึ้นมาใหม่เลย

ที่ workspace root ให้สั่ง

Terminal window
rm -rf apps/api

folder apps ก็จะว่างเปล่าละ

ให้สั่ง

create new api app

Terminal window
mkdir apps/api
cd apps/api

สร้าง api app ของเราด้วย scripts แบบนี้
หรือถ้าใครอยาก copy เองก็ด้านล่างเลย

Terminal window
pnpm init
pnpm pkg set name=api
pnpm pkg set type=module
pnpm pkg set 'main=dist/esm/index.js'
pnpm pkg set 'dependencies.@repo/api=workspace:*'
pnpm pkg set 'devDependencies.@repo/eslint-config=workspace:*'
pnpm pkg set 'devDependencies.@repo/prisma=workspace:*'
pnpm pkg set 'devDependencies.@repo/rslib-config=workspace:*'
pnpm pkg set 'scripts.dev=tsx watch --include \'../../packages/*/dist/**/*.js\' src/index.ts'
pnpm pkg set 'scripts.build=rslib build'
pnpm pkg set 'scripts.start=node ./dist/esm/index.js'
pnpm pkg set 'scripts.check=tsc --noEmit'
pnpm pkg delete 'scripts.test'
pnpm add @hono/node-server
pnpm add -D typescript tsx @types/node @rslib/core @total-typescript/tsconfig
package.json
package.json
{
"name": "api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "tsx watch --include '../../packages/*/dist/**/*.js' src/index.ts",
"build": "rslib build",
"start": "node ./dist/esm/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hono/node-server": "^1.13.8",
"@repo/api": "workspace:*"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/prisma": "workspace:*",
"@repo/rslib-config": "workspace:*",
"@rslib/core": "^0.5.3",
"@types/node": "^22.13.9",
"tsx": "^4.19.3"
}
}

add tsconfig file

เพิ่ม tsconfig.json แบบนี้

Terminal window
echo '{
"extends": "@total-typescript/tsconfig/bundler/dom/app"
}
' > tsconfig.json

จะได้แบบนี้

tsconfig.json
tsconfig.json
{
"extends": "@total-typescript/tsconfig/bundler/dom/app"
}

add eslint config file

สร้าง eslint config file

Terminal window
echo '
import config from "@repo/eslint-config/antfu"
export default config
' > eslint.config.ts

จะได้แบบนี้

eslint.config.ts
import config from "@repo/eslint-config/antfu"
export default config

add rslib config file

สร้าง rslib.config.ts

rslib.config.ts
import config from "@repo/rslib-config/app"
export default config

สร้าง folder src กับไฟล์ index.ts

Terminal window
mkdir src
touch src/index.ts

จากนั้นก็มาแก้ไฟล์ src/index.ts

เราจะใช้ app ที่ได้จาก packages/api แทนทั้งหมดเลย
ที่ไฟล์นี้จะเหลือแค่เรียกใช้ serve จาก @hono/node-server เท่านั้น

src/index.ts
import { serve } from "@hono/node-server"
import { routes } from "@repo/api"
serve({
fetch: routes.fetch,
port: 3000,
})
console.log("server started", 3000)

จะเห็นว่าที่ apps/api ตรงนี้ เราเรียกใช้ serve จาก node-server ฉนั้นถ้าเราอยากเปลี่ยน serve adapter เป็น Deno หรือ Bun ก็ทำได้เลย
โดยที่เราก็ไม่ต้องไปแก้อะไรที่ packages/api เพราะไม่ว่าจะ adapter อะไร Hono ก็ยังเป็น Hono เหมือนเดิม

ลองยิง curl ดู ได้แบบนี้

Terminal window
curl http://localhost:3000/
Hello Hono!⏎

ถือว่าทำงานได้ปกติ

ต่อไป เราจะไปสร้าง Frontend กันก่อน แล้วค่อยให้ Frontend ใช้ Hono client เพื่อทำ E2E Type-safe ภายหลัง

Frontend Tanstack start

จาก workspace root ให้ cd ไปที่ folder app

Terminal window
cd apps

create Tanstack start

Terminal window
npx degit https://github.com/tanstack/router/examples/react/start-basic web

เราจะได้ folder web เพิ่มมา

apps
├── api
└── web
├── app
├── app.config.ts
├── package.json
├── postcss.config.mjs
├── public
├── README.md
├── tailwind.config.mjs
└── tsconfig.json

cd เข้ามาที่ web

Terminal window
cd web

แก้ name ใน package.json

Terminal window
pnpm pkg set name=web

แบบนี้

package.json
{
"name": "web",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start"
},
"dependencies": {
"@tanstack/react-router": "^1.112.0",
"@tanstack/react-start": "^1.112.3",
"@tanstack/router-devtools": "^1.112.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"redaxios": "^0.5.1",
"tailwind-merge": "^2.6.0",
"vinxi": "0.5.3"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4"
}
}

แล้วก็ install package ได้เลย

Terminal window
pnpm i

เสร็จแล้วลองสั่ง

Terminal window
pnpm run dev

จะมีหน้าเวปง่ายๆแบบนี้

img04

use Hono client to fetch data

เราจะดึง data จาก Hono กันหน่อย

ผมจะย้อนกลับไปที่ api package ก่อน
ผมจะไปเพิ่ม Hono client ตรงนั้น แล้ว export มาให้ frontend เรียกใช้

packages/api/src/index.ts
index.ts
import { Hono } from 'hono'
import { prismaClient } from "@repo/prisma"
import { hc } from "hono/client"
29 collapsed lines
const app = new Hono()
export const routes = app
.get('/', (c) => {
return c.text('Hello Hono!')
})
.get("/users", async (c) => {
const users = await prismaClient.user.findMany()
console.log({ users })
return c.json(users)
})
.get("/cameras", (c) => {
return c.json([
{
brand: "Canon",
model: "R1",
},
{
brand: "Sony",
model: "A9",
},
{
brand: "Nikon",
model: "Z9",
},
])
})
export type App = typeof routes
export const honoClient = (apiUrl: string) => hc<App>(apiUrl)

แล้วก็สร้าง function ที่เอาไว้สร้าง Hono client อีกที เพื่อให้ Frontend แก้ได้ในภายหลังว่า url ของ api จะเป็นอะไร

ทีนี้ก็กลับมาที่ Frontend ของเรา
เราจะติดตั้ง @repo/api package นะครับ

Terminal window
pnpm pkg set 'dependencies.@repo/api=workspace:*'
pnpm pkg set 'dependencies.@repo/eslint-config=workspace:*'
pnpm pkg set 'scripts.dev=vinxi dev --port 3001'
pnpm pkg set 'scripts.lint=eslint --fix .'

โดย default Tanstack start จะใช้ port 3000 ผมเปลี่ยน port ด้วยเนื่องจากว่า hono api ใช้ port 3000 ไปแล้ว

cross check อีกที ใน package.json

package.json
{
"name": "web",
3 collapsed lines
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vinxi dev --port 3001",
"build": "vinxi build",
"start": "vinxi start",
"lint": "eslint --fix ."
},
"dependencies": {
"@repo/api": "workspace:*",
"@repo/eslint-config": "workspace:*",
"@tanstack/react-router": "^1.112.18",
"@tanstack/react-start": "^1.112.19",
"@tanstack/router-devtools": "^1.112.18",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"redaxios": "^0.5.1",
"tailwind-merge": "^2.6.0",
"vinxi": "0.5.3"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4"
}
}

เพิ่ม package แล้วก็อย่าลืมสั่ง

Terminal window
pnpm i

fetch data from Hono app

ถัดมาเราจะมาใช้ hono client ในการดึง data นะครับ

ผมจะเรียกที่ Home page เลย

ไฟล์นี้

Terminal window
app
9 collapsed lines
├── api.ts
├── client.tsx
├── components
├── DefaultCatchBoundary.tsx
├── NotFound.tsx
├── PostError.tsx
└── UserError.tsx
├── global-middleware.ts
├── router.tsx
├── routes
5 collapsed lines
├── __root.tsx
├── _pathlessLayout
├── _pathlessLayout.tsx
├── api
├── deferred.tsx
├── index.tsx
17 collapsed lines
├── posts.$postId.tsx
├── posts.index.tsx
├── posts.route.tsx
├── posts_.$postId.deep.tsx
├── redirect.tsx
├── users.$userId.tsx
├── users.index.tsx
└── users.route.tsx
├── routeTree.gen.ts
├── ssr.tsx
├── styles
└── app.css
└── utils
├── loggingMiddleware.tsx
├── posts.tsx
├── seo.ts
└── users.tsx

ใน Tanstack start จะใช้ loader ในการดึง data และใช้ useLoaderData() เพื่อเอา data มาใช้งานใน page
สุดท้ายผมก็แปลงเป็น string แล้ว render มันทั้งอย่างนั้นเลย

index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { honoClient } from "@repo/api"
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const hc = honoClient("http://localhost:3000")
const cameras = await hc.cameras.$get()
return cameras.json()
}
})
function Home() {
const state = Route.useLoaderData()
return (
<div className="p-2">
<h3>Welcome Home!!!</h3>
<h3>Cameras</h3>
<div>
{JSON.stringify(state)}
</div>
</div>
)
}

จะเห็นว่ามี type บอกครบเลย

img06

เราใช้ Tanstack start ซึ่งทำ SSR ได้
ตัว loader ก็จะทำงานที่ฝั่ง server นะครับ

พอดูหน้าเวปที่ homepage ก็จะได้

img06

Shadcn package

เราจะมาทำ package สำหรับ Shadcn ui components กัน
อันนี้จะยากที่สุดละ ใน Tutorial นี้
มันจะมี setup เยอะมากๆ บวกกับ Tanstack start ที่ยังเป็น Beta ด้วย
บางอย่างก็ต้อง install ทั้งใน package และ ใน web framework สองที่เลย
ค่อยๆทำตามนะครับ

folders and files

ให้ดูภาพรวมก่อน เดี๋ยวเราจะมี folders กับ files แบบนี้

Terminal window
packages/shadcn
├── src/
├── index.css
├── components/
├── hooks/
└── lib/
└── utils.ts
├── components.json
├── eslint.config.ts
├── package.json
├── postcss.config.mjs
└── tsconfig.json

remove tailwindcss version3

ผมอยากให้เข้าใจก่อนว่า ตอนนี้ Tanstack start มันยังเป็น Beta อยู่ เราแค่จะลองใช้งาน ตอนที่เราสร้าง project Tanstack start แบบที่เราทำไปนั้นมันแค่การ clone template เค้ามาเท่านั้น
และมันมี tailwindcss ติดมาด้วยอยู่แล้ว แต่มันเป็น version 3
ตอนนี้ tailwindcss มี version 4 ออกมาแล้ว ผมก็เลยจะลบ tailwindcss v3 ที่อยู่ใน Tanstack start ทิ้งไปก่อน แล้วค่อยกลับมาติดตั้งอีกครั้งภายหลัง

Terminal window
cd apps/web
pnpm rm tailwindcss

ที่ workspace root

Terminal window
mkdir packages/shadcn
cd packages/shadcn

create package.json

Terminal window
pnpm init
pnpm pkg set name=@repo/shadcn
pnpm pkg set type=module
pnpm pkg set 'version=0.0.0'
pnpm pkg delete main
pnpm pkg delete scripts.test
pnpm pkg set 'devDependencies.@repo/eslint-config=workspace:*'
pnpm add react react-dom
pnpm add -D typescript @total-typescript/tsconfig @types/node @types/react @types/react-dom

create tsconfig file

สร้างไฟล์ tsconfig.json แล้วใส่ config แบบนี้

tsconfig.json
tsconfig.json
{
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@repo/shadcn/*": [
"./src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules",
"dist"
]
}

จาก code ด้านบน ในส่วนของ paths ผมใส่ @repo/shadcn/* ซึ่งก็ต้องให้ตรงกับ name ใน package.json นี้นี่แหละ
จะต่างกับใน docs ของ shadcn ที่แนะนำให้ใส่ @/* เพราะว่าแบบ @/* ใช้กับ frontend ที่ไม่ใช่ Monorepo

create eslint config file

สร้างไฟล์ eslint.config.ts

จะใช้คำสั่งหรือ copy code ก็ได้นะ

Terminal window
echo '
import config from "@repo/eslint-config/antfu"
export default config
' > eslint.config.ts

จะได้แบบนี้

eslint.config.ts
eslint.config.ts
import config from "@repo/eslint-config/antfu"
export default config

install tailwindcss v4

ขั้นตอนที่ผมพาทำผมก็ทำตาม docs นี้ครับ
โดยเราจะใช้ postcss นะครับ

install packages

Terminal window
pnpm add tailwindcss @tailwindcss/postcss postcss

create postcss config file

สร้างไฟล์ postcss.config.mjs

สั่ง command หรือ copy code ด้านล่างก็ได้

Terminal window
echo '
export default {
plugins: {
"@tailwindcss/postcss": {},
}
}
' > postcss.config.mjs
postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
}
}

create css file

สร้างไฟล์ src/index.css ด้วยคำสั่ง

Terminal window
mkdir src
echo '
@import "tailwindcss";
' > src/index.css

จะได้ไฟล์ scr/index.css แบบนี้

src/index.css
@import "tailwindcss";

install Shadcn

ก็จะทำตาม shadcn manual installation docs

install packages

Terminal window
pnpm add tailwindcss-animate class-variance-authority clsx tailwind-merge lucide-react

add paths to tsconfig.json

อันนี้เราได้ทำกันไปแล้ว

add shadcn css to index.css

ไปที่ไฟล์ src/index.css ที่เราได้สร้างไว้ ใส่ css ของ Shadcn เข้าไปแบบนี้

css ของ Shadcn เราสามารถแก้ได้นะ เอาให้เป็นแบบที่เราชอบได้เลย แก้ได้ทุกจุด

แต่ว่าจะมีส่วนที่แตกต่างจาก docs ของ Shadcn อยู่นิดนึง คือตรงที่ไฮไลท์สีเขียว @source
คือเราต้องบอก Tailwind ด้วยว่า components ของเราที่จะใช้ Tailwindcss อยู่ที่ไหนบ้าง

src/index.css
@import "tailwindcss";
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
:root {
33 collapsed lines
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
32 collapsed lines
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
36 collapsed lines
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
6 collapsed lines
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

create helper functions

สร้างไฟล์ lib/utils.ts

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

create shadcn config file (components.json)

เราจะสร้างไฟล์ components.json ไฟล์นี้เป็นเหมือน config ของ Shadcn น่ะแหละ

สิ่งที่เราต้องใส่ในไฟล์นี้จะมีบางส่วนที่ไม่เหมือนกับใน docs ของ Shadcn นะครับ
ผมจะไฮไลท์สีเขียวไว้

components.json
components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "./src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@repo/shadcn/components",
"ui": "@repo/shadcn/components",
"utils": "@repo/shadcn/lib/utils",
"lib": "@repo/shadcn/lib",
"hooks": "@repo/shadcn/hooks"
},
"iconLibrary": "lucide"
}

จาก code ด้านบน ผมจะอธิบายเพิ่มแบบนี้

  • tailwind.config เป็นค่าว่างๆนะ เพราะเราใช้ tailwind v4 ไม่จำเป็นต้องมี tailwind.config.js ก็ได้ แต่ถ้ามีก็เอามาใส่ตรงนี้แหละ
  • aliases ทุกๆ keys ด้านใน จะ ใส่ aliases ให้ตรงกับ exports ใน package.json นะ (เรายังไม่ได้ทำ เดี๋ยวจะใส่ exports ด้านล่างนี่แหละ)

exports files in package.json

เราจะใส่ exports ใน package.json ด้วยคำสั่ง

Terminal window
pnpm pkg set 'exports[./css]=./src/index.css'
pnpm pkg set 'exports[./postcss.config]=./postcss.config.mjs'
pnpm pkg set 'exports[./lib/*]=./src/lib/*.ts'
pnpm pkg set 'exports[./components/*]=./src/components/*.tsx'
pnpm pkg set 'exports[./hooks/*]=./src/hooks/*.tsx'

ในไฟล์ package.json เราจะมี dependencies กับ devDependencies ต่างๆ ตามนี้

package.json
package.json
{
"name": "@repo/shadcn",
"version": "0.0.0",
"description": "",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@total-typescript/tsconfig": "^1.0.4",
"@types/node": "^22.13.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.8.2"
},
"dependencies": {
"@tailwindcss/postcss": "^4.0.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.479.0",
"postcss": "^8.5.1",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.6",
"tailwindcss-animate": "^1.0.7"
},
"exports": {
"./css": "./src/index.css",
"./postcss.config": "./postcss.config.mjs",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts"
}
}

install Shadcn Button

เราจะมาลอง install Shadcn Button กัน
ยังอยู่ใน packages/shadcn นะครับ
ให้สั่ง

Terminal window
pnpm dlx shadcn@latest add button

จะได้แบบนี้

Terminal window
pnpm dlx shadcn@latest add button
Checking registry.
Installing dependencies.
Created 1 file:
- src/components/button.tsx

เราจะได้ไฟล์ button.tsx เพิ่มมาแบบนี้

Terminal window
src
├── components
├── button.tsx
├── hooks/
├── index.css
└── lib/

component Button ที่ได้มาผมจะไม่แก้อะไร จะเอาไปใช้ที่ web เลย

use Shadcn package in Web

เราจะเอา package shadcn มาใช้งานละ

มาที่ apps/web ของเรา

Terminal window
cd apps/web

install package @repo/shadcn

เพิ่ม @repo/shadcn ใน package.json

Terminal window
pnpm pkg set 'dependencies.@repo/shadcn=workspace:*'
pnpm i

create postcss config file

สร้างไฟล์ postcss.config.mjs เราจะเอา postcss config file ที่อยู่ใน @repo/shadcn มาใช้งาน

Terminal window
echo '
import postcssConfig from "@repo/shadcn/postcss.config"
export default postcssConfig
' > postcss.config.mjs

จะได้ไฟล์ postcss.config.mjs แบบนี้

postcss.config.mjs
import postcssConfig from "@repo/shadcn/postcss.config"
export default postcssConfig

setup tsconfig.json

เราจะต้องเพิ่ม paths ใน tsconfig.json อีก
เพิ่ม @repo/shadcn ตามที่ชื่อของ shadcn package นะ
แล้วให้ชี้ไปที่ src ที่ shadcn package จริงๆผ่าน relative paths เพื่อให้ auto-import ช่วย import components ได้ถูก

tsconfig.json
tsconfig.json
{
23 collapsed lines
"extend": "",
"include": [
"**/*.ts",
"**/*.tsx"
],
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "ES2022",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
3 collapsed lines
"~/*": [
"./app/*",
],
"@repo/shadcn/*": [
"../../packages/shadcn/src/*"
]
},
"noEmit": true
}
}

ลองใช้ Button component ดูที่ home page

app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { honoClient } from "@repo/api"
import { Button } from '@repo/shadcn/components/button'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const hc = honoClient("http://localhost:3000")
const cameras = await hc.cameras.$get()
return cameras.json()
}
})
function Home() {
const state = Route.useLoaderData()
return (
<div className="p-2">
<h3>Welcome Home!!!</h3>
<h3>Cameras</h3>
<div>
{JSON.stringify(state)}
</div>
<Button className="p-4 bg-red-500 rounded-full" >Test button</Button>
</div>
)
}

เปิดหน้าเวปจะได้แบบนี้

img07

ลองอีก component นึง
รอบนี้ลองใช้ Calendar

มาที่ packages/shadcn

Terminal window
cd packages/shadcn
pnpm dlx shadcn@latest add calendar

จะได้แบบนี้

Terminal window
cd packages/shadcn
pnpm dlx shadcn@latest add calendar
Checking registry.
Installing dependencies.
Created 1 file:
- src/components/calendar.tsx
Skipped 1 files: (files might be identical, use --overwrite to overwrite)
- src/components/button.tsx

เหมือนเดิม ผมจะไม่แก้อะไรใน Calendar component เลย
ไปลองใช้งานกันเลย

ลองใช้ Calendar component ดูที่ home page

app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { honoClient } from "@repo/api"
import { Button } from '@repo/shadcn/components/button'
import { Calendar } from '@repo/shadcn/components/calendar'
import { useState } from 'react'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const hc = honoClient("http://localhost:3000")
const cameras = await hc.cameras.$get()
return cameras.json()
}
})
function Home() {
const state = Route.useLoaderData()
const [date, setDate] = useState<Date | undefined>(new Date())
return (
<div className="p-2">
<h3>Welcome Home!!!</h3>
<h3>Cameras</h3>
<div>
{JSON.stringify(state)}
</div>
<Button className="p-4 bg-red-500 rounded-full" >Test button</Button>
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="w-full rounded-md border shadow"
/>
</div>
)
}
img 08

ถึงตรงนี้เพื่อนๆน่าจะใช้ Shadcn ใน Turborepo ได้แล้ว

Husky + Gitmoji

กลับมาที่ workspace ของเรา

install husky

ติดตั้ง husky ด้วยคำสั่ง

Terminal window
pnpm add --save-dev husky -w && pnpm exec husky init

ผมจะสร้างไฟล์ install สำหรับ husky ใหม่ เพราะผมชอบลืมใส่ env HUSKY=0 เพื่อ disable husky ที่ CI pipeline ก็เลยใช้วิธีนี้แทน
สร้างไฟล์ .husky/install.mjs

Terminal window
echo '
// Skip Husky install in production and CI
if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') {
process.exit(0)
}
const husky = (await import('husky')).default
console.log(husky());
' > .husky/install.mjs

แก้ script prepare เพื่อให้เรียกใช้ script install ที่เราสร้าง

Terminal window
pnpm pkg set \
'scripts.prepare=node .husky/install.mjs'

ที่ package.json ของ workspace จะได้แบบนี้

package.json
package.json
{
"name": "blog",
"private": true,
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types",
"dev": "turbo run dev",
"prepare": "node .husky/install.mjs"
},
"devDependencies": {
"eslint": "^9.21.0",
"eslint-plugin-oxlint": "^0.15.13",
"eslint-plugin-perfectionist": "^4.9.0",
"husky": "^9.1.7",
"jiti": "^2.4.2",
"prettier": "^3.5.3",
"turbo": "^2.4.4",
"typescript": "5.8.2"
},
"packageManager": "[email protected]",
"engines": {
"node": ">=18"
}
}

install commitlint and gitmoji

Terminal window
pnpm i -D commitlint-config-gitmoji commitlint -w

สร้าง commitlint config file

Terminal window
echo 'module.exports = {
extends: ["gitmoji"],
};
' > commitlint.config.cjs

จะได้ commitlint.config.cjs แบบนี้

commitlint.config.cjs
module.exports = {
extends: ["gitmoji"],
};

จากนั้นไป set ให้ husky เรียกใช้ commitlint แบบนี้

Terminal window
echo '
pnpm dlx commitlint --edit "$1"
' > .husky/commit-msg

จะได้ไฟล์ .husky/commit-msg แบบนี้

Terminal window
pnpm dlx commitlint --edit "$1"

ติดตั้ง commitizen

Terminal window
pnpm add -D commitizen cz-customizable -w
pnpm pkg set config.commitizen.path="node_modules/cz-customizable"
pnpm pkg set config.cz-customizable.config="./cz.config.cjs"

เพิ่ม script ที่เอาไว้เรียกใช้แทน git commit

Terminal window
pnpm pkg set \
'scripts.commit=cz'

จะได้ package.json ของ workspace แบบนี้

package.json
{
"name": "blog",
"private": true,
"scripts": {
6 collapsed lines
"build": "turbo run build",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types",
"dev": "turbo run dev",
"prepare": "node .husky/install.mjs",
"commit": "cz"
},
"devDependencies": {
"commitizen": "^4.3.1",
"commitlint": "^19.8.0",
"commitlint-config-gitmoji": "^2.3.1",
"cz-customizable": "^7.4.0",
8 collapsed lines
"eslint": "^9.21.0",
"eslint-plugin-oxlint": "^0.15.13",
"eslint-plugin-perfectionist": "^4.9.0",
"husky": "^9.1.7",
"jiti": "^2.4.2",
"prettier": "^3.5.3",
"turbo": "^2.4.4",
"typescript": "5.8.2"
},
4 collapsed lines
"packageManager": "[email protected]",
"engines": {
"node": ">=18"
},
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
},
"cz-customizable": {
"config": "./cz.config.cjs"
}
}
}

สร้าง cz config file

Terminal window
touch cz.config.cjs

ใส่ code template แบบนี้
ตรงนี้เพื่อนๆก็แก้เอาตามชอบนะครับ

cz.config.cjs
module.exports = {
types: [
{ value: ':sparkles: feat', name: '✨ feat:\tAdding a new feature' },
{ value: ':bug: fix', name: '🐛 fix:\tFixing a bug' },
{ value: ':memo: docs', name: '📝 docs:\tAdd or update documentation' },
{
value: ':lipstick: style',
name: '💄 style:\tAdd or update styles, ui or ux',
},
{
value: ':recycle: refactor',
name: '♻️ refactor:\tCode change that neither fixes a bug nor adds a feature',
},
{
value: ':zap: perf',
name: '⚡️ perf:\tCode change that improves performance',
},
{
value: ':white_check_mark: test',
name: '✅ test:\tAdding tests cases',
},
{
value: ':truck: chore',
name: '🚚 chore:\tChanges to the build process or auxiliary tools\n\t\tand libraries such as documentation generation',
},
{ value: ':rewind: revert', name: '⏪️ revert:\tRevert to a commit' },
{ value: ':construction: wip', name: '🚧 wip:\tWork in progress' },
{
value: ':construction_worker: build',
name: '👷 build:\tAdd or update regards to build process',
},
{
value: ':green_heart: ci',
name: '💚 ci:\tAdd or update regards to build process',
},
],
scopes: [
{ name: 'apps/api' },
{ name: 'apps/web' },
{ name: 'pkg/api' },
{ name: "pkg/eslint" },
{ name: "pkg/rslib" },
{ name: "pkg/shadcn" },
{ name: "pkg/prisma" },
{ name: "docker" },
{ name: "pipeline" },
{ name: "general" },
],
usePreparedCommit: false, // to re-use commit from ./.git/COMMIT_EDITMSG
allowTicketNumber: false,
isTicketNumberRequired: false,
ticketNumberPrefix: 'TICKET-',
ticketNumberRegExp: '\\d{1,5}',
// override the messages, defaults are as follows
messages: {
type: "Select the type of change that you're committing:",
scope: '\nDenote the SCOPE of this change (optional):',
// used if allowCustomScopes is true
customScope: 'Denote the SCOPE of this change:',
subject: 'Write a SHORT, IMPERATIVE tense description of the change:\n',
body: 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n',
breaking: 'List any BREAKING CHANGES (optional):\n',
footer: 'List any ISSUES CLOSED by this change (optional). E.g.: #31, #34:\n',
confirmCommit: 'Are you sure you want to proceed with the commit above?',
},
allowCustomScopes: true,
allowBreakingChanges: ['feat', 'fix'],
// skip any questions you want
// skipQuestions: ['scope', 'body'],
// limit subject length
subjectLimit: 100,
// breaklineChar: '|', // It is supported for fields body and footer.
// footerPrefix : 'ISSUES CLOSED:'
// askForBreakingChangeFirst : true, // default is false
};

Setup lint-staged

การใช้ lint-staged ก็ไม่ยาก
เราแค่ติดตั้ง lint-staged ไว้ใน workspace level

Terminal window
pnpm add -D lint-staged -w

จากนั้น
เราจะต้องสร้าง lint staged config file (.lintstagedrc.json ) ในทุกๆ apps ในทุกๆ packages ที่เราต้องการจะสั่ง eslint

ในที่นี้ผมต้องการจะสั่ง eslint ในทุกๆ apps แต่ไม่ใช่ในทุกๆ packages
ตามนี้

  • apps/api
  • apps/web
  • packages/prisma
  • pacakges/api
  • packages/shadcn

ฉนั้น lint-staged config จะต้องมีอยู่ในทุกๆ apps และ packages เหล่านั้น

ไฟล์ lint-staged config ของผมเหมือนกันหมดในทุกๆที่เลย แบบนี้

.lintstagedrc.json
.lintstagedrc.json
{
"*.{js,ts,jsx,tsx}": "eslint --max-warnings 0"
}

จากนี้ก็แค่ใส่ไฟล์นี้เข้าไปในทุกๆ apps ทุกๆ packages

.
6 collapsed lines
├── .gitignore
├── .husky
│ ├── commit-msg
│ ├── install.mjs
│ └── pre-commit
├── .npmrc
├── apps
│ ├── api
│ │ ├── .lintstagedrc.json
6 collapsed lines
│ │ ├── dist
│ │ ├── eslint.config.ts
│ │ ├── package.json
│ │ ├── rslib.config.ts
│ │ ├── src
│ │ └── tsconfig.json
│ └── web
│ ├── .lintstagedrc.json
15 collapsed lines
│ ├── .gitignore
│ ├── .prettierignore
│ ├── .turbo
│ ├── .vscode
│ ├── app
│ ├── app.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ ├── README.md
│ └── tsconfig.json
├── commitlint.config.cjs
├── cz.config.cjs
├── package.json
├── packages
│ ├── api
│ │ ├── .lintstagedrc.json
15 collapsed lines
│ │ ├── .gitignore
│ │ ├── dist
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── rslib.config.ts
│ │ ├── src
│ │ └── tsconfig.json
│ ├── eslint-config
│ │ ├── antfu.ts
│ │ ├── base.js
│ │ ├── eslint.config.ts
│ │ ├── next.js
│ │ ├── package.json
│ │ ├── react-internal.js
│ │ └── README.md
│ ├── prisma
│ │ ├── .lintstagedrc.json
14 collapsed lines
│ │ ├── .env
│ │ ├── .gitignore
│ │ ├── dist
│ │ ├── eslint.config.ts
│ │ ├── package.json
│ │ ├── prisma
│ │ ├── rslib.config.ts
│ │ ├── src
│ │ └── tsconfig.json
│ ├── rslib-config
│ │ ├── app.config.ts
│ │ ├── eslint.config.ts
│ │ ├── lib.config.ts
│ │ └── package.json
│ ├── shadcn
│ │ ├── .lintstagedrc.json
23 collapsed lines
│ │ ├── .turbo
│ │ ├── components.json
│ │ ├── eslint.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── src
│ │ └── tsconfig.json
│ ├── typescript-config
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── package.json
│ │ └── react-library.json
│ └── ui
│ ├── .turbo
│ ├── eslint.config.ts
│ ├── package.json
│ ├── src
│ ├── tsconfig.json
│ └── turbo
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
└── turbo.json

ทดลองใช้ lint-staged ดู

Terminal window
git add .
pnpm lint-staged

ผมได้ commit ไปก่อนที่จะติดตั้ง lint-staged
ในส่วนของผมจะได้แบบนี้

Terminal window
pnpm lint-staged
Backed up original state in git stash (58f0e79)
Running tasks for staged files...
Applying modifications from tasks...
Cleaning up temporary files...

Conclusion

เราได้ทำอะไรกันไปบ้าง

  • สร้าง Turborepo เปล่าๆ แต่ก็ไม่ได้เปล่าๆเท่าไร มี Nextjs ติดมาด้วย 2 apps แต่ว่าลบทิ้งไป
  • สร้าง configs ต่างๆที่สามารถใช้งานร่วมกันได้ทั้งใน apps และ packages เช่น eslint-config, rslib-config
  • สร้าง package Hono api แล้วให้ logic ของ API มาอยู่ตรงนี้ให้หมด แล้วค่อยเอาไป run server ที่ apps/api อีกครั้งนึง
    ทำให้เราสามารถใช้ Hono client ดึง data ที่ Frontend โดยมี Type support ทั้งหมด เป็น E2E type-safe
  • สร้าง apps/api แล้วเรียกใช้ package api อีกที เท่านี้เลย ง่ายมากๆตรงนี้
  • สร้าง prisma package
  • สร้าง apps/web โดยใช้ Tanstack start framework
  • สร้าง shadcn package เพื่อทำ UI ให้ Web นำไปใช้อีกที ซึ่งในอนาคตถ้ามี Web ที่ 2 แล้วอยากใช้ UI ตรงนี้ด้วยก็ดึงไปใช้ได้เลย สะดวกมากๆ
    ตรงนี้ผมแนะนำว่าถ้าเป็นไปให้ควรสร้าง pure components ไว้ในนี้จะดีกว่านะครับ เพื่อให้ง่ายตอนที่เอา components ไปใช้ในหลายๆที่

tutorial นี้มันยาวมากๆๆๆ ผมอยากให้เห็นภาพให้ได้มากที่สุด เลยพยายามยกตัวอย่างพร้อมพาทำทีละขั้นตอน
อยากให้คนที่ follow ตาม step-by-step ใช้งาน Turborepo ได้เข้าใจ

ยังไงถ้าตกหล่นตรงไหน DM มาบอกกันได้นะครับ ผมจะรีบแก้
ผมพยายามทำให้ละเอียดแล้ว ใช้เวลานานมากกว่าจะเขียนเสร็จ แต่ผมว่ามันน่าจะมีจุดที่พลาดอยู่หลายจุดแน่ๆ ต้องขออภัยด้วยนะครับ

ยังเหลืออีก 2 EPs นะครับ ผมจะพยายามหาเวลามาเขียนให้จบ

EP2 จะเขียนคำสั่งเพื่อเอา apps ของเราทั้ง api ทั้ง web มาทำเป็น Docker image นะครับ
EP3 จะเขียน pipeline ให้ build docker image ของ api และ web แล้ว push เข้า google registry นะครับ


Thank you 🙏🫰

ขอบคุณทุกคนที่อ่านมาถึงตรงนี้

ใน tutorial นี้ยังขาดการ setup Test ถ้าผมมีเวลาจะมาเขียนแยกเป็นอีก blog นึงละกันครับ นี่ก็เยอะมากเลย

ตัวอย่าง repo ที่เสร็จแล้ว อยู่ที่ my github

ขอให้มีความสุขกับการ coding นะครับ


Crafted with care 📝❤️ ✨ bycode sook logoCodeSook