Jika Anda mengelola proyek Next.js di monorepo Turborepo, kebutuhan utamanya biasanya bukan sekadar “CI jalan”, tetapi CI yang cepat, konsisten, dan tidak membuang waktu developer. Pipeline yang baik harus bisa menjalankan lint, type-check, test, dan build hanya untuk paket yang terdampak, memanfaatkan cache, lalu membuat preview per pull request tanpa meninggalkan deployment usang.

Artikel ini membahas implementasi praktis Next.js: CI Pipeline Turborepo untuk Build, Lint, dan Preview PR menggunakan GitHub Actions. Fokusnya adalah otomasi yang masuk akal untuk tim kecil sampai menengah: struktur repo yang jelas, selective task berbasis affected packages, concurrency untuk membatalkan job lama, artifact untuk memisahkan tahap build dan deploy, serta langkah untuk menjaga environment tetap konsisten.

Tujuan pipeline yang akan dibangun

Pipeline yang sehat untuk monorepo biasanya memenuhi beberapa tujuan berikut:

  • Cepat: tidak menjalankan semua task untuk semua package jika perubahan hanya terjadi di sebagian repo.
  • Konsisten: versi Node.js, package manager, dan command yang dipakai lokal sama dengan CI.
  • Dapat dipercaya: lint, type-check, test, dan build berjalan dengan urutan yang jelas.
  • Aman untuk kolaborasi: setiap pull request mendapat preview, dan preview lama dibersihkan atau ditimpa agar tidak membingungkan reviewer.
  • Efisien: cache dependency dan cache task Turborepo dipakai dengan benar, tanpa mengorbankan reproducibility.

Struktur monorepo yang disarankan

Untuk pembahasan ini, anggap repo memiliki struktur seperti berikut:

repo/
  apps/
    web/              # aplikasi Next.js utama
    docs/             # aplikasi lain, opsional
  packages/
    ui/               # shared UI components
    eslint-config/    # shared lint config
    typescript-config/# shared tsconfig
  .github/workflows/
    ci.yml
    preview-cleanup.yml
  package.json
  turbo.json
  pnpm-workspace.yaml

Nama folder tidak harus sama, tetapi pola apps/ dan packages/ membantu memisahkan aplikasi yang dibuild/deploy dari library internal yang menjadi dependency.

Kenapa struktur ini membantu CI

  • Dependency graph lebih jelas: Turborepo dapat menentukan package mana yang terdampak ketika library berubah.
  • Task mudah dipisah: lint/test/build untuk app dan package bisa didefinisikan konsisten.
  • Selective execution lebih efektif: perubahan di packages/ui bisa memicu build apps/web, tetapi perubahan di dokumen internal tidak harus membangun semua app.

Menyiapkan task di package.json dan turbo.json

Langkah pertama adalah memastikan setiap workspace memiliki script yang konsisten. Misalnya di root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@9",
  "scripts": {
    "lint": "turbo run lint",
    "type-check": "turbo run type-check",
    "test": "turbo run test",
    "build": "turbo run build"
  }
}

Lalu di aplikasi Next.js, misalnya apps/web/package.json:

{
  "name": "web",
  "scripts": {
    "dev": "next dev",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "build": "next build"
  }
}

Untuk konfigurasi Turborepo, gunakan turbo.json yang eksplisit soal dependency task dan output cache:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "lint": {
      "outputs": []
    },
    "type-check": {
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "build": {
      "dependsOn": ["^build"],
      "outputs": [
        ".next/**",
        "dist/**",
        "build/**",
        "!.next/cache/**"
      ]
    }
  }
}

Catatan: output cache harus mencerminkan hasil build yang benar-benar diproduksi oleh package. Jangan memasukkan terlalu banyak path yang berubah-ubah jika tidak diperlukan, karena itu bisa menurunkan efektivitas cache.

Mengapa dependsOn penting

Pada monorepo, aplikasi Next.js sering bergantung pada package internal seperti ui atau config. Dengan dependsOn: ["^build"], saat apps/web dibuild, Turborepo tahu bahwa package dependency di atasnya juga perlu dibuild lebih dulu jika memang punya task build.

Selective task berdasarkan package yang terdampak

Salah satu keuntungan terbesar Turborepo adalah kemampuan menjalankan task hanya pada package yang terdampak perubahan. Dalam konteks pull request, pendekatan umum adalah membandingkan branch saat ini dengan branch target, lalu menjalankan:

turbo run lint type-check test build --filter=...[origin/main]

Filter tersebut secara konsep berarti: jalankan task pada workspace yang berubah dan package yang bergantung padanya. Ini penting karena perubahan di library shared bisa memengaruhi aplikasi Next.js yang menggunakannya.

Kapan selective task efektif

  • Repo memiliki beberapa app/package dan perubahan biasanya lokal pada subset tertentu.
  • Dependency graph internal rapi.
  • Task lint/test/build per package sudah terdefinisi konsisten.

Kapan perlu fallback ke full run

  • Perubahan menyentuh file root yang memengaruhi seluruh workspace, seperti lockfile, base tsconfig, shared eslint config, atau util build yang dipakai semua package.
  • Anda belum yakin dependency graph internal sudah mencerminkan relasi sebenarnya.
  • Debugging pipeline sedang dilakukan dan Anda ingin menyingkirkan variabel selective execution.

Praktiknya, banyak tim memakai selective run untuk pull request dan full run untuk branch utama atau release branch.

Workflow GitHub Actions untuk lint, type-check, test, dan build

Berikut contoh workflow GitHub Actions yang bisa dijadikan basis. Contoh ini menggunakan beberapa praktik penting:

  • Trigger pada pull_request dan push ke branch utama.
  • Concurrency untuk membatalkan run lama pada branch/PR yang sama.
  • Cache dependency melalui setup package manager.
  • Artifact untuk menyimpan hasil build preview sebelum deploy.
  • Selective task untuk PR, dan full run untuk branch utama bila diperlukan.
name: ci

on:
  pull_request:
    types: [opened, synchronize, reopened]
  push:
    branches:
      - main

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  validate:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'pnpm'

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          run_install: false

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Determine turbo filter for PR
        id: scope
        shell: bash
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            echo "filter=--filter=...[origin/${{ github.base_ref }}]" >> $GITHUB_OUTPUT
          else
            echo "filter=" >> $GITHUB_OUTPUT
          fi

      - name: Lint
        run: pnpm turbo run lint ${{ steps.scope.outputs.filter }}

      - name: Type check
        run: pnpm turbo run type-check ${{ steps.scope.outputs.filter }}

      - name: Test
        run: pnpm turbo run test ${{ steps.scope.outputs.filter }}

      - name: Build
        run: pnpm turbo run build ${{ steps.scope.outputs.filter }}

      - name: Pack preview artifact
        if: github.event_name == 'pull_request'
        run: |
          tar -czf preview-web.tar.gz apps/web/.next apps/web/package.json

      - name: Upload preview artifact
        if: github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: preview-web
          path: preview-web.tar.gz
          retention-days: 3

Penjelasan trigger dan concurrency

Bagian ini sering diabaikan, padahal dampaknya langsung terasa oleh tim:

  • pull_request: pipeline berjalan untuk validasi dan preview setiap ada update PR.
  • push ke main: berguna untuk memastikan branch utama tetap sehat, termasuk saat merge queue atau squash merge mengubah commit history.
  • concurrency: run lama dibatalkan ketika developer push commit baru ke PR yang sama. Ini mengurangi antrean runner, menghemat waktu, dan mencegah preview dari commit lama menyelesaikan deploy lebih belakangan.

Tanpa cancel-in-progress: true, Anda bisa mengalami kondisi di mana PR sudah punya commit baru, tetapi hasil preview yang muncul justru berasal dari run sebelumnya yang lebih lambat selesai.

Mengapa artifact dipisahkan dari job deploy

Memisahkan job build dari job deploy preview memberi beberapa keuntungan:

  • Deploy hanya memakai hasil yang sudah lolos build.
  • Debugging lebih mudah karena hasil build bisa diunduh dari workflow.
  • Jika deploy gagal karena masalah platform preview, Anda tidak perlu mengulang lint/test/build dari nol.

Dalam praktik nyata, isi artifact dapat berupa output build, metadata commit, atau bundle yang sesuai dengan platform preview yang Anda pakai.

Workflow deploy preview per pull request

Karena platform preview berbeda-beda, contoh di bawah ini dibuat generik. Polanya tetap sama: unduh artifact dari job validasi, deploy ke environment bernama unik berdasarkan nomor PR, lalu publikasikan URL preview ke PR.

name: preview

on:
  workflow_run:
    workflows: ["ci"]
    types:
      - completed

concurrency:
  group: preview-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_branch }}
  cancel-in-progress: true

jobs:
  deploy-preview:
    if: >
      github.event.workflow_run.conclusion == 'success' &&
      github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest

    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: preview-web
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract artifact
        run: tar -xzf preview-web.tar.gz

      - name: Deploy preview
        id: deploy
        run: |
          # Ganti perintah ini dengan CLI platform preview yang Anda gunakan
          # Simpan URL hasil deploy ke output
          echo "url=https://preview.example.com/pr-${{ github.event.workflow_run.pull_requests[0].number }}" >> $GITHUB_OUTPUT

      - name: Comment preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const pr = context.payload.workflow_run.pull_requests[0];
            const url = '${{ steps.deploy.outputs.url }}';
            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              body: `Preview siap: ${url}`
            });

Alur trigger yang perlu dipahami

Contoh di atas menggunakan workflow_run, artinya deploy preview hanya berjalan setelah workflow CI selesai dan statusnya sukses. Ini memisahkan concern validasi dari concern deployment.

Alternatifnya, Anda bisa menaruh deploy preview di workflow yang sama setelah build selesai. Pendekatan terpisah lebih modular, tetapi ada trade-off:

  • Kelebihan: alur lebih bersih, deployment tidak berjalan jika CI gagal, debugging lebih mudah.
  • Kekurangan: koordinasi artifact antar workflow bisa sedikit lebih rumit.

Untuk tim kecil-menengah, dua pendekatan sama-sama valid. Jika proses masih sederhana, satu workflow dengan beberapa job sering lebih mudah dikelola.

Mencegah preview usang

Masalah umum pada preview PR adalah URL atau environment lama yang masih aktif walau PR sudah diperbarui atau ditutup. Ini membingungkan reviewer dan bisa memboroskan resource.

Strategi yang disarankan

  1. Gunakan nama environment yang deterministik, misalnya pr-123. Dengan begitu deploy baru akan menimpa preview lama untuk PR yang sama.
  2. Aktifkan concurrency pada job preview agar deploy lama dibatalkan.
  3. Bersihkan preview saat PR ditutup menggunakan workflow terpisah.

Contoh workflow cleanup:

name: preview-cleanup

on:
  pull_request:
    types: [closed]

jobs:
  cleanup-preview:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview environment
        run: |
          # Ganti dengan CLI/SDK platform preview Anda
          echo "Remove preview for PR-${{ github.event.pull_request.number }}"

Prinsip penting: preview sebaiknya dipetakan ke nomor PR, bukan ke SHA commit. Jika dipetakan ke SHA, Anda akan menghasilkan banyak preview sementara yang sulit dikelola.

Cache dependency dan cache build: apa yang benar-benar membantu

Pipeline lambat biasanya bukan karena satu hal, tetapi kombinasi dari install dependency, restore cache, task yang terlalu luas, dan build aplikasi yang berulang. Ada dua lapisan cache yang relevan di sini:

1. Cache dependency

Biasanya dikelola oleh action setup package manager. Tujuannya mempercepat instalasi paket dari lockfile yang sama. Ini efektif jika:

  • Lockfile tidak sering berubah drastis.
  • Semua workspace memakai package manager yang sama.
  • CI selalu menjalankan install berbasis lockfile, misalnya --frozen-lockfile.

Kesalahan umum adalah mengandalkan cache dependency tetapi membiarkan install berjalan tanpa lockfile yang ketat. Akibatnya environment lokal dan CI bisa berbeda.

2. Cache task/build Turborepo

Turborepo menyimpan hasil task berdasarkan input file, dependency graph, environment tertentu, dan output yang dideklarasikan. Ini berguna untuk task seperti build package internal, test tertentu, atau lint pada workspace besar.

Agar cache Turborepo efektif:

  • Deklarasikan outputs dengan benar di turbo.json.
  • Hindari task yang membaca file acak di luar workspace tanpa terdeteksi input-nya.
  • Jaga script build tetap deterministik.
  • Jika Anda memakai variabel environment yang memengaruhi output, pastikan nilainya konsisten di CI.

Jangan cache semuanya secara membabi buta

Misalnya, cache folder yang sangat besar tetapi volatil bisa membuat restore cache lebih lambat daripada membangun ulang. Uji bottleneck utama lebih dulu: apakah waktu habis di install, test, atau build Next.js.

Menjaga konsistensi environment antara lokal dan CI

Banyak kegagalan CI di monorepo bukan karena kode rusak, melainkan karena environment drift. Beberapa langkah sederhana bisa mencegahnya:

  • Pin versi Node.js dengan .nvmrc atau mekanisme serupa, lalu pakai file itu juga di GitHub Actions.
  • Pin package manager di root package.json.
  • Gunakan lockfile dan install dengan mode ketat seperti --frozen-lockfile.
  • Samakan command lokal dan CI: jika lokal memakai pnpm turbo run build, jangan gunakan command lain di CI kecuali ada alasan kuat.
  • Hindari script yang diam-diam bergantung pada tool global.

Untuk tim kecil-menengah, aturan paling praktis adalah: semua task yang dijalankan di CI harus bisa dijalankan developer dari root repo dengan command yang sama.

Mengurangi waktu pipeline tanpa mengorbankan kualitas

Berikut beberapa optimasi yang biasanya memberi hasil nyata:

Pisahkan validasi cepat dan lambat

Lint dan type-check sering memberi feedback lebih cepat daripada build penuh. Jika ingin, Anda bisa memecah job menjadi:

  • fast-check: lint + type-check
  • test-build: test + build, berjalan setelah fast-check atau paralel sesuai kebutuhan

Trade-off-nya: workflow lebih kompleks, tetapi feedback awal lebih cepat.

Gunakan selective run untuk pull request

Ini adalah penghemat waktu terbesar pada monorepo yang sehat. Namun tetap sediakan satu jalur full validation pada main agar perubahan lintas package tetap tertangkap.

Batalkan job lama

Concurrency sering lebih terasa dampaknya daripada optimasi kecil di script. Jika satu developer push 5 kali ke PR yang sama, Anda tidak ingin 5 build Next.js penuh berjalan bersamaan.

Hindari rebuild yang tidak perlu untuk preview

Bangun artifact sekali di CI, lalu deploy artifact tersebut. Jangan build ulang di job deploy kecuali platform preview memang mewajibkan.

Kesalahan umum dan cara debugging

1. Filter affected tidak menghasilkan package yang diharapkan

Penyebab umum:

  • fetch-depth terlalu dangkal sehingga base branch tidak tersedia.
  • Dependency internal tidak dideklarasikan benar di workspace.
  • Perubahan ada di file root yang tidak diperlakukan sesuai strategi filter.

Langkah debug:

  • Pastikan checkout memakai fetch-depth: 0.
  • Jalankan command filter yang sama secara lokal.
  • Periksa apakah package internal benar-benar terhubung sebagai dependency.

2. Cache terasa tidak membantu

Penyebab umum:

  • Output cache tidak akurat.
  • Task tidak deterministik.
  • Terlalu banyak file yang berubah sehingga hit rate rendah.

Langkah debug:

  • Tinjau outputs di turbo.json.
  • Pastikan script tidak menulis timestamp atau file acak ke output.
  • Bandingkan durasi restore cache dengan durasi task tanpa cache.

3. Preview URL tidak sesuai commit terbaru

Ini biasanya masalah concurrency atau penamaan environment. Gunakan satu environment tetap per nomor PR dan batalkan deploy lama yang masih berjalan.

4. Build lolos lokal tetapi gagal di CI

Fokuskan pemeriksaan pada:

  • Versi Node.js
  • Perbedaan lockfile
  • Dependency yang tidak tercantum tetapi tersedia secara kebetulan di mesin lokal
  • Script yang membaca environment variable yang tidak ada di CI

Checklist adopsi untuk tim kecil-menengah

Jika Anda ingin mengadopsi pipeline ini tanpa membuat perubahan besar sekaligus, gunakan checklist berikut:

  1. Rapikan struktur monorepo ke pola apps/ dan packages/ bila memungkinkan.
  2. Standarkan script lint, type-check, test, dan build di tiap workspace.
  3. Tambahkan turbo.json dengan dependsOn dan outputs yang jelas.
  4. Samakan environment: pin Node.js, package manager, dan pakai lockfile ketat.
  5. Buat workflow CI dasar untuk install, lint, type-check, test, build.
  6. Aktifkan selective run untuk pull request setelah dependency graph tervalidasi.
  7. Tambahkan concurrency agar run lama dibatalkan otomatis.
  8. Pisahkan build dan deploy preview dengan artifact jika preview Anda butuh langkah deploy tersendiri.
  9. Gunakan environment preview per PR, bukan per commit.
  10. Tambahkan cleanup preview saat pull request ditutup.
  11. Evaluasi bottleneck nyata sebelum menambah cache atau paralelisasi yang lebih kompleks.

Penutup

Pipeline Next.js: CI Pipeline Turborepo untuk Build, Lint, dan Preview PR yang baik bukan soal menambahkan sebanyak mungkin langkah, tetapi soal mengotomasi hal yang tepat. Untuk monorepo, kombinasi yang paling berdampak biasanya adalah selective task berbasis affected packages, cache yang wajar, concurrency untuk membatalkan job lama, dan preview PR yang selalu merepresentasikan commit terbaru.

Jika Anda mulai dari setup sederhana lalu mengukur bottleneck secara berkala, tim kecil-menengah bisa mendapatkan feedback yang cepat tanpa membuat workflow CI/CD terlalu rumit untuk dipelihara.