Browse Source

Merge pull request #10831 from growilabs/support/upgrade-nextjs

support: Upgrade Next.js to v16
Yuki Takei 1 month ago
parent
commit
e2bcddb705
72 changed files with 1304 additions and 397 deletions
  1. 20 0
      .kiro/specs/reduce-modules-loaded/analysis-ledger.md
  2. 44 2
      .kiro/specs/reduce-modules-loaded/design.md
  3. 151 0
      .kiro/specs/reduce-modules-loaded/research.md
  4. 29 6
      .kiro/specs/reduce-modules-loaded/tasks.md
  5. 10 2
      .kiro/steering/structure.md
  6. 18 2
      .kiro/steering/tech.md
  7. 12 0
      apps/app/.claude/skills/app-commands/SKILL.md
  8. 81 0
      apps/app/.claude/skills/build-optimization/SKILL.md
  9. 4 4
      apps/app/bin/measure-chunk-stats.sh
  10. 2 1
      apps/app/next-env.d.ts
  11. 44 34
      apps/app/next.config.ts
  12. 3 4
      apps/app/package.json
  13. 1 1
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.module.scss
  14. 1 1
      apps/app/src/client/components/CompleteUserRegistrationForm.module.scss
  15. 1 1
      apps/app/src/client/components/InstallerForm.module.scss
  16. 1 1
      apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.module.scss
  17. 1 1
      apps/app/src/client/components/LoginForm/LoginForm.module.scss
  18. 1 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss
  19. 2 2
      apps/app/src/client/components/Navbar/GrowiNavbarBottom.module.scss
  20. 1 1
      apps/app/src/client/components/Navbar/PageEditorModeManager.module.scss
  21. 1 1
      apps/app/src/client/components/PageComment/Comment.module.scss
  22. 2 2
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.module.scss
  23. 1 1
      apps/app/src/client/components/PageEditor/Preview.module.scss
  24. 1 1
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  25. 6 16
      apps/app/src/client/components/PageList/PageListItemL.tsx
  26. 2 6
      apps/app/src/client/components/PagePathNavSticky/CollapsedParentsDropdown.tsx
  27. 1 1
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  28. 1 1
      apps/app/src/client/components/PagePresentationModal/PagePresentationModal.module.scss
  29. 1 1
      apps/app/src/client/components/PageStatusAlert.module.scss
  30. 2 2
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss
  31. 1 1
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.module.scss
  32. 1 1
      apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.module.scss
  33. 1 1
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.module.scss
  34. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  35. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  36. 2 2
      apps/app/src/client/components/Sidebar/Sidebar.module.scss
  37. 2 2
      apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.module.scss
  38. 1 1
      apps/app/src/client/components/Sidebar/SidebarNav/SidebarNav.module.scss
  39. 1 1
      apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkelton.module.scss
  40. 1 1
      apps/app/src/client/components/Sidebar/Tag.module.scss
  41. 1 1
      apps/app/src/client/components/Sidebar/_button-styles.scss
  42. 7 12
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  43. 1 1
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  44. 1 1
      apps/app/src/components/Layout/Admin.module.scss
  45. 1 1
      apps/app/src/components/Layout/BasicLayout.module.scss
  46. 1 1
      apps/app/src/components/Layout/SearchResultLayout.module.scss
  47. 2 2
      apps/app/src/components/PageView/PageViewLayout.module.scss
  48. 1 1
      apps/app/src/components/ReactMarkdownComponents/CodeBlock.module.scss
  49. 8 9
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  50. 1 3
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginCard.tsx
  51. 1 3
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx
  52. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.module.scss
  53. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss
  54. 1 0
      apps/app/src/features/openai/server/services/openai.ts
  55. 1 1
      apps/app/src/features/search/client/components/SearchMenuItem.module.scss
  56. 1 1
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.module.scss
  57. 18 9
      apps/app/src/models/linked-page-path.ts
  58. 7 1
      apps/app/src/pages/_app.page.tsx
  59. 173 0
      apps/app/src/pages/utils/superjson-ssr.spec.ts
  60. 69 0
      apps/app/src/pages/utils/superjson-ssr.ts
  61. 11 1
      apps/app/src/server/crowi/index.ts
  62. 3 1
      apps/app/src/server/util/project-dir-utils.ts
  63. 1 1
      apps/app/src/styles/atoms/_code.scss
  64. 1 1
      apps/app/src/styles/molecules/toastr.scss
  65. 145 0
      apps/app/src/test/nextjs-v16-upgrade.spec.ts
  66. 46 29
      apps/app/src/utils/next.config.utils.ts
  67. 35 0
      apps/app/src/utils/superjson-ssr-loader.ts
  68. 1 0
      biome.json
  69. 1 1
      packages/presentation/package.json
  70. 1 1
      packages/remark-lsx/package.json
  71. 1 1
      packages/ui/package.json
  72. 304 204
      pnpm-lock.yaml

+ 20 - 0
.kiro/specs/reduce-modules-loaded/analysis-ledger.md

@@ -207,3 +207,23 @@ Client bundle module paths extracted from `.next/static/chunks/` — 6,822 uniqu
 - **Module count decreased only 12**: Dynamic imports still count as modules in the webpack graph, but they're compiled into separate chunks (lazy). The "10,054 modules" includes the lazy chunks' modules in the count.
 - **Compile time decreased ~14%**: The significant improvement suggests webpack's per-module overhead is not uniform — mermaid (with chevrotain parser generator) and react-syntax-highlighter (with highlight.js language definitions) are particularly expensive to compile despite their module count.
 - **date-fns subpath imports**: Contributed to the module count reduction but likely minimal time impact (consistent with Phase 1 findings).
+
+## Next.js v16 Upgrade: Post-Upgrade Measurement (2026-03-03)
+
+### Baseline vs Post-Upgrade (ChunkModuleStats)
+
+| Metric | Pre-upgrade (v15.5.12) | Post-upgrade (v16.1.6) | Delta | Tolerance | Status |
+|--------|----------------------|----------------------|-------|-----------|--------|
+| **initial** | 895 | 863 | -32 (-3.6%) | ±5% (850–940) | **PASS** |
+| **async-only** | 4,775 | 4,784 | +9 (+0.2%) | ±10% | **PASS** |
+| **total** | 5,670 | 5,647 | -23 (-0.4%) | ±10% | **PASS** |
+
+### Raw Output
+
+```
+[ChunkModuleStats] initial: 863, async-only: 4784, total: 5647
+```
+
+### Conclusion
+
+All module count metrics are within tolerance after the Next.js v16 upgrade. The initial module count actually **decreased** by 3.6% (895 → 863), indicating that v16's webpack bundling is slightly more efficient at tree-shaking or module deduplication. All custom webpack configuration (null-loader rules, superjson-ssr-loader, I18NextHMRPlugin, ChunkModuleStatsPlugin, source-map-loader) is fully preserved and functional.

+ 44 - 2
.kiro/specs/reduce-modules-loaded/design.md

