Sawana Huang Avatar

Sawana Huang

Ship-ready Next.js Starter Checklist

启动一个面向快速交付的 Next.js 项目时,用来检查 Next.js 初始化、shadcn UI 组件边界、Loading 与 Streaming、Tailwind 语义颜色门禁、质量门禁、Agent Harness、测试机制和交付闭环的清单。

Ship-ready Next.js Starter Checklist

这份清单服务一个很具体的目标:启动一个可以快速 ship 的 Next.js 项目,同时把工程结构和 Agent Harness 一起搭好。

它的默认使用场景是:新建一个 Web / 全栈项目时,先用这页检查项目基座有没有到位。初版重点放在 Next.js 应用、shadcn UI 组件边界、Loading 与 Streaming、Tailwind 语义颜色门禁、质量门禁、Agent 协作、测试机制和交付闭环。数据库、Auth、支付、队列和后台任务这类 full-stack 模块后续可以继续补进来。

Starter checklist

勾选只保存在当前页面会话里;点击条目切换状态,右侧箭头跳到对应说明。

0/8

使用方式

把顶部 checklist 当成启动前或启动后的验收目录。每完成一项就勾选一次;勾选状态只保存在当前页面会话里,刷新页面后会清空。

初版不要追求把所有命令自动化。更重要的是让新项目有一条清楚的落地路径:先能跑起来,再接质量门禁,再让 Agent 读得懂、测得住、交付得出去。

Next.js 初始化

这一项确认项目的基础形态已经清楚。它决定后面目录、路由、组件、环境变量和部署命令有没有稳定入口。

完成标准:

  • 用官方 Next.js 初始化流程创建项目,优先选择 App Router、TypeScript、Tailwind CSS 和 src/ 目录。
  • 包管理器统一到 pnpm,并在 package.json 写入 packageManager
  • 清理默认模板内容,保留一个能说明项目方向的最小首页。
  • 明确 src/appsrc/componentssrc/libpublicdocs.agents/skills 等目录的职责。
  • 常用脚本至少包括 devbuildstartcheckfixtest

建议命令:

corepack enable
pnpm create next-app@latest
pnpm install
pnpm dev

初始结构可以先保持简单。不要为了看起来完整而提前创建大量空目录;等真实模块出现后,再按功能边界把它们放进对应位置。

shadcn UI 组件边界

这一项用于确认 shadcn primitive 与业务组件的边界。目录名不是重点:monorepo 常见的 packages/ui 与单应用常见的 src/components/ui 在这里是等价的 primitive 边界。

先把下面这段提示词交给 Agent,让它结合目标仓库讨论并确认目录映射与例外,不要直接更新代码或文档:

我想为这个 Next.js + shadcn 项目确定 UI 组件边界:primitive 目录(monorepo 通常是 packages/ui,单应用通常是 src/components/ui)只存放与业务无关、可跨功能复用的组件。

更具体的客制化组件应在对应 feature 或页面附近引用 primitive 后二次封装。封装默认继承 primitive 的 props,只处理自身需要的属性,其余通过 ...props 透传,包括 className、ref、aria-*、data-* 和事件。

先不要更新。请先检查当前目录和已有组件,和我讨论:primitive 的实际目录映射、业务封装应该放在哪里、哪些属性必须由封装控制,以及是否存在合理例外。确认后再把最终约束写入项目文档并实施。

确认后,把以下规约写入目标项目的 docs/frontend/component-system.md 或等价的长期文档:

  • primitive 目录只存放与业务无关、可跨功能复用的 UI primitive,例如 CardDialogSidebar。这里的 primitive 是项目术语,不等于只能放严格意义上的原子组件。
  • 带产品语义、业务文案、数据读取、业务状态或特定页面布局的组件放在对应 feature 或页面附近,通过 primitive 组合或封装。
  • 二次封装默认继承 ComponentProps<typeof Primitive>,只解构自身需要处理的属性,其余通过 ...props 透传,包括 classNamerefaria-*data-* 和事件。
  • 用展开顺序明确属性控制权:调用方可以覆盖的属性放在 ...props 前;封装必须守住的属性放在 ...props 后。
  • 二次封装必须提供明确价值,例如稳定布局、业务状态映射、无障碍组合或重复交互。只为单个调用点增加一次 className 时,直接组合 primitive 通常更清楚。

项目中立示例:

import type { ComponentProps } from "react";

import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";

type ResultCardProps = ComponentProps<typeof Card> & {
  isSelected?: boolean;
};

export function ResultCard({
  className,
  isSelected = false,
  ...props
}: ResultCardProps) {
  return (
    <Card
      className={cn("transition-colors", className)}
      {...props}
      data-selected={isSelected || undefined}
    />
  );
}

这个例子让调用方通过 ...props 继续传入原始 Card 的属性,但把 data-selected 放在展开之后,因此选中状态仍由 ResultCard 控制。

Loading 与 Streaming 边界

这一项用于建立两层互补的 loading state:route fallback 在页面导航期间立即给出完整首屏反馈;模块级 <Suspense> 让真实慢模块在可用 shell 中独立加载和 reveal。它们都服务真实等待,不是为了展示 skeleton 而制造异步或把每个组件拆成边界。

先把下面这段提示词交给 Agent,让它检查目标项目并讨论边界,不要机械地给所有页面和组件添加 loading:

我想为这个 Next.js App Router 项目确定 Loading 与 Streaming 边界。

先不要更新。请先检查 Next.js 版本、Cache Components 配置、App Router 路由树、已有 loading.tsx、runtime / uncached data access、真实异步数据边界,以及 notFound()、鉴权和授权的执行顺序。

逐个 page.tsx 评估导航是否存在真实等待成本;需要反馈时,确定最邻近且有意义的 route fallback,以及 application / feature 层页面 skeleton 的位置。再找出 cookies()、headers()、searchParams、params、uncached fetch 等读取发生在哪里,判断哪些慢模块确实能在页面 shell 可用时独立加载和 reveal,并把覆盖读取的最近有效 <Suspense> 放在实际等待的上方。

同步、静态、必须先完成鉴权或存在性检查的路径不要为了 skeleton 被人为拆分。受保护数据仍由 DAL 或数据访问旁的 guard 执行权威鉴权与授权,layout 里的检查只能用于非权威的乐观 UI。要求真实 redirect / 404 / HTTP status 时,如果不能在 fallback flush 前完成 guard,就不要添加同 segment 的 loading.tsx,只在 guard 通过后增加局部 Suspense。确认后,把最终约束写入 docs/frontend/loading-and-streaming.md 或等价长期文档,再实施。

确认后,按下面的稳定规约实施:

第一层:route fallback

  • 每个 App Router page.tsx 都必须被评估,但只有导航等待有真实成本时,才添加最邻近且有意义的 loading.tsx。不要把所有页面退化为一个 locale / root 级 spinner。
  • loading.tsx 保持很薄,只导入 application / feature 层拥有的页面 skeleton;不要把具体页面结构复制进 route tree。
  • fallback 接近目标页面首屏的稳定几何结构,减少空白和明显 layout shift。限制在首屏范围,不伪造用户尚未拥有的数据。
  • 每个页面 fallback 总共只暴露一个有意义的语义 loading status,例如带可访问名称的 <output>;嵌套的 skeleton primitive 不要重复播报。所有动画都必须尊重 prefers-reduced-motion

项目中立示例:

// app/blog/loading.tsx
import { BlogPageSkeleton } from "@/components/blog/blog-page-skeleton";

export default function Loading() {
  return <BlogPageSkeleton />;
}

第二层:module fallback

  • 只有真实慢模块能在周围 shell 已经可用时独立加载和 reveal,才增加显式 <Suspense>。不要给每个组件套边界,不要拆散原本内聚的数据读取,也不要制造假延迟。
  • 决定让 runtime / uncached data access 独立 streaming 时,读取必须发生在边界内渲染的异步子组件中或更深处。边界要位于实际读取和等待的上方;如果父组件先 await 数据再返回 <Suspense>,这个边界无法覆盖那次等待。未启用 Cache Components enforcement 的项目不要只为满足规则机械包边界。
  • 把具体数据访问收进小而内聚的异步子组件,并让 fallback 描述这个模块的稳定形状,而不是重复整页 skeleton。
  • loading fallback 不是失败 UI。真实异步模块还要用最近的 error.tsx 或 Error Boundary 处理失败,并提供符合产品场景的恢复或重试路径。
import { Suspense } from "react";

async function LatestPosts() {
  const response = await fetch("https://example.com/api/posts", {
    cache: "no-store",
  });

  if (!response.ok) {
    throw new Error("Failed to load latest posts");
  }

  const posts = await response.json();

  return <PostList posts={posts} />;
}

export function BlogOverview() {
  return (
    <Suspense fallback={<LatestPostsSkeleton />}>
      <LatestPosts />
    </Suspense>
  );
}

这里的 uncached data access 发生在 LatestPosts 内部,因此父层边界能覆盖它。只有 LatestPosts 确实可以独立加载和 reveal 时,才使用这种拆分。

边界位置与安全顺序

  • 同 segment 的 loading.tsx 位于该 segment 的 layout.tsx 下方,不能覆盖 layout 自身的 runtime / uncached data access。遇到这类读取,要么在 layout 内用局部 <Suspense> 包住相应异步子组件,要么把读取移入 page.tsx,让 route fallback 覆盖。
  • 受保护数据的权威身份与授权检查留在 DAL 或数据访问旁;layout 里的检查只能用于非权威的乐观 UI。对未登录、无权限和资源不存在使用项目既定且不泄露资源存在性的响应策略。
  • 同 segment 的 loading.tsx 会在 page.tsx 内的 guard 等待时先 flush fallback。要求真实 redirect / 404 / HTTP status 或资源存在性保密时,如果不能在 fallback flush 前完成 guard,就不要添加这个 loading.tsx,只在 guard 通过后增加局部 <Suspense>。响应开始 streaming 后再触发 notFound(),可能得到 200noindex 的 soft 404。
  • 先核对目标项目的 Next.js 版本和 Cache Components 配置。稳定版 Next.js 15 不使用统一 Cache Components 模型,Next.js 15 canary 的 experimental.ppr 也是不同机制。Next.js 16 只有在 cacheComponents: true 时才启用对应的 uncached-data enforcement;没有启用时,只为确实需要独立 streaming 的读取增加 <Suspense>

Skeleton 所有权

  • shadcn Skeleton primitive 放在 packages/ui(monorepo)或 src/components/ui(单应用)这类 primitive 边界中;两个目录在职责上等价。
  • 页面和模块 skeleton 由 application / feature 层组合 primitive。它们可以表达页面结构和可访问状态,但 primitive 目录不承载页面布局、业务文案或数据读取。

完成标准:

  • 已逐页记录是否需要 route fallback,以及真实等待成本和最近合理的 loading.tsx 位置。
  • 已盘点 runtime / uncached data access,并确认每个显式 <Suspense> 位于实际读取和等待的上方。
  • 没有为同步、静态、鉴权或存在性检查路径制造假异步,也没有给不能独立 reveal 的模块滥加边界。
  • route 与 module fallback 接近各自稳定几何结构;每个 fallback 总共只有一个可访问 loading status,并通过 reduced-motion 检查。
  • 每个真实异步模块都有最近的失败边界和符合产品场景的恢复或重试路径,没有把 loading fallback 当成 error UI。
  • 受保护动态详情页已验证 DAL / 数据访问旁的权威鉴权与授权,以及项目既定的未登录 / 无权限 / 不存在响应。要求真实 redirect / 404 / HTTP status 或资源存在性保密时,guard 在 fallback flush 前完成,或该 segment 不使用 loading.tsx
  • 已按目标版本的官方文档确认 Cache Components 前提,没有把 Next.js 16 的 cacheComponents: true enforcement 套用到稳定版 Next.js 15 或未启用该配置的项目。
  • 最终边界、例外与版本 / Cache Components 前提已写入项目长期文档。

Tailwind 语义颜色门禁

把下面代码保存为 eslint/rules/no-color-literals.js。它禁止 Tailwind 默认色板、任意颜色值和内联 style 颜色,组件改用 bg-backgroundtext-foreground 等语义 token。

const tailwindColorLiteralPattern =
  // eslint-disable-next-line sonarjs/regex-complexity -- Mirrors the explicit Tailwind color policy matrix.
  /(?:bg|text|border|outline|ring|shadow|fill|stroke|from|via|to|decoration|caret|accent)-\[[^\]]*(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\()/u;

const tailwindPalettePattern =
  // eslint-disable-next-line sonarjs/regex-complexity -- Mirrors the explicit Tailwind color policy matrix.
  /(?:^|[^A-Za-z0-9_-])(?:bg|text|border(?:-[trblxyse])?|outline|ring(?:-offset)?|shadow|drop-shadow|inset-shadow|inset-ring|fill|stroke|from|via|to|decoration|caret|accent|divide|placeholder)-(?:black|white|(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|mauve|olive|mist|taupe)-(?:50|100|200|300|400|500|600|700|800|900|950))(?:\/[^\s"']+)?(?:[^A-Za-z0-9_/-]|$)/u;

const colorLiteralPattern =
  /(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\()/u;

const colorStyleProperties = new Set([
  "background",
  "backgroundColor",
  "borderColor",
  "boxShadow",
  "color",
  "fill",
  "outlineColor",
  "stroke",
  "textShadow",
]);

const hasHardcodedTailwindColor = (value) =>
  tailwindColorLiteralPattern.test(value) || tailwindPalettePattern.test(value);

const getPropertyName = (node) => {
  if (node.key.type === "Identifier" && !node.computed) {
    return node.key.name;
  }

  if (node.key.type === "Literal" && typeof node.key.value === "string") {
    return node.key.value;
  }

  return null;
};

export const noColorLiteralsRule = {
  create: (context) => {
    const reportedNodes = new WeakSet();

    const reportColorLiteral = (node) => {
      if (reportedNodes.has(node)) {
        return;
      }

      reportedNodes.add(node);
      context.report({ messageId: "useSemanticColor", node });
    };

    const checkLiteral = (node) => {
      if (
        typeof node.value === "string" &&
        hasHardcodedTailwindColor(node.value)
      ) {
        reportColorLiteral(node);
      }
    };

    const checkProperty = (node) => {
      const propertyName = getPropertyName(node);
      const ownsColor =
        propertyName !== null &&
        (propertyName.startsWith("--") ||
          colorStyleProperties.has(propertyName));

      if (
        ownsColor &&
        node.value.type === "Literal" &&
        typeof node.value.value === "string" &&
        colorLiteralPattern.test(node.value.value)
      ) {
        reportColorLiteral(node.value);
      }
    };

    const checkTemplateElement = (node) => {
      if (hasHardcodedTailwindColor(node.value.raw)) {
        reportColorLiteral(node);
      }
    };

    return {
      Literal: checkLiteral,
      Property: checkProperty,
      TemplateElement: checkTemplateElement,
    };
  },
  meta: {
    messages: {
      useSemanticColor:
        "Use a semantic color token from the shared theme instead of a hardcoded color.",
    },
    schema: [],
    type: "problem",
  },
};

在现有 ESLint flat config 中启用:

import { noColorLiteralsRule } from "./eslint/rules/no-color-literals.js";

const localPlugin = {
  rules: {
    "no-color-literals": noColorLiteralsRule,
  },
};

export default [
  {
    plugins: {
      local: localPlugin,
    },
    rules: {
      "local/no-color-literals": "error",
    },
  },
];

质量检查

这一项负责把低价值返工提前拦掉。格式化、lint、import 排序、框架规则和文件复杂度都应该进入本地命令,别靠人眼反复检查。

完成标准:

  • 接入 Ultracite / Biome,统一格式化、lint 和 import 排序。
  • pnpm run check 能作为只读质量检查命令。
  • pnpm run fix 能执行安全自动修复。
  • 针对项目约束加入硬规则,例如禁止绕过本地 i18n navigation、限制 TS/TSX 文件和函数过长。
  • 把质量门禁写进 docs/tooling/quality-gates.md 或同类文档,后续修改脚本和规则时同步更新。

参考脚本:

{
  "scripts": {
    "check": "ultracite check",
    "fix": "ultracite fix"
  }
}

质量门禁的重点不在配置数量。它应该优先约束那些会真实拖慢 ship 的问题:格式漂移、错误 import、过大的组件、失控的 client boundary、缺少本地验证入口。

Agent Harness

这一项确认新开的 Agent session 能读懂项目、选择正确工具、执行任务、验证结果,并把新的稳定知识写回仓库。

完成标准:

  • 根目录有 AGENTS.md,写清楚仓库工作方式、关键规则和 docs system 入口。
  • 有项目长期知识入口,例如 docs/index.mddocs/DOCS.mddocs/<domain>/DOCS.md
  • CONTEXT.md 或等价的领域词表,定义产品、模块和交付语言。
  • .agents/skillsskills-lock.json 能记录当前项目实际使用的 skills。
  • skills 至少覆盖项目文档、拷问方案、spec、ticket 拆分、implement、TDD、code review、模块设计和架构改进。
  • MCP / 浏览器工具的使用规则有明确入口,UI 变更需要真实浏览器验证。

建议优先安装的 skills:

npx skills add https://github.com/multicul-silver-wolf/agent-docs-system-skill --skill project-docs-system
npx skills add https://github.com/mattpocock/skills --skill ask-matt
npx skills add https://github.com/mattpocock/skills --skill grilling
npx skills add https://github.com/mattpocock/skills --skill domain-modeling
npx skills add https://github.com/mattpocock/skills --skill grill-with-docs
npx skills add https://github.com/mattpocock/skills --skill codebase-design
npx skills add https://github.com/mattpocock/skills --skill to-spec
npx skills add https://github.com/mattpocock/skills --skill to-tickets
npx skills add https://github.com/mattpocock/skills --skill implement
npx skills add https://github.com/mattpocock/skills --skill tdd
npx skills add https://github.com/mattpocock/skills --skill code-review
npx skills add https://github.com/mattpocock/skills --skill improve-codebase-architecture

如果仓库要完整采用 Matt 的 issue tracker workflow,再安装并运行 setup-matt-pocock-skills。已经用 project-docs-system 的仓库要先确认文档入口,避免 docs/agents/*docs/ 分层规则互相抢入口。

Agent Harness 的关键是让上下文、skills、tools 和验证路径组合起来。单独装一个 skill 没有多大意义;它需要和仓库文档、质量命令、测试命令、浏览器验证和 Git 交付一起工作。

Test 机制

这一项确认项目有最小但有效的测试入口。初始项目不需要把测试覆盖率做满,但必须有能保护核心行为的 tracer bullet。

完成标准:

  • 接入 Vitest,并提供 pnpm test
  • 为纯逻辑、路由辅助、i18n 配置、内容 loader、核心组件契约补最小测试。
  • 测试文件靠近被测代码,或者放在清楚的 __tests__ 边界里。
  • UI 变更除了单测,还要打开浏览器检查关键路由。
  • 需要性能或 SEO 的页面,保留 Lighthouse / PageSpeed / 浏览器控制台检查证据。

参考脚本:

{
  "scripts": {
    "test": "vitest run"
  }
}

测试的重点是守住能影响 ship 的核心行为。比如多语言路由、导航生成、内容解析、按钮 primitive、section copy contract,这些比给每个样式细节写断言更有价值。

Git / Vercel / CI

这一项确认项目能从本地改动进入线上,并且每次交付都有可追踪的验证结果。

完成标准:

  • 初始化 Git,明确默认分支和提交信息风格。
  • .gitignore 排除本地缓存、构建产物、密钥和临时文件。
  • GitHub 仓库创建完成,远端指向清楚。
  • GitHub Actions 至少运行 pnpm installpnpm run checkpnpm testpnpm run build
  • Vercel 项目已经连接仓库,环境变量以 Vercel 为事实来源。
  • 上线后检查关键路由、浏览器控制台、基础性能和 SEO。

参考 CI 形状:

name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm run check
      - run: pnpm test
      - run: pnpm run build

Vercel 相关环境变量不要散落在多个本地文件里。能通过 Vercel 管理的,就让 Vercel 成为事实来源;本地只保留开发必要的占位和说明。

维护方式

这页和 Agent Skills 收集 一样,应该长期更新。它不需要一次写成最终版。

维护规则:

  • 新项目里出现了稳定、可复用的初始化动作,就补进对应分组。
  • 某个工具被替换,例如 Biome、测试框架、部署平台或 MCP 入口变化,就同步更新完成标准和命令。
  • 如果 full-stack 能力开始稳定,例如数据库、Auth、支付、队列、后台任务,就新增分组,不要硬塞进现有分组。
  • 每次扩展 checklist,都要保留顶部 checklist 和正文解释的一一对应关系。
  • 如果某项已经变成自动化脚本,正文要写清楚脚本做了什么,以及人还需要检查什么。

相关页面