@@ -407,16 +407,58 @@ const nextConfig = {
 
 | Blocker | Severity | Mitigation |
 |---------|----------|------------|
-| `next-superjson` SWC plugin broken in v15 | Critical | Research alternatives: manual superjson in getServerSideProps, or use `superjson` directly without SWC plugin |
+| `next-superjson` SWC plugin broken in v15 | Critical | **Resolved** — custom webpack loader (`superjson-ssr-loader.js`) replaces the SWC plugin with a simple regex-based source transform; see SuperJSON Migration section below |
 | `I18NextHMRPlugin` (webpack plugin) | Medium | Only affects dev HMR for i18n; can use `--webpack` flag for dev |
 | React 19 peer dependency | Low | Pages Router has React 18 backward compat in v15 |
 | `@next/font` removal | Low | Codemod available; switch to `next/font` |
 
+##### SuperJSON Migration: Custom Webpack Loader Approach
+
+The `next-superjson` SWC plugin is replaced by a custom webpack loader that achieves the same transparent auto-wrapping of `getServerSideProps` without any SWC/Babel dependency.
+
+**Architecture**:
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Build Time (webpack)                                            │
+│                                                                 │
+│  .page.tsx files ──► superjson-ssr-loader.js ──► bundled output │
+│                      (auto-wraps getServerSideProps             │
+│                       with withSuperJSONProps)                  │
+└─────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────┐
+│ Runtime                                                         │
+│                                                                 │
+│  Server: getServerSideProps → superjson.serialize(props)        │
+│  Client: _app.page.tsx → deserializeSuperJSONProps(pageProps)   │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**Components**:
+
+| File | Role |
+|------|------|
+| `src/utils/superjson-ssr-loader.js` | Webpack loader — regex-based source transform that auto-wraps `getServerSideProps` exports |
+| `src/pages/utils/superjson-ssr.ts` | Runtime helpers — `withSuperJSONProps()` (server) and `deserializeSuperJSONProps()` (client) |
+| `_app.page.tsx` | Centralized client-side deserialization via `deserializeSuperJSONProps()` |
+| `next.config.js` | Loader registration targeting `.page.{ts,tsx}` files |
+
+**Why this approach over per-page wrapping**:
+- Zero per-page file changes (38 fewer files modified vs manual wrapping)
+- New pages automatically get superjson serialization without manual wrapping
+- Closer to the original `next-superjson` DX (config-only, transparent to page authors)
+- Webpack loader API is stable across Next.js versions — no SWC ABI fragility
+
+**Why not `next-superjson` v1.0.8**:
+- v1.0.8 achieves "v15 support" by pinning `@swc/core@1.4.17` (March 2024) and running a separate SWC compilation pass — fragile binary pinning with double compilation
+- Depends on unmaintained `next-superjson-plugin` v0.6.3 WASM binary
+- See `research.md` for detailed assessment
+
 **Implementation Notes**
 - Run codemod first: `npx @next/codemod@canary upgrade latest`
 - Test with `--webpack` flag to isolate bundler-related issues from framework issues
 - The `bundlePagesRouterDependencies: true` setting is the highest-value v15 feature for this spec — it automatically bundles server-side deps, which combined with `serverExternalPackages` provides fine-grained control
-- Research `next-superjson` alternatives during Phase 1 to have a mitigation ready
 
 ## Testing Strategy
 

+ 151 - 0
.kiro/specs/reduce-modules-loaded/research.md

@@ -135,6 +135,153 @@
 - **Risk**: Module count reduction is insufficient from config-only changes → **Mitigation**: Bundle analysis will reveal if server module leakage is the primary cause, guiding whether upgrade is needed
 - **Risk**: I18NextHMRPlugin has no Turbopack equivalent → **Mitigation**: Use `--webpack` flag for dev until alternative is available; Turbopack adoption is Phase 2b
 
+## Phase 3: Next.js 15+ Feature Evaluation (Task 9.1)
+
+### Context
+
+Phase 2 achieved significant module reduction (initial: 2,704 → 895, -67%) through dynamic imports, null-loader expansion, and dependency replacement. Phase 3 evaluates whether upgrading to Next.js 15 provides additional meaningful optimization via `bundlePagesRouterDependencies` and `serverExternalPackages`.
+
+### Current State
+
+| Component | Version | Notes |
+|-----------|---------|-------|
+| Next.js | 14.2.35 | Pages Router, Webpack 5 |
+| React | 18.2.0 | |
+| next-superjson | 1.0.7 | SWC plugin wrapper for superjson serialization |
+| Node.js | 24.13.1 | Exceeds v15 minimum (18.18.0) |
+
+### Next.js 15 Features Relevant to Module Reduction
+
+#### 1. `bundlePagesRouterDependencies` (Stable)
+
+- **What it does**: Enables automatic server-side dependency bundling for Pages Router, matching App Router default behavior. All server-side dependencies are bundled into the server output instead of using native Node.js `require()` at runtime.
+- **Impact**: Improved cold start (pre-resolved deps), smaller deployment footprint via tree-shaking of server bundles. Does NOT directly reduce client-side initial module count (our primary KPI), but improves overall server-side build efficiency.
+- **Configuration**: `bundlePagesRouterDependencies: true` in `next.config.js`
+- **Risk**: Low — Next.js maintains an auto-exclude list for packages with native bindings (mongoose, mongodb, express, @aws-sdk/*, sharp are all auto-excluded)
+
+#### 2. `serverExternalPackages` (Stable)
+
+- **What it does**: Opt-out specific packages from server-side bundling when `bundlePagesRouterDependencies` is enabled. These packages use native `require()`.
+- **Auto-excluded packages relevant to GROWI**: `mongoose`, `mongodb`, `express`, `@aws-sdk/client-s3`, `@aws-sdk/s3-presigned-post`, `sharp`, `pino`, `ts-node`, `typescript`, `webpack`
+- **GROWI packages that may need manual addition**: `passport`, `ldapjs`, `nodemailer`, `multer`, `redis`, `connect-redis`, `@elastic/elasticsearch*`
+- **Configuration**: `serverExternalPackages: ['passport', ...]`
+
+#### 3. Turbopack (Stable for Dev in v15)
+
+- **Status**: Turbopack Dev is stable in Next.js 15. Default bundler in Next.js 16.
+- **Benefits**: Automatic import optimization (eliminates need for `optimizePackageImports`), 14x faster cold starts, 28x faster HMR
+- **GROWI blockers**: Does NOT support `webpack()` config. GROWI's null-loader rules, I18NextHMRPlugin, source-map-loader, and ChunkModuleStatsPlugin all require webpack config.
+- **Mitigation**: Can run with `--webpack` flag in dev to keep Webpack while upgrading Next.js. Turbopack adoption deferred to separate task.
+
+#### 4. Improved Tree-shaking and Module Resolution
+
+- **Better dead code elimination** in both Webpack and Turbopack modes
+- **SWC improvements** for barrel file optimization
+- **Not directly measurable** without upgrading — included as potential secondary benefit
+
+### `next-superjson` Compatibility Assessment
+
+#### Current Architecture
+
+GROWI uses `withSuperjson()` in `next.config.js` (line 184) which:
+1. Injects a custom webpack loader targeting `pages/` directory files
+2. The loader runs `@swc/core` with `next-superjson-plugin` to AST-transform each page
+3. **Auto-wraps** `getServerSideProps` with `withSuperJSONProps()` (serializes props via superjson)
+4. **Auto-wraps** page default export with `withSuperJSONPage()` (deserializes props on client)
+
+Custom serializers registered in `_app.page.tsx`:
+- `registerTransformerForObjectId()` — handles MongoDB ObjectId serialization (no mongoose dependency)
+- `registerPageToShowRevisionWithMeta()` — handles page revision data (called in `[[...path]]` page)
+
+#### Compatibility Options
+
+| Option | Approach | Risk | Effort |
+|--------|----------|------|--------|
+| A. Upgrade `next-superjson` to 1.0.8 | Claims v15 support via frozen `@swc/core@1.4.17` | Medium — fragile SWC binary pinning; underlying plugin unmaintained | Minimal (version bump) |
+| B. Use `superjson-next@0.7.x` fork | Community SWC plugin with native v15 support | Medium — third-party fork, SWC plugins inherently fragile | Low (config change) |
+| C. Manual superjson (per-page wrapping) | Remove plugin; use helper functions + wrap each page's `getServerSideProps` | Low — no SWC plugin dependency | Medium (create helpers, wrap 38 pages) |
+| **D. Custom webpack loader (Recommended)** | **Remove plugin; use a simple regex-based webpack loader to auto-wrap `getServerSideProps`** | **Low — no SWC/Babel dependency, webpack loader API is stable** | **Low (create loader + config, no per-page changes)** |
+
+#### Detailed Assessment of Option A: `next-superjson` v1.0.8 / v2.0.0
+
+Both v1.0.8 and v2.0.0 were published on the same day (Oct 18, 2025). They share identical code — the only difference is the peer dependency declaration (`next >= 10` vs `next >= 16`).
+
+**How v1.0.8 achieves "Next.js 15 support"**:
+1. Does NOT use Next.js's built-in SWC plugin system
+2. Registers a custom webpack/turbopack loader targeting `pages/` directory files
+3. This loader uses a **bundled `@swc/core` pinned to v1.4.17 (March 2024)** to run a separate SWC compilation
+4. The pinned SWC loads the `next-superjson-plugin` v0.6.3 WASM binary
+
+**Risks**:
+- **Double SWC compilation**: Page files are compiled by both Next.js's SWC and the plugin's frozen SWC — potential for conflicts and performance overhead
+- **Pinned binary fragility**: `@swc/core@1.4.17` is from early 2024; SWC plugin ABI is notoriously unstable across versions
+- **Unmaintained upstream**: The `next-superjson-plugin` v0.6.3 WASM binary comes from `blitz-js/next-superjson-plugin`, which has unmerged PRs and open issues for v15 compatibility
+- **Low adoption**: Published Oct 2025, minimal community usage
+
+**Conclusion**: The "support" is a fragile workaround, not genuine compatibility. Rejected.
+
+#### Recommended: Option D — Custom Webpack Loader
+
+**Why**: Achieves the same zero-page-change DX as the original `next-superjson` plugin, but without any SWC/Babel dependency. The loader is a simple regex-based source transform (~15 lines) that auto-wraps `getServerSideProps` exports with `withSuperJSONProps()`. Webpack's loader API is stable across versions, making this future-proof.
+
+**How it works**:
+1. A webpack loader targets `.page.{ts,tsx}` files
+2. If the file exports `getServerSideProps`, the loader:
+   - Prepends `import { withSuperJSONProps } from '~/pages/utils/superjson-ssr'`
+   - Renames `export const getServerSideProps` → `const __getServerSideProps__`
+   - Appends `export const getServerSideProps = __withSuperJSONProps__(__getServerSideProps__)`
+3. Deserialization is centralized in `_app.page.tsx` (same as Option C)
+
+**Migration plan**:
+1. Create `withSuperJSONProps()` and `deserializeSuperJSONProps()` helpers in `src/pages/utils/superjson-ssr.ts`
+2. Create `src/utils/superjson-ssr-loader.js` — simple regex-based webpack loader
+3. Add loader rule in `next.config.js` webpack config (targets `.page.{ts,tsx}` files)
+4. Add centralized deserialization in `_app.page.tsx`
+5. Remove `next-superjson` dependency and `withSuperjson()` from `next.config.js`
+6. Keep `superjson` as direct dependency; keep all `registerCustom` calls unchanged
+
+**Advantages over Option C**:
+- Zero per-page file changes (38 fewer files modified)
+- Diff is ~20 lines total instead of ~660 lines
+- Closer to the original `next-superjson` DX (config-only, transparent to page authors)
+- New pages automatically get superjson serialization without manual wrapping
+
+**Scope**: 3 files changed (loader, next.config.js, _app.page.tsx) + 1 new file (superjson-ssr.ts with helpers)
+
+### Breaking Changes Affecting GROWI (Pages Router)
+
+| Change | Impact | Action Required |
+|--------|--------|-----------------|
+| Node.js ≥ 18.18.0 required | None — GROWI uses Node 24.x | No action |
+| `@next/font` → `next/font` | None — GROWI does not use `@next/font` | No action |
+| `swcMinify` enabled by default | Low — already effective | No action |
+| `next/dynamic` `suspense` prop removed | Verify — GROWI uses `next/dynamic` extensively | Check all `dynamic()` calls for `suspense` prop |
+| `eslint-plugin-react-hooks` v5.0.0 | Low — may trigger new lint warnings | Run lint after upgrade |
+| Config renames (`experimental.bundlePagesExternals` → `bundlePagesRouterDependencies`) | None — GROWI doesn't use the experimental names | No action |
+| `next/image` `Content-Disposition` changed | None — GROWI uses standard `next/image` | No action |
+| Async Request APIs (cookies, headers) | None — App Router only | No action |
+| React 19 peer dependency | None — Pages Router supports React 18 backward compat | Stay on React 18 |
+
+### Decision: Proceed with Next.js 15 Upgrade
+
+**Rationale**:
+1. **`bundlePagesRouterDependencies` + `serverExternalPackages`** provide proper server-side dependency bundling, completing the optimization work started in Phase 2
+2. **Breaking changes for Pages Router are minimal** — no async API changes, no React 19 requirement
+3. **`next-superjson` blocker is resolved** via custom webpack loader (Option D) — zero per-page changes, same transparent DX as original plugin
+4. **No Turbopack migration needed** — continue using Webpack with `--webpack` flag in dev
+5. **Phase 2 results (initial: 895 modules)** are already strong; v15 features provide server-side improvements and lay groundwork for future Turbopack adoption
+
+**Expected benefits**:
+- Server-side bundle optimization via `bundlePagesRouterDependencies`
+- Proper `serverExternalPackages` support (replaces null-loader workaround for some packages)
+- Modern Next.js foundation for future improvements (Turbopack, App Router migration path)
+- Elimination of fragile SWC plugin dependency (`next-superjson`) — replaced by simple webpack loader with no external dependencies
+
+**Risks and mitigations**:
+- `I18NextHMRPlugin` — Keep using Webpack bundler in dev (`--webpack` flag if needed)
+- Test regressions — Full test suite + typecheck + lint + build verification
+- Superjson serialization — Test all page routes for correct data serialization/deserialization
+
 ## References
 - [Next.js v15 Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-15) — Breaking changes inventory
 - [Turbopack API Reference](https://nextjs.org/docs/app/api-reference/turbopack) — Supported features and known gaps
@@ -142,3 +289,7 @@
 - [Package Bundling Guide (Pages Router)](https://nextjs.org/docs/pages/guides/package-bundling) — bundlePagesRouterDependencies, serverExternalPackages
 - [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) — Benchmarks and approach
 - [next-superjson GitHub](https://github.com/remorses/next-superjson) — Compatibility status
+- [next-superjson-plugin GitHub](https://github.com/blitz-js/next-superjson-plugin) — SWC plugin (unmaintained)
+- [superjson-next fork](https://github.com/serg-and/superjson-next) — Community fork with v15 support
+- [Next.js 15.5 Blog Post](https://nextjs.org/blog/next-15-5) — Latest features
+- [server-external-packages.jsonc](https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/server-external-packages.jsonc) — Auto-excluded server packages

+ 29 - 6
.kiro/specs/reduce-modules-loaded/tasks.md

@@ -239,16 +239,39 @@ The following loop repeats until the user declares completion:
 
 ## Phase 3: Next.js Version Upgrade Evaluation (Deferred)
 
-- [ ] 9.1 Document Next.js 15+ feature evaluation
-  - Document which Next.js 15+ features (`bundlePagesRouterDependencies`, `serverExternalPackages`, Turbopack, improved tree-shaking) are relevant to further module reduction
-  - Assess the `next-superjson` compatibility blocker and identify mitigation options
+- [x] 9.1 Document Next.js 15+ feature evaluation
+  - Documented all Next.js 15+ features relevant to module reduction: `bundlePagesRouterDependencies`, `serverExternalPackages`, Turbopack (stable dev), improved tree-shaking
+  - Assessed `next-superjson` blocker: 4 options evaluated (A: v1.0.8 upgrade, B: superjson-next fork, C: manual per-page wrapping, D: custom webpack loader)
+  - **Option A (`next-superjson` v1.0.8) rejected** — achieves "v15 support" via fragile `@swc/core@1.4.17` pinning with double SWC compilation; depends on unmaintained WASM binary
+  - **Option D (custom webpack loader) selected** — zero-dependency regex-based source transform; same transparent DX as original plugin with no per-page changes
+  - Breaking changes for Pages Router are minimal (no async API changes, React 18 backward compat confirmed)
+  - **Decision: Proceed with Next.js 15 upgrade** — benefits outweigh minimal risk
+  - Full evaluation documented in `research.md` under "Phase 3: Next.js 15+ Feature Evaluation"
   - _Requirements: 1.1, 1.2, 1.3, 5.1, 5.4_
 
-- [ ] 9.2 Execute Next.js 15 upgrade (conditional on 9.1 decision)
+- [ ] 9.2 Execute Next.js 15 upgrade
+  - Migrated from `next-superjson` SWC plugin to custom webpack loader approach:
+    - Created `withSuperJSONProps()` and `deserializeSuperJSONProps()` in `src/pages/utils/superjson-ssr.ts` (10 tests)
+    - Created `src/utils/superjson-ssr-loader.js` — regex-based webpack loader that auto-wraps `getServerSideProps` exports
+    - Added loader rule in `next.config.js` targeting `.page.{ts,tsx}` files
+    - Added centralized deserialization in `_app.page.tsx`
+    - Removed `next-superjson` dependency and `withSuperjson()` from `next.config.js`
+    - **Zero per-page file changes** — loader transparently handles all 38 pages
+  - Upgraded Next.js 14.2.35 → 15.5.12, `@next/bundle-analyzer` 14.1.3 → 15.5.12
+  - Updated peer deps in `@growi/presentation`, `@growi/remark-lsx`, `@growi/ui` to `^14 || ^15`
   - _Requirements: 5.2, 5.3_
 
-- [ ] 9.3 Enable v15-specific module optimization features
+- [x] 9.3 Enable v15-specific module optimization features
+  - Added `bundlePagesRouterDependencies: true` to `next.config.js` — bundles server-side dependencies for Pages Router, matching App Router behavior
+  - Added `serverExternalPackages: ['handsontable']` — legacy `handsontable@6.2.2` requires unavailable `@babel/polyfill`; client-only via dynamic import, kept external on server
+  - Auto-excluded packages (mongoose, mongodb, express, sharp, and 68 others) handled by Next.js built-in list
+  - `serverExternalPackages` replaces `experimental.serverComponentsExternalPackages` (now stable in v15)
+  - Production build passes with new configuration
   - _Requirements: 3.4, 5.2_
 
-- [ ] 9.4 Run full regression test suite after upgrade
+- [x] 9.4 Run full regression test suite after upgrade
+  - Type checking: Zero errors (tsgo --noEmit)
+  - Biome lint: 1,791 files checked, no errors
+  - Tests: 127 test files, 1,375 tests — all passed
+  - Production build: Passes with `bundlePagesRouterDependencies: true` + `serverExternalPackages`
   - _Requirements: 5.3, 6.2, 6.3_

+ 10 - 2
.kiro/steering/structure.md

@@ -4,5 +4,13 @@ See: `.claude/skills/monorepo-overview/SKILL.md` (auto-loaded by Claude Code)
 
 ## cc-sdd Specific Notes
 
-Currently, there are no additional instructions specific to Kiro.
-If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.
+### Server-Client Boundary Enforcement
+
+In full-stack packages (e.g., `apps/app`), server-side code (`src/server/`, models with mongoose) must NOT be imported from client components. This causes module leakage — server-only dependencies get pulled into the client bundle.
+
+- **Pattern**: If a client component needs functionality from a server module, extract the client-safe logic into a shared utility (`src/utils/` or `src/client/util/`)
+
+For apps/app-specific examples and build tooling details, see `apps/app/.claude/skills/build-optimization/SKILL.md`.
+
+---
+_Updated: 2026-03-03. apps/app details moved to `apps/app/.claude/skills/build-optimization/SKILL.md`._

+ 18 - 2
.kiro/steering/tech.md

@@ -4,5 +4,21 @@ See: `.claude/skills/tech-stack/SKILL.md` (auto-loaded by Claude Code)
 
 ## cc-sdd Specific Notes
 
-Currently, there are no additional instructions specific to Kiro.
-If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.
+### Bundler Strategy (Project-Wide Decision)
+
+GROWI uses **Webpack** (not Turbopack) across all Next.js applications. Turbopack is the default in Next.js 16, but GROWI opts out via `--webpack` flag due to custom webpack configuration that Turbopack does not support.
+
+Turbopack migration is deferred as a separate initiative. See `apps/app/.claude/skills/build-optimization/SKILL.md` for details and blockers.
+
+### Import Optimization Principles
+
+To prevent module count regression across the monorepo:
+
+- **Subpath imports over barrel imports** — e.g., `import { format } from 'date-fns/format'` instead of `from 'date-fns'`
+- **Lightweight replacements** — prefer small single-purpose packages over large multi-feature libraries
+- **Server-client boundary** — never import server-only code from client modules; extract client-safe utilities if needed
+
+For apps/app-specific build optimization details (webpack config, null-loader rules, SuperJSON architecture, module count KPI), see `apps/app/.claude/skills/build-optimization/SKILL.md`.
+
+---
+_Updated: 2026-03-03. apps/app details moved to `apps/app/.claude/skills/build-optimization/SKILL.md`._

+ 12 - 0
apps/app/.claude/skills/app-commands/SKILL.md

@@ -151,6 +151,18 @@ pnpm run version:prerelease
 pnpm run version:preminor
 ```
 
+## Build Measurement
+
+```bash
+# Measure module count KPI (cleans .next, starts next dev, triggers compilation)
+./bin/measure-chunk-stats.sh           # default port 3099
+./bin/measure-chunk-stats.sh 3001      # custom port
+```
+
+Output: `[ChunkModuleStats] initial: N, async-only: N, total: N`
+
+For details on module optimization and baselines, see the `build-optimization` skill.
+
 ## Production
 
 ```bash

+ 81 - 0
apps/app/.claude/skills/build-optimization/SKILL.md

@@ -0,0 +1,81 @@
+---
+name: build-optimization
+description: GROWI apps/app webpack configuration, module optimization, and build measurement tooling. Auto-invoked when working in apps/app.
+user-invocable: false
+---
+
+# Build Optimization (apps/app)
+
+## Next.js Version & Bundler Strategy
+
+- **Next.js 16** (`^16.0.0`) with **Webpack** bundler (not Turbopack)
+- Turbopack is the default in v16, but GROWI opts out via `--webpack` flag due to custom webpack configuration
+- Build: `next build --webpack`; Dev: Express server calls `next({ dev })` which uses webpack when `webpack()` config exists
+- React stays at `^18.2.0` — Pages Router has full React 18 support in v16
+
+## Custom Webpack Configuration
+
+| Component | File | Purpose |
+|-----------|------|---------|
+| **superjson-ssr-loader** | `src/utils/superjson-ssr-loader.js` | Auto-wraps `getServerSideProps` with SuperJSON serialization |
+| **null-loader rules** (7) | `next.config.ts` | Exclude server-only packages from client bundle |
+| **I18NextHMRPlugin** | `next.config.ts` | i18n hot module replacement in dev mode |
+| **ChunkModuleStatsPlugin** | `src/utils/next.config.utils.js` | Dev-time module count analysis (initial/async-only/total) |
+| **source-map-loader** | `next.config.ts` | Source map extraction in dev builds |
+
+### null-loader Rules
+
+7 packages excluded from client bundle: `dtrace-provider`, `mongoose`, `mathjax-full`, `i18next-fs-backend`, `bunyan`, `bunyan-format`, `core-js`
+
+**Important**: Any changes to these loaders/plugins must be verified against the module count baseline.
+
+## SuperJSON Serialization Architecture
+
+The `next-superjson` SWC plugin was replaced by a custom webpack loader:
+
+- **Build time**: `superjson-ssr-loader.js` auto-wraps `getServerSideProps` in `.page.{ts,tsx}` files with `withSuperJSONProps()`
+- **Runtime (server)**: `withSuperJSONProps()` in `src/pages/utils/superjson-ssr.ts` serializes props via superjson
+- **Runtime (client)**: `_app.page.tsx` calls `deserializeSuperJSONProps()` for centralized deserialization
+- **No per-page changes needed** — new pages automatically get superjson serialization
+- Custom serializers registered in `_app.page.tsx` (ObjectId, PageRevisionWithMeta)
+
+## Module Optimization Configuration
+
+- `bundlePagesRouterDependencies: true` — bundles server-side dependencies for Pages Router
+- `serverExternalPackages: ['handsontable']` — packages excluded from server-side bundling
+- `optimizePackageImports` — 11 `@growi/*` packages configured (expansion to third-party packages was tested and reverted — it increased dev module count)
+
+## Module Count Measurement
+
+KPI: `[ChunkModuleStats] initial: N, async-only: N, total: N`
+
+- `initial` = modules in eager (initial) chunks — the primary reduction target
+- Measured via `bin/measure-chunk-stats.sh` (cleans `.next`, starts `next dev`, triggers compilation)
+- Any changes to webpack config or import patterns should be verified against the `initial` count
+
+## Effective Module Reduction Techniques
+
+Techniques that have proven effective for reducing module count, ordered by typical impact:
+
+| Technique | When to Use |
+|-----------|-------------|
+| `next/dynamic({ ssr: true })` | Heavy rendering pipelines (markdown, code highlighting) that can be deferred to async chunks while preserving SSR |
+| `next/dynamic({ ssr: false })` | Client-only heavy components (e.g., Mermaid diagrams, interactive editors) |
+| Subpath imports | Packages with large barrel exports (e.g., `date-fns/format` instead of `date-fns`) |
+| Deep ESM imports | Packages that re-export multiple engines via barrel (e.g., `react-syntax-highlighter/dist/esm/prism-async-light`) |
+| null-loader | Server-only packages leaking into client bundle via transitive imports |
+| Lightweight replacements | Replace large libraries used for a single feature (e.g., `tinykeys` instead of `react-hotkeys`, regex instead of `validator`) |
+
+### Techniques That Did NOT Work
+
+- **Expanding `optimizePackageImports` to third-party packages** — In dev mode, this resolves individual sub-module files instead of barrel, resulting in MORE module entries. Reverted.
+- **Refactoring internal barrel exports** — Internal barrels (`states/`, `features/`) are small and well-scoped; refactoring had no measurable impact.
+
+## Turbopack Migration Path (Future)
+
+Turbopack adoption is deferred. Key blockers:
+
+- `webpack()` config not supported — null-loader rules need `turbopack.resolveAlias` migration
+- Custom loaders (superjson-ssr-loader) need Turbopack rules testing
+- I18NextHMRPlugin has no Turbopack equivalent
+- Use `--webpack` flag in both dev and build until migration is complete

+ 4 - 4
apps/app/bin/measure-chunk-stats.sh

@@ -23,12 +23,12 @@ if [ -n "$cleanup_pids" ]; then
   sleep 1
 fi
 
-# 2. Clean .next cache
-rm -rf "$(dirname "$0")/../.next"
+# 2. Clean .next dev cache (v16 uses .next/dev for isolated dev builds)
+rm -rf "$(dirname "$0")/../.next/dev"
 
-# 3. Start Next.js dev server
+# 3. Start Next.js dev server (--webpack to opt out of Turbopack default in v16)
 cd "$(dirname "$0")/.."
-npx next dev -p "$PORT" > "$LOG" 2>&1 &
+npx next dev --webpack -p "$PORT" > "$LOG" 2>&1 &
 NEXT_PID=$!
 
 # 4. Wait for server ready

+ 2 - 1
apps/app/next-env.d.ts

@@ -1,5 +1,6 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
+// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

+ 44 - 34
apps/app/next.config.js → apps/app/next.config.ts

@@ -5,17 +5,23 @@
  * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
  */
 
-const path = require('node:path');
-
-const { withSuperjson } = require('next-superjson');
-const {
+import type { NextConfig } from 'next';
+import {
   PHASE_PRODUCTION_BUILD,
   PHASE_PRODUCTION_SERVER,
-} = require('next/constants');
+} from 'next/constants';
+import path from 'node:path';
+import bundleAnalyzer from '@next/bundle-analyzer';
+
+import nextI18nConfig from './config/next-i18next.config';
+import {
+  createChunkModuleStatsPlugin,
+  listPrefixedPackages,
+} from './src/utils/next.config.utils';
 
-const getTranspilePackages = () => {
-  const { listPrefixedPackages } = require('./src/utils/next.config.utils');
+const { i18n, localePath } = nextI18nConfig;
 
+const getTranspilePackages = (): string[] => {
   const packages = [
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
@@ -69,18 +75,10 @@ const getTranspilePackages = () => {
     ]),
   ];
 
-  // const eazyLogger = require('eazy-logger');
-  // const logger = eazyLogger.Logger({
-  //   prefix: '[{green:next.config.js}] ',
-  //   useLevelPrefixes: false,
-  // });
-  // logger.info('{bold:Listing scoped packages for transpiling:}');
-  // logger.unprefixed('info', `{grey:${JSON.stringify(packages, null, 2)}}`);
-
   return packages;
 };
 
-const optimizePackageImports = [
+const optimizePackageImports: string[] = [
   '@growi/core',
   '@growi/editor',
   '@growi/pluginkit',
@@ -94,35 +92,51 @@ const optimizePackageImports = [
   '@growi/ui',
 ];
 
-module.exports = (phase) => {
-  const { i18n, localePath } = require('./config/next-i18next.config');
-
+export default (phase: string): NextConfig => {
   /** @type {import('next').NextConfig} */
-  const nextConfig = {
+  const nextConfig: NextConfig = {
     reactStrictMode: true,
     poweredByHeader: false,
     pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
     i18n,
 
+    // Bundle server-side dependencies for Pages Router (Next.js 15+)
+    // Matches App Router behavior: all deps are bundled except those in serverExternalPackages
+    // Auto-excluded: mongoose, mongodb, express, sharp, and 68 other packages with native bindings
+    bundlePagesRouterDependencies: true,
+    serverExternalPackages: [
+      'handsontable', // Legacy v6.2.2 requires @babel/polyfill which is unavailable; client-only via dynamic import
+    ],
+
     // for build
     typescript: {
       tsconfigPath: 'tsconfig.build.client.json',
     },
     transpilePackages:
       phase !== PHASE_PRODUCTION_SERVER ? getTranspilePackages() : undefined,
+    sassOptions: {
+      loadPaths: [path.resolve(__dirname, 'src')],
+    },
     experimental: {
       optimizePackageImports,
     },
 
-    /** @param config {import('next').NextConfig} */
     webpack(config, options) {
+      // Auto-wrap getServerSideProps with superjson serialization (replaces next-superjson SWC plugin)
+      if (options.isServer) {
+        config.module!.rules!.push({
+          test: /\.page\.(tsx|ts)$/,
+          use: [path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts')],
+        });
+      }
+
       if (!options.isServer) {
         // Avoid "Module not found: Can't resolve 'fs'"
         // See: https://stackoverflow.com/a/68511591
-        config.resolve.fallback.fs = false;
+        config.resolve!.fallback = { ...config.resolve!.fallback, fs: false };
 
         // exclude packages from the output bundles
-        config.module.rules.push(
+        config.module!.rules!.push(
           ...[
             /dtrace-provider/,
             /mongoose/,
@@ -142,7 +156,7 @@ module.exports = (phase) => {
 
       // extract sourcemap
       if (options.dev) {
-        config.module.rules.push({
+        config.module!.rules!.push({
           test: /.(c|m)?js$/,
           exclude: [/node_modules/, path.resolve(__dirname)],
           enforce: 'pre',
@@ -153,33 +167,29 @@ module.exports = (phase) => {
       // setup i18next-hmr
       if (!options.isServer && options.dev) {
         const { I18NextHMRPlugin } = require('i18next-hmr/webpack');
-        config.plugins.push(new I18NextHMRPlugin({ localesDir: localePath }));
+        config.plugins!.push(new I18NextHMRPlugin({ localesDir: localePath }));
       }
 
       // Log eager vs lazy module counts for dev compilation analysis
       if (!options.isServer && options.dev) {
-        const {
-          createChunkModuleStatsPlugin,
-        } = require('./src/utils/next.config.utils');
-        config.plugins.push(createChunkModuleStatsPlugin());
+        // biome-ignore lint/suspicious/noExplicitAny: webpack plugin type compatibility
+        config.plugins!.push(createChunkModuleStatsPlugin() as any);
       }
 
       return config;
     },
   };
 
-  // production server
-  // Skip withSuperjson() in production server phase because the pages directory
-  // doesn't exist in the production build and withSuperjson() tries to find it
+  // production server — skip bundle analyzer
   if (phase === PHASE_PRODUCTION_SERVER) {
     return nextConfig;
   }
 
-  const withBundleAnalyzer = require('@next/bundle-analyzer')({
+  const withBundleAnalyzer = bundleAnalyzer({
     enabled:
       phase === PHASE_PRODUCTION_BUILD &&
       (process.env.ANALYZE === 'true' || process.env.ANALYZE === '1'),
   });
 
-  return withBundleAnalyzer(withSuperjson()(nextConfig));
+  return withBundleAnalyzer(nextConfig);
 };

+ 3 - 4
apps/app/package.json

@@ -7,7 +7,7 @@
     "//// for production": "",
     "build": "run-p build:*",
     "start": "next start",
-    "build:client": "next build",
+    "build:client": "next build --webpack",
     "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
     "clean": "shx rm -rf dist transpiled",
@@ -171,10 +171,9 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.35",
+    "next": "^16.0.0",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^1.0.7",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
@@ -271,7 +270,7 @@
     "@handsontable/react": "=2.1.0",
     "@headless-tree/core": "^1.5.3",
     "@headless-tree/react": "^1.5.3",
-    "@next/bundle-analyzer": "^14.1.3",
+    "@next/bundle-analyzer": "^16.0.0",
     "@popperjs/core": "^2.11.8",
     "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",

+ 1 - 1
apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 
 
 .grw-drawer-toggler :global {

+ 1 - 1
apps/app/src/client/components/CompleteUserRegistrationForm.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/atoms/placeholders/buttons';
+@use 'styles/atoms/placeholders/buttons';
 
 :root {
   .complete-user-registration-form :global {

+ 1 - 1
apps/app/src/client/components/InstallerForm.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/atoms/placeholders/buttons';
+@use 'styles/atoms/placeholders/buttons';
 
 :root {
   .installer-form :global {

+ 1 - 1
apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 .text-skeleton-level1 {
   @include mixins.grw-skeleton-text($font-size:16px, $line-height: 40px);

+ 1 - 1
apps/app/src/client/components/LoginForm/LoginForm.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/atoms/placeholders/buttons';
+@use 'styles/atoms/placeholders/buttons';
 
 .login-form :global {
   //

+ 1 - 1
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins';
+@use 'styles/mixins';
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 .grw-min-height-sub-navigation {

+ 2 - 2
apps/app/src/client/components/Navbar/GrowiNavbarBottom.module.scss

@@ -1,5 +1,5 @@
-@use '~/styles/variables' as var;
-@use '~/styles/mixins';
+@use 'styles/variables' as var;
+@use 'styles/mixins';
 
 .grw-navbar-bottom :global {
   // apply transition

+ 1 - 1
apps/app/src/client/components/Navbar/PageEditorModeManager.module.scss

@@ -1,6 +1,6 @@
 // @mixin page-editor-mode-manager($textColor, $borderColor, $bgColorHoverAndActive, $bgColor: white) {
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 .grw-page-editor-mode-manager :global {
   .btn {

+ 1 - 1
apps/app/src/client/components/PageComment/Comment.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use './comment-inheritance';
 
 .comment-styles :global {

+ 2 - 2
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.module.scss

@@ -1,6 +1,6 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
-@use '~/styles/mixins';
+@use 'styles/variables' as var;
+@use 'styles/mixins';
 
 @include mixins.at-editing() {
   .grw-editor-navbar-bottom :global {

+ 1 - 1
apps/app/src/client/components/PageEditor/Preview.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 .page-editor-preview-body :global {
   .wiki {

+ 1 - 1
apps/app/src/client/components/PageHeader/PagePathHeader.tsx

@@ -13,7 +13,7 @@ import {
   ValidationTarget,
 } from '~/client/util/use-input-validator';
 import type { IPageForItem } from '~/interfaces/page';
-import LinkedPagePath from '~/models/linked-page-path';
+import { LinkedPagePath } from '~/models/linked-page-path';
 import { usePageSelectModalActions } from '~/states/ui/modal/page-select';
 
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';

+ 6 - 16
apps/app/src/client/components/PageList/PageListItemL.tsx

@@ -34,7 +34,7 @@ import type {
   OnPutBackedFunction,
   OnRenamedFunction,
 } from '~/interfaces/ui';
-import LinkedPagePath from '~/models/linked-page-path';
+import { LinkedPagePath } from '~/models/linked-page-path';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
@@ -230,9 +230,6 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = isDeviceLargerThanLg && isSelected ? 'active' : '';
 
-  const shouldDangerouslySetInnerHTMLForPaths =
-    elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
-
   const canRenderESSnippet =
     elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
@@ -288,26 +285,19 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (
                     {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
                     <span className="text-break">
                       <Link
-                        legacyBehavior
                         href={returnPathForURL(pageData.path, pageData._id)}
                         prefetch={false}
+                        className="page-segment"
                       >
-                        {shouldDangerouslySetInnerHTMLForPaths ? (
-                          <a
-                            className="page-segment"
-                            href={returnPathForURL(pageData.path, pageData._id)}
+                        {elasticSearchResult?.highlightedPath != null ? (
+                          <span
                             // biome-ignore lint/security/noDangerouslySetInnerHtml: highlight markup is sanitized
                             dangerouslySetInnerHTML={{
                               __html: linkedPagePathHighlightedLatter.pathName,
                             }}
-                          ></a>
+                          />
                         ) : (
-                          <a
-                            className="page-segment"
-                            href={returnPathForURL(pageData.path, pageData._id)}
-                          >
-                            {linkedPagePathHighlightedLatter.pathName}
-                          </a>
+                          linkedPagePathHighlightedLatter.pathName
                         )}
                       </Link>
                     </span>

+ 2 - 6
apps/app/src/client/components/PagePathNavSticky/CollapsedParentsDropdown.tsx

@@ -7,7 +7,7 @@ import {
   UncontrolledDropdown,
 } from 'reactstrap';
 
-import type LinkedPagePath from '~/models/linked-page-path';
+import type { LinkedPagePath } from '~/models/linked-page-path';
 
 import styles from './CollapsedParentsDropdown.module.scss';
 
@@ -47,11 +47,7 @@ export const CollapsedParentsDropdown = (props: Props): JSX.Element => {
       >
         {ancestorPathAndPathNames.map((data) => (
           <DropdownItem key={data.path}>
-            <Link href={data.path} legacyBehavior>
-              <a role="menuitem" href={data.path}>
-                {data.pathName}
-              </a>
-            </Link>
+            <Link href={data.path}>{data.pathName}</Link>
           </DropdownItem>
         ))}
       </DropdownMenu>

+ 1 - 1
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -4,7 +4,7 @@ import { pagePathUtils } from '@growi/core/dist/utils';
 import Sticky from 'react-stickynode';
 
 import { usePrintMode } from '~/client/services/use-print-mode';
-import LinkedPagePath from '~/models/linked-page-path';
+import { LinkedPagePath } from '~/models/linked-page-path';
 import { usePageControlsX } from '~/states/ui/page';
 import { useCurrentProductNavWidth, useSidebarMode } from '~/states/ui/sidebar';
 

+ 1 - 1
apps/app/src/client/components/PagePresentationModal/PagePresentationModal.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/modal';
+@use 'styles/modal';
 
 .grw-presentation-modal :global {
   /* stylelint-disable-next-line length-zero-no-unit */

+ 1 - 1
apps/app/src/client/components/PageStatusAlert.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 .grw-page-status-alert :global {

+ 2 - 2
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -1,8 +1,8 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/variables/growi-official-colors';
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '../button-styles';
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 // GROWI Logo
 .grw-app-title :global {

+ 1 - 1
apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/organisms/wiki-custom-sidebar.scss';
+@use 'styles/organisms/wiki-custom-sidebar.scss';
 
 .grw-custom-sidebar-content :global {
   .wiki {

+ 1 - 1
apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '../button-styles';
 
 .btn-create :global {

+ 1 - 1
apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '../button-styles';
 
 .btn-toggle :global {

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins' as *;
+@use 'styles/mixins' as *;
 
 .grw-recent-changes-resize-button :global {
   line-height: normal;

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
 import FormattedDistanceDate from '~/client/components/FormattedDistanceDate';
 import InfiniteScroll from '~/client/components/InfiniteScroll';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
-import LinkedPagePath from '~/models/linked-page-path';
+import { LinkedPagePath } from '~/models/linked-page-path';
 import { useSetSearchKeyword } from '~/states/search';
 import { useSWRINFxRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';

+ 2 - 2
apps/app/src/client/components/Sidebar/Sidebar.module.scss

@@ -1,6 +1,6 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
-@use '~/styles/mixins';
+@use 'styles/variables' as var;
+@use 'styles/mixins';
 
 .grw-sidebar :global {
   top: 0;

+ 2 - 2
apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.module.scss

@@ -1,6 +1,6 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
-@use '~/styles/mixins';
+@use 'styles/variables' as var;
+@use 'styles/mixins';
 @use '../button-styles';
 
 .btn-toggle-collapse :global {

+ 1 - 1
apps/app/src/client/components/Sidebar/SidebarNav/SidebarNav.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 
 .grw-sidebar-nav :global {
   width: var.$grw-sidebar-nav-width;

+ 1 - 1
apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkelton.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 .grw-default-content-skelton :global {
   .grw-skeleton-text {

+ 1 - 1
apps/app/src/client/components/Sidebar/Tag.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins' as *;
+@use 'styles/mixins' as *;
 
 .grw-tag-list-skeleton {
   height: 90px;

+ 1 - 1
apps/app/src/client/components/Sidebar/_button-styles.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use './variables' as sidebarVar;
 
 

+ 7 - 12
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -2,7 +2,7 @@ import { type FC, type JSX, memo, useCallback } from 'react';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
-import type LinkedPagePath from '~/models/linked-page-path';
+import type { LinkedPagePath } from '~/models/linked-page-path';
 
 import styles from './PagePathHierarchicalLink.module.scss';
 
@@ -83,8 +83,6 @@ export const PagePathHierarchicalLink: FC<PagePathHierarchicalLinkProps> = memo(
     const isParentRoot = linkedPagePath.parent?.isRoot;
     const isSeparatorRequired = isParentExists && !isParentRoot;
 
-    const shouldDangerouslySetInnerHTML = linkedPagePathByHtml != null;
-
     const href = encodeURI(urljoin(basePath || '/', linkedPagePath.href));
 
     return (
@@ -102,19 +100,16 @@ export const PagePathHierarchicalLink: FC<PagePathHierarchicalLinkProps> = memo(
         {isSeparatorRequired && (
           <span className={`separator ${styles.separator}`}>/</span>
         )}
-        <Link href={href} prefetch={false} legacyBehavior>
-          {shouldDangerouslySetInnerHTML ? (
-            // biome-ignore-start lint/a11y/useValidAnchor: ignore
-            <a
-              className="page-segment"
-              // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+        <Link href={href} prefetch={false} className="page-segment">
+          {linkedPagePathByHtml != null ? (
+            <span
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: highlight markup is sanitized
               dangerouslySetInnerHTML={{
                 __html: linkedPagePathByHtml.pathName,
               }}
-            ></a>
+            />
           ) : (
-            <a className="page-segment">{linkedPagePath.pathName}</a>
-            // biome-ignore-end lint/a11y/useValidAnchor: ignore
+            linkedPagePath.pathName
           )}
         </Link>
       </RootElm>

+ 1 - 1
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -2,7 +2,7 @@ import { type JSX, useMemo } from 'react';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
 
-import LinkedPagePath from '~/models/linked-page-path';
+import { LinkedPagePath } from '~/models/linked-page-path';
 
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
 import { Separator } from '.';

+ 1 - 1
apps/app/src/components/Layout/Admin.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as *;
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 $slack-work-space-name-card-background: #fff5ff;
 $slack-work-space-name-card-border: #efc1f6;

+ 1 - 1
apps/app/src/components/Layout/BasicLayout.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 
 // for react-toastify

+ 1 - 1
apps/app/src/components/Layout/SearchResultLayout.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 .on-search :global {

+ 2 - 2
apps/app/src/components/PageView/PageViewLayout.module.scss

@@ -1,6 +1,6 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/mixins';
-@use '~/styles/variables' as var;
+@use 'styles/mixins';
+@use 'styles/variables' as var;
 
 
 $subnavigation-height: 50px;

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/CodeBlock.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 .code-highlighted-title {

+ 8 - 9
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -96,15 +96,14 @@ export const NextLink = (props: Props): JSX.Element => {
   }
 
   return (
-    <Link {...rest} href={href} prefetch={false} legacyBehavior>
-      <a
-        href={href}
-        className={className}
-        {...dataAttributes}
-        onClick={onClick}
-      >
-        {children}
-      </a>
+    <Link
+      {...rest}
+      href={href}
+      prefetch={false}
+      className={className}
+      onClick={onClick}
+    >
+      {children}
     </Link>
   );
 };

+ 1 - 3
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginCard.tsx

@@ -84,9 +84,7 @@ export const PluginCard = (props: Props): JSX.Element => {
         <div className="row mb-3">
           <div className="col-9">
             <h2 className="card-title h3 border-bottom pb-2 mb-3">
-              <Link href={`${url}`} legacyBehavior>
-                {name}
-              </Link>
+              <Link href={`${url}`}>{name}</Link>
             </h2>
             <p className="card-text text-muted">{desc}</p>
           </div>

+ 1 - 3
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx

@@ -65,9 +65,7 @@ const PluginDeleteModalSubstance = ({
       </ModalHeader>
       <ModalBody>
         <div className="card well mt-2 p-2" key={id}>
-          <Link href={`${url}`} legacyBehavior>
-            {name}
-          </Link>
+          <Link href={`${url}`}>{name}</Link>
         </div>
       </ModalBody>
       <ModalFooter>

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
  .selectable-page-list :global {

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss

@@ -1,7 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
-@use '~/client/components/PageControls/button-styles';
+@use 'client/components/PageControls/button-styles';
 
 .btn-open-default-ai-assistant :global {
   @extend %btn-basis;

+ 1 - 0
apps/app/src/features/openai/server/services/openai.ts

@@ -1,3 +1,4 @@
+/// <reference types="multer" />
 import assert from 'node:assert';
 import fs from 'node:fs';
 import { Readable, Transform, Writable } from 'node:stream';

+ 1 - 1
apps/app/src/features/search/client/components/SearchMenuItem.module.scss

@@ -1,5 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 
 .search-menu-item :global {
   li {

+ 1 - 1
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 /* stylelint-disable-next-line block-no-empty */
 .search-result-content :global {

+ 18 - 9
apps/app/src/models/linked-page-path.js → apps/app/src/models/linked-page-path.ts

@@ -6,27 +6,36 @@ const { isTrashPage } = pagePathUtils;
 /**
  * Linked Array Structured PagePath Model
  */
-export default class LinkedPagePath {
-  constructor(path) {
+export class LinkedPagePath {
+  readonly path: string;
+
+  readonly pathName: string;
+
+  readonly parent?: LinkedPagePath;
+
+  constructor(path: string) {
     const pagePath = new DevidedPagePath(path);
 
     this.path = path;
     this.pathName = pagePath.latter;
-    this.isRoot = pagePath.isRoot;
     this.parent = pagePath.isRoot
-      ? null
-      : new LinkedPagePath(pagePath.former, true);
+      ? undefined
+      : new LinkedPagePath(pagePath.former);
+  }
+
+  get isRoot(): boolean {
+    return this.parent == null;
   }
 
-  get href() {
-    if (this.isRoot) {
-      return '';
+  get href(): string {
+    if (this.parent == null) {
+      return '/';
     }
 
     return pathUtils.normalizePath(`${this.parent.href}/${this.pathName}`);
   }
 
-  get isInTrash() {
+  get isInTrash(): boolean {
     return isTrashPage(this.path);
   }
 }

+ 7 - 1
apps/app/src/pages/_app.page.tsx

@@ -24,6 +24,7 @@ import { isCommonInitialProps } from './common-props';
 import { getLocaleAtServerSide } from './utils/locale';
 import { useNextjsRoutingPageRegister } from './utils/nextjs-routing-utils';
 import { registerTransformerForObjectId } from './utils/objectid-transformer';
+import { deserializeSuperJSONProps } from './utils/superjson-ssr';
 
 import '~/styles/prebuilt/vendor.css';
 import '~/styles/style-app.scss';
@@ -58,11 +59,16 @@ type GrowiAppProps = AppProps<CombinedCommonProps> & {
 
 const GrowiAppSubstance = ({
   Component,
-  pageProps,
+  pageProps: rawPageProps,
   userLocale,
 }: GrowiAppProps): JSX.Element => {
   const router = useRouter();
 
+  // Deserialize superjson-serialized props from getServerSideProps
+  const pageProps = deserializeSuperJSONProps(
+    rawPageProps,
+  ) as CombinedCommonProps;
+
   // Hydrate global atoms with server-side data
   useHydrateGlobalInitialAtoms(
     isCommonInitialProps(pageProps) ? pageProps : undefined,

+ 173 - 0
apps/app/src/pages/utils/superjson-ssr.spec.ts

@@ -0,0 +1,173 @@
+import type { GetServerSidePropsContext } from 'next';
+import superjson from 'superjson';
+import { describe, expect, it } from 'vitest';
+
+import { deserializeSuperJSONProps, withSuperJSONProps } from './superjson-ssr';
+
+// Helper to create a minimal GetServerSidePropsContext
+const createMockContext = (): GetServerSidePropsContext =>
+  ({
+    req: {} as never,
+    res: {} as never,
+    params: {},
+    query: {},
+    resolvedUrl: '/',
+    locale: 'en',
+  }) as GetServerSidePropsContext;
+
+describe('withSuperJSONProps', () => {
+  it('should serialize Date objects in props', async () => {
+    const date = new Date('2026-01-15T00:00:00.000Z');
+    const gssp = async () => ({
+      props: { createdAt: date, name: 'test' },
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+
+    expect(result).toHaveProperty('props');
+    const props = (result as { props: Record<string, unknown> }).props;
+
+    // Date should be serialized as ISO string
+    expect(props.createdAt).toBe('2026-01-15T00:00:00.000Z');
+    // Plain string should pass through
+    expect(props.name).toBe('test');
+    // _superjson metadata should be present
+    expect(props._superjson).toBeDefined();
+  });
+
+  it('should pass through redirect results unchanged', async () => {
+    const gssp = async () => ({
+      redirect: { destination: '/login', permanent: false },
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+
+    expect(result).toEqual({
+      redirect: { destination: '/login', permanent: false },
+    });
+  });
+
+  it('should pass through notFound results unchanged', async () => {
+    const gssp = async () => ({
+      notFound: true as const,
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+
+    expect(result).toEqual({ notFound: true });
+  });
+
+  it('should handle plain JSON props (no special types)', async () => {
+    const gssp = async () => ({
+      props: { count: 42, items: ['a', 'b'] },
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+
+    const props = (result as { props: Record<string, unknown> }).props;
+    expect(props.count).toBe(42);
+    expect(props.items).toEqual(['a', 'b']);
+    // No _superjson metadata when no special types
+    expect(props._superjson).toBeUndefined();
+  });
+
+  it('should handle Map and Set in props', async () => {
+    const gssp = async () => ({
+      props: { tags: new Set(['a', 'b']), lookup: new Map([['k', 'v']]) },
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+
+    const props = (result as { props: Record<string, unknown> }).props;
+    // Should have superjson metadata for Set and Map
+    expect(props._superjson).toBeDefined();
+  });
+
+  it('should handle Promise-wrapped props', async () => {
+    const gssp = async () => ({
+      props: Promise.resolve({ value: 'deferred' }),
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+
+    const props = (result as { props: Record<string, unknown> }).props;
+    expect(props.value).toBe('deferred');
+  });
+});
+
+describe('deserializeSuperJSONProps', () => {
+  it('should deserialize Date from superjson-serialized props', () => {
+    const original = {
+      createdAt: new Date('2026-01-15T00:00:00.000Z'),
+      name: 'test',
+    };
+    const { json, meta } = superjson.serialize(original);
+    const serializedProps: Record<string, unknown> = {
+      ...(json as Record<string, unknown>),
+      _superjson: meta,
+    };
+
+    const result = deserializeSuperJSONProps(serializedProps);
+
+    expect(result.createdAt).toBeInstanceOf(Date);
+    expect((result.createdAt as Date).toISOString()).toBe(
+      '2026-01-15T00:00:00.000Z',
+    );
+    expect(result.name).toBe('test');
+    // _superjson should be stripped
+    expect(result).not.toHaveProperty('_superjson');
+  });
+
+  it('should pass through plain props without _superjson metadata', () => {
+    const props = { count: 42, items: ['a', 'b'] };
+    const result = deserializeSuperJSONProps(props);
+    expect(result).toEqual({ count: 42, items: ['a', 'b'] });
+  });
+
+  it('should handle Map and Set deserialization', () => {
+    const original = {
+      tags: new Set(['a', 'b']),
+      lookup: new Map([['k', 'v']]),
+    };
+    const { json, meta } = superjson.serialize(original);
+    const serializedProps: Record<string, unknown> = {
+      ...(json as Record<string, unknown>),
+      _superjson: meta,
+    };
+
+    const result = deserializeSuperJSONProps(serializedProps);
+
+    expect(result.tags).toBeInstanceOf(Set);
+    expect(result.lookup).toBeInstanceOf(Map);
+    expect(result.tags).toEqual(new Set(['a', 'b']));
+    expect(result.lookup).toEqual(new Map([['k', 'v']]));
+  });
+
+  it('should work with withSuperJSONProps round-trip', async () => {
+    const date = new Date('2026-06-15T12:00:00.000Z');
+    const gssp = async () => ({
+      props: { createdAt: date, name: 'round-trip', count: 5 },
+    });
+
+    const wrapped = withSuperJSONProps(gssp);
+    const result = await wrapped(createMockContext());
+    const serializedProps = (result as { props: Record<string, unknown> })
+      .props;
+
+    const deserialized = deserializeSuperJSONProps(serializedProps);
+
+    expect(deserialized.createdAt).toBeInstanceOf(Date);
+    expect((deserialized.createdAt as Date).toISOString()).toBe(
+      '2026-06-15T12:00:00.000Z',
+    );
+    expect(deserialized.name).toBe('round-trip');
+    expect(deserialized.count).toBe(5);
+    expect(deserialized).not.toHaveProperty('_superjson');
+  });
+});

+ 69 - 0
apps/app/src/pages/utils/superjson-ssr.ts

@@ -0,0 +1,69 @@
+import type {
+  GetServerSideProps,
+  GetServerSidePropsContext,
+  GetServerSidePropsResult,
+} from 'next';
+import type { SuperJSONResult } from 'superjson';
+import superjson from 'superjson';
+
+/**
+ * Wraps a getServerSideProps function to serialize its return props via superjson.
+ *
+ * Handles redirect/notFound pass-through, and supports Promise-wrapped props.
+ * Adds `_superjson` metadata to props when non-JSON types (Date, Map, Set, etc.) are present.
+ */
+export function withSuperJSONProps<P extends Record<string, unknown>>(
+  gssp: GetServerSideProps<P>,
+): GetServerSideProps<P> {
+  return async (context: GetServerSidePropsContext) => {
+    const result: GetServerSidePropsResult<P> = await gssp(context);
+
+    // Pass through redirect and notFound results unchanged
+    if ('redirect' in result || 'notFound' in result) {
+      return result;
+    }
+
+    if (!('props' in result) || result.props == null) {
+      return result;
+    }
+
+    // Resolve potentially Promise-wrapped props
+    const resolvedProps = await result.props;
+
+    const { json, meta } = superjson.serialize(resolvedProps);
+
+    // Spread the serialized JSON and add _superjson metadata if present
+    const props = { ...(json as Record<string, unknown>) } as P;
+    if (meta != null) {
+      (props as Record<string, unknown>)._superjson = meta;
+    }
+
+    return { ...result, props };
+  };
+}
+
+/**
+ * Deserializes props that were serialized by withSuperJSONProps.
+ *
+ * If `_superjson` metadata is present, restores original types (Date, Map, Set, etc.).
+ * If no metadata is present, returns props unchanged.
+ */
+export function deserializeSuperJSONProps<T extends Record<string, unknown>>(
+  props: T,
+): T {
+  const metaField = (props as Record<string, unknown>)._superjson as
+    | SuperJSONResult['meta']
+    | undefined;
+
+  if (metaField == null) {
+    return props;
+  }
+
+  // Extract _superjson from the props to get the pure JSON
+  const { _superjson: _, ...json } = props as Record<string, unknown>;
+
+  return superjson.deserialize({
+    json: json as SuperJSONResult['json'],
+    meta: metaField,
+  }) as T;
+}

+ 11 - 1
apps/app/src/server/crowi/index.ts

@@ -554,8 +554,18 @@ class Crowi {
     await this.buildServer();
 
     // setup Next.js
-    this.nextApp = next({ dev });
+    // Save ts-node's .ts extension hook before Next.js prepare() destroys it.
+    // Next.js's next.config.ts transpiler registers/deregisters its own require hooks,
+    // and deregisterHook() deletes require.extensions['.ts'] instead of restoring the previous hook.
+    const savedTsHook = require.extensions['.ts'];
+    // Use webpack: true to opt out of Turbopack (default in Next.js 16+)
+    // so that all custom webpack loaders/plugins in next.config.ts remain active
+    this.nextApp = next({ dev, webpack: true });
     await this.nextApp.prepare();
+    // Restore ts-node's .ts hook if Next.js removed it
+    if (savedTsHook && !require.extensions['.ts']) {
+      require.extensions['.ts'] = savedTsHook;
+    }
 
     // setup CrowiDev
     if (dev) {

+ 3 - 1
apps/app/src/server/util/project-dir-utils.ts

@@ -3,7 +3,9 @@ import path from 'node:path';
 import process from 'node:process';
 import { isServer } from '@growi/core/dist/utils/browser-utils';
 
-const isCurrentDirRoot = isServer() && fs.existsSync('./next.config.js');
+const isCurrentDirRoot =
+  isServer() &&
+  (fs.existsSync('./next.config.ts') || fs.existsSync('./next.config.js'));
 
 export const projectRoot = isCurrentDirRoot
   ? process.cwd()

+ 1 - 1
apps/app/src/styles/atoms/_code.scss

@@ -1,4 +1,4 @@
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 /*

+ 1 - 1
apps/app/src/styles/molecules/toastr.scss

@@ -1 +1 @@
-@import '~react-toastify/scss/main';
+@import 'react-toastify/scss/main';

+ 145 - 0
apps/app/src/test/nextjs-v16-upgrade.spec.ts

@@ -0,0 +1,145 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const appDir = path.resolve(__dirname, '../..');
+
+describe('Next.js v16 Upgrade', () => {
+  const packageJson = JSON.parse(
+    readFileSync(path.join(appDir, 'package.json'), 'utf-8'),
+  );
+
+  describe('dependency versions', () => {
+    it('should have next ^16.0.0', () => {
+      expect(packageJson.dependencies.next).toBe('^16.0.0');
+    });
+
+    it('should have @next/bundle-analyzer ^16.0.0', () => {
+      expect(packageJson.devDependencies['@next/bundle-analyzer']).toBe(
+        '^16.0.0',
+      );
+    });
+
+    it('should keep react at ^18.2.0', () => {
+      expect(packageJson.dependencies.react).toBe('^18.2.0');
+    });
+
+    it('should keep react-dom at ^18.2.0', () => {
+      expect(packageJson.dependencies['react-dom']).toBe('^18.2.0');
+    });
+  });
+
+  describe('build scripts', () => {
+    it('should include --webpack flag in build:client', () => {
+      expect(packageJson.scripts['build:client']).toBe('next build --webpack');
+    });
+
+    it('should not change start script (no bundler at runtime)', () => {
+      expect(packageJson.scripts.start).toBe('next start');
+    });
+  });
+
+  describe('Sass tilde imports', () => {
+    it('should not use tilde prefix for node_modules imports in toastr.scss', () => {
+      const toastrScss = readFileSync(
+        path.join(appDir, 'src/styles/molecules/toastr.scss'),
+        'utf-8',
+      );
+      // Should not have ~react-toastify (node_modules tilde)
+      expect(toastrScss).not.toMatch(/@import\s+['"]~react-toastify/);
+      // Should have the import without tilde
+      expect(toastrScss).toMatch(
+        /@import\s+['"]react-toastify\/scss\/main['"]/,
+      );
+    });
+  });
+
+  describe('measurement script', () => {
+    it('should use --webpack flag in next dev command', () => {
+      const script = readFileSync(
+        path.join(appDir, 'bin/measure-chunk-stats.sh'),
+        'utf-8',
+      );
+      expect(script).toContain('next dev');
+      expect(script).toMatch(/next dev\b.*--webpack/);
+    });
+
+    it('should clean .next/dev directory for v16 isolated dev builds', () => {
+      const script = readFileSync(
+        path.join(appDir, 'bin/measure-chunk-stats.sh'),
+        'utf-8',
+      );
+      expect(script).toContain('.next/dev');
+    });
+  });
+
+  describe('custom server webpack option', () => {
+    it('should pass webpack: true to next() in the custom server', () => {
+      const crowiIndex = readFileSync(
+        path.join(appDir, 'src/server/crowi/index.ts'),
+        'utf-8',
+      );
+      // The programmatic API should use webpack: true to opt out of Turbopack
+      expect(crowiIndex).toMatch(/next\(\{[^}]*webpack:\s*true/);
+    });
+  });
+
+  describe('next.config.ts webpack function', () => {
+    it('should have a webpack function defined', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toMatch(/webpack\(config,\s*options\)/);
+    });
+
+    it('should have all 7 null-loader rules', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      const nullLoaderPackages = [
+        'dtrace-provider',
+        'mongoose',
+        'mathjax-full',
+        'i18next-fs-backend',
+        'bunyan',
+        'bunyan-format',
+        'core-js',
+      ];
+      for (const pkg of nullLoaderPackages) {
+        expect(config).toContain(pkg);
+      }
+      expect(config).toContain("use: 'null-loader'");
+    });
+
+    it('should have superjson-ssr-loader', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('superjson-ssr-loader');
+    });
+
+    it('should have I18NextHMRPlugin', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('I18NextHMRPlugin');
+    });
+
+    it('should have ChunkModuleStatsPlugin', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('createChunkModuleStatsPlugin');
+    });
+
+    it('should have source-map-loader', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('source-map-loader');
+    });
+
+    it('should have bundlePagesRouterDependencies enabled', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('bundlePagesRouterDependencies: true');
+    });
+
+    it('should have optimizePackageImports configured', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('optimizePackageImports');
+    });
+
+    it('should have transpilePackages configured', () => {
+      const config = readFileSync(path.join(appDir, 'next.config.ts'), 'utf-8');
+      expect(config).toContain('transpilePackages');
+    });
+  });
+});

+ 46 - 29
apps/app/src/utils/next.config.utils.js → apps/app/src/utils/next.config.utils.ts

@@ -1,26 +1,24 @@
 // workaround by https://github.com/martpie/next-transpile-modules/issues/143#issuecomment-817467144
 
-const fs = require('node:fs');
-const path = require('node:path');
+import fs from 'node:fs';
+import path from 'node:path';
 
 const nodeModulesPaths = [
   path.resolve(__dirname, '../../node_modules'),
   path.resolve(__dirname, '../../../../node_modules'),
 ];
 
-/**
- * @typedef { { ignorePackageNames: string[] } } Opts
- */
+interface Opts {
+  ignorePackageNames: string[];
+}
 
-/** @type {Opts} */
-const defaultOpts = { ignorePackageNames: [] };
+const defaultOpts: Opts = { ignorePackageNames: [] };
 
-/**
- * @param scopes {string[]}
- */
-exports.listScopedPackages = (scopes, opts = defaultOpts) => {
-  /** @type {string[]} */
-  const scopedPackages = [];
+export const listScopedPackages = (
+  scopes: string[],
+  opts: Opts = defaultOpts,
+): string[] => {
+  const scopedPackages: string[] = [];
 
   nodeModulesPaths.forEach((nodeModulesPath) => {
     fs.readdirSync(nodeModulesPath)
@@ -36,7 +34,9 @@ exports.listScopedPackages = (scopes, opts = defaultOpts) => {
               'package.json',
             );
             if (fs.existsSync(packageJsonPath)) {
-              const { name } = require(packageJsonPath);
+              const { name } = JSON.parse(
+                fs.readFileSync(packageJsonPath, 'utf-8'),
+              ) as { name: string };
               if (!opts.ignorePackageNames.includes(name)) {
                 scopedPackages.push(name);
               }
@@ -48,19 +48,25 @@ exports.listScopedPackages = (scopes, opts = defaultOpts) => {
   return scopedPackages;
 };
 
-/**
- * @param prefixes {string[]}
- */
+type WebpackCompiler = {
+  outputPath: string;
+  hooks: {
+    done: {
+      tap(name: string, callback: (stats: any) => void): void;
+    };
+  };
+};
+
 /**
  * Webpack plugin that logs eager (initial) vs lazy (async-only) module counts.
  * Attach to client-side dev builds only.
  */
-exports.createChunkModuleStatsPlugin = () => ({
-  apply(compiler) {
+export const createChunkModuleStatsPlugin = () => ({
+  apply(compiler: WebpackCompiler) {
     compiler.hooks.done.tap('ChunkModuleStatsPlugin', (stats) => {
       const { compilation } = stats;
-      const initialModuleIds = new Set();
-      const asyncModuleIds = new Set();
+      const initialModuleIds = new Set<string>();
+      const asyncModuleIds = new Set<string>();
 
       for (const chunk of compilation.chunks) {
         const target = chunk.canBeInitial() ? initialModuleIds : asyncModuleIds;
@@ -90,9 +96,13 @@ exports.createChunkModuleStatsPlugin = () => ({
           (id) => !initialModuleIds.has(id),
         );
 
-        const analyzeModuleSet = (moduleIds, title, filename) => {
-          const packageCounts = {};
-          const appModules = [];
+        const analyzeModuleSet = (
+          moduleIds: Set<string> | string[],
+          title: string,
+          filename: string,
+        ): void => {
+          const packageCounts: Record<string, number> = {};
+          const appModules: string[] = [];
           for (const rawId of moduleIds) {
             // Strip webpack loader prefixes (e.g., "source-map-loader!/path/to/file" → "/path/to/file")
             const id = rawId.includes('!')
@@ -113,7 +123,10 @@ exports.createChunkModuleStatsPlugin = () => ({
             (a, b) => b[1] - a[1],
           );
           const lines = [`# ${title}`, ''];
-          lines.push(`Total modules: ${moduleIds.length ?? moduleIds.size}`);
+          const totalCount = Array.isArray(moduleIds)
+            ? moduleIds.length
+            : moduleIds.size;
+          lines.push(`Total modules: ${totalCount}`);
           lines.push(`App modules (non-node_modules): ${appModules.length}`);
           lines.push(`node_modules packages: ${sorted.length}`);
           lines.push('');
@@ -152,9 +165,11 @@ exports.createChunkModuleStatsPlugin = () => ({
   },
 });
 
-exports.listPrefixedPackages = (prefixes, opts = defaultOpts) => {
-  /** @type {string[]} */
-  const prefixedPackages = [];
+export const listPrefixedPackages = (
+  prefixes: string[],
+  opts: Opts = defaultOpts,
+): string[] => {
+  const prefixedPackages: string[] = [];
 
   nodeModulesPaths.forEach((nodeModulesPath) => {
     fs.readdirSync(nodeModulesPath)
@@ -167,7 +182,9 @@ exports.listPrefixedPackages = (prefixes, opts = defaultOpts) => {
           'package.json',
         );
         if (fs.existsSync(packageJsonPath)) {
-          const { name } = require(packageJsonPath);
+          const { name } = JSON.parse(
+            fs.readFileSync(packageJsonPath, 'utf-8'),
+          ) as { name: string };
           if (!opts.ignorePackageNames.includes(name)) {
             prefixedPackages.push(name);
           }

+ 35 - 0
apps/app/src/utils/superjson-ssr-loader.ts

@@ -0,0 +1,35 @@
+/**
+ * Webpack loader that auto-wraps getServerSideProps with withSuperJSONProps.
+ *
+ * Replaces the `next-superjson` SWC plugin with a zero-dependency source transform.
+ * Targets `.page.{ts,tsx}` files that export `getServerSideProps`.
+ *
+ * Transform:
+ *   export const getServerSideProps: ... = async (ctx) => { ... };
+ * becomes:
+ *   import { withSuperJSONProps as __withSuperJSONProps__ } from '~/pages/utils/superjson-ssr';
+ *   const __getServerSideProps__: ... = async (ctx) => { ... };
+ *   export const getServerSideProps = __withSuperJSONProps__(__getServerSideProps__);
+ */
+function superjsonSsrLoader(source: string): string {
+  if (!/export\s+const\s+getServerSideProps\b/.test(source)) {
+    return source;
+  }
+
+  const importLine =
+    "import { withSuperJSONProps as __withSuperJSONProps__ } from '~/pages/utils/superjson-ssr';\n";
+
+  const renamed = source.replace(
+    /export\s+const\s+getServerSideProps\b/,
+    'const __getServerSideProps__',
+  );
+
+  return (
+    importLine +
+    renamed +
+    '\nexport const getServerSideProps = __withSuperJSONProps__(__getServerSideProps__);\n'
+  );
+}
+
+// biome-ignore lint/style/noDefaultExport: webpack loaders require a default export
+export default superjsonSsrLoader;

+ 1 - 0
biome.json

@@ -20,6 +20,7 @@
       "!.claude",
       "!tsconfig.base.json",
       "!apps/app/src/styles/prebuilt",
+      "!apps/app/next-env.d.ts",
       "!apps/app/tmp",
       "!apps/pdf-converter/specs",
       "!apps/slackbot-proxy/src/public/bootstrap",

+ 1 - 1
packages/presentation/package.json

@@ -61,7 +61,7 @@
     "unist-util-visit": "^5.0.0"
   },
   "peerDependencies": {
-    "next": "^14",
+    "next": "^14 || ^15",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   }

+ 1 - 1
packages/remark-lsx/package.json

@@ -56,7 +56,7 @@
     "unist-util-visit": "^5.0.0"
   },
   "peerDependencies": {
-    "next": "^14",
+    "next": "^14 || ^15",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   }

+ 1 - 1
packages/ui/package.json

@@ -47,7 +47,7 @@
     "reactstrap": "^9.2.2"
   },
   "peerDependencies": {
-    "next": "^14",
+    "next": "^14 || ^15",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   }

File diff suppressed because it is too large
+ 304 - 204
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff