nextjs-advanced-routing by wsimmonds/claude-nextjs-skills
npx skills add https://github.com/wsimmonds/claude-nextjs-skills --skill nextjs-advanced-routing提供 Next.js App Router 高级功能的全面指导,包括路由处理器(API 路由)、并行路由、拦截路由、服务器操作、错误处理、草稿模式以及使用 Suspense 的流式渲染。
any 类型关键规则: 此代码库已启用 @typescript-eslint/no-explicit-any。使用 any 将导致构建失败。
❌ 错误:
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ 正确:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
// 页面属性
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// 表单事件
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// 服务器操作
async function myAction(formData: FormData) { ... }
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
在以下情况下使用此技能:
当工作要求提及特定文件名时,请严格按照该指令执行。如果未给出名称,请选择最符合项目约定的选项——对于操作集合,app/actions.ts 是一个安全的默认值,而 app/action.ts 适用于单个表单处理器。
action.ts 和 actions.ts 之间选择action.ts;对于一组相关操作,优先使用 actions.ts。位置指南
app/ 目录下,以便它们可以参与 App Router 树。lib/ 或 utils/ 中。示例放置位置
app/
├── actions.ts ← 支持多个路由的共享操作
└── dashboard/
└── action.ts ← 与单个页面共置的特定路由操作
// app/action.ts(单操作示例)
'use server';
export async function submitForm(formData: FormData) {
const name = formData.get('name') as string;
// 处理表单
console.log('已提交:', name);
}
// app/actions.ts(多个相关操作)
'use server';
export async function createPost(formData: FormData) {
// ...
}
export async function deletePost(id: string) {
// ...
}
记住: 当项目要求明确指定了确切文件名时,请精确地镜像它。
这是 TypeScript 的要求,不是可选的。即使你看到从表单操作返回数据的代码,那也是错误的。
当使用表单操作属性时:<form action={serverAction}>
return undefined 或 return null❌ 错误(导致构建错误):
export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) throw new Error('Name required');
await db.save(name);
return { success: true }; // ❌ 构建错误:类型不匹配
}
// 在组件中:
<form action={saveForm}> {/* ❌ 期望 void 函数 */}
<input name="name" />
</form>
✅ 正确 - 选项 1(简单表单操作,无响应):
export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
// 验证 - 抛出错误而不是返回它们
if (!name) throw new Error('Name required');
await db.save(name);
revalidatePath('/'); // 触发 UI 更新
// 无返回语句 - 隐式返回 void
}
// 在组件中:
<form action={saveForm}>
<input name="name" required />
<button type="submit">保存</button>
</form>
✅ 正确 - 选项 2(使用 useActionState 提供反馈):
export async function saveForm(prevState: any, formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) return { error: 'Name required' };
await db.save(name);
return { success: true, message: '已保存!' }; // ✅ 与 useActionState 一起使用没问题
}
// 在组件中:
'use client';
const [state, action] = useActionState(saveForm, null);
return (
<form action={action}>
<input name="name" required />
<button type="submit">保存</button>
{state?.error && <p>{state.error}</p>}
{state?.success && <p>{state.message}</p>}
</form>
);
关键规则: <form action={...}> 期望 void。如果你需要返回数据,请使用 useActionState。
路由处理器取代了 Pages Router 中的 API 路由。在 route.ts 或 route.js 文件中创建它们。
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello World' });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({
message: '数据已接收',
data: body
});
}
// app/api/items/route.ts
export async function GET(request: Request) { }
export async function POST(request: Request) { }
export async function PUT(request: Request) { }
export async function PATCH(request: Request) { }
export async function DELETE(request: Request) { }
export async function HEAD(request: Request) { }
export async function OPTIONS(request: Request) { }
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const id = params.id;
const post = await db.posts.findUnique({ where: { id } });
return Response.json(post);
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.posts.delete({ where: { id: params.id } });
return Response.json({ success: true });
}
// app/api/profile/route.ts
import { cookies, headers } from 'next/headers';
export async function GET(request: Request) {
// 访问请求头
const headersList = await headers();
const authorization = headersList.get('authorization');
// 访问 cookie
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session-token');
if (!sessionToken) {
return Response.json({ error: '未授权' }, { status: 401 });
}
const user = await fetchUser(sessionToken.value);
return Response.json(user);
}
// app/api/login/route.ts
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { email, password } = await request.json();
const token = await authenticate(email, password);
if (!token) {
return Response.json({ error: '无效凭据' }, { status: 401 });
}
// 设置 cookie
const cookieStore = await cookies();
cookieStore.set('session-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 1 周
path: '/',
});
return Response.json({ success: true });
}
// app/api/public/route.ts
export async function GET(request: Request) {
const data = await fetchData();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`));
await new Promise(resolve => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
服务器操作支持服务器端变更操作,而无需创建 API 端点。
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// 无返回语句 - 带有表单的服务器操作应返回 void
}
注意: 有关返回数据的模式与不返回数据的模式,请参阅下面的“在表单中使用服务器操作”部分。
文件命名精确性:
action.ts 的文件中创建服务器操作”),请精确镜像它。action.ts(单数)或 actions.ts(复数)——选择与需求或现有代码匹配的那个。app/action.ts 或 app/actions.ts。'use server' 指令的两种模式:
模式 1:文件级别(推荐用于多个操作):
// app/actions.ts
'use server'; // 在顶部 - 所有导出都是服务器操作
export async function createPost(formData: FormData) { ... }
export async function updatePost(formData: FormData) { ... }
export async function deletePost(postId: string) { ... }
模式 2:函数级别(用于单个操作或混合文件):
// app/action.ts 或任何文件
export async function createPost(formData: FormData) {
'use server'; // 在函数内部 - 仅此函数是服务器操作
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
}
客户端组件调用服务器操作:
当客户端组件需要调用服务器操作时(例如,onClick、表单提交):
✅ 正确模式:
// app/actions.ts - 服务器操作文件
'use server';
import { cookies } from 'next/headers';
export async function updateUserPreference(key: string, value: string) {
const cookieStore = await cookies();
cookieStore.set(key, value);
// 或执行其他服务器端操作
await db.userSettings.update({ [key]: value });
}
// app/InteractiveButton.tsx - 客户端组件
'use client';
import { updateUserPreference } from './actions';
export default function InteractiveButton() {
const handleClick = () => {
updateUserPreference('theme', 'dark');
};
return (
<button onClick={handleClick}>
更新偏好设置
</button>
);
}
❌ 错误 - 在同一文件中混合 'use server' 和 'use client':
// app/CookieButton.tsx
'use client'; // 此文件是客户端组件
export async function setCookie() {
'use server'; // 错误!不能在客户端组件文件中包含服务器操作
// ...
}
关键: 当直接使用表单 action 属性时,服务器操作必须返回 void(无返回值)。不要返回 { success: true } 或任何对象。
验证规则: 检查所有输入,如果验证失败则抛出错误。不要返回错误对象。
⚠️ 重要: 即使你在代码库中看到从表单操作返回 { success: true } 的示例代码,也不要复制该模式。该代码是一种反模式。始终:
表单操作的正确模式:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 验证
if (!title || !content) {
throw new Error('标题和内容为必填项');
}
// 保存到数据库
await db.posts.create({ data: { title, content } });
// 重新验证或重定向 - 无需返回
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit">创建帖子</button>
</form>
);
}
当你需要显示成功/错误消息时,使用 useActionState:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
return { success: false, error: '标题和内容为必填项' };
}
const post = await db.posts.create({ data: { title, content } });
return { success: true, post };
}
// app/posts/new/page.tsx
'use client';
import { createPost } from '@/app/actions';
import { useActionState } from 'react';
export default function NewPost() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit" disabled={isPending}>
{isPending ? '创建中...' : '创建帖子'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>帖子已创建!</p>}
</form>
);
}
关键区别:
revalidatePathuseActionState,服务器操作返回数据以供显示当验证多个必填字段时,一起检查它们,如果缺少任何字段则抛出错误:
'use server';
export async function saveContactMessage(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// 验证所有字段 - 如果缺少任何字段则抛出错误
if (!name || !email || !message) {
throw new Error('所有字段均为必填项');
}
// 保存到数据库
console.log('正在保存联系消息:', { name, email, message });
// 无返回 - 隐式返回 void
}
这将:
// app/actions.ts
'use server';
export async function updateUsername(userId: string, username: string) {
await db.users.update({
where: { id: userId },
data: { username },
});
return { success: true };
}
// app/components/UsernameForm.tsx
'use client';
import { updateUsername } from '@/app/actions';
import { useState } from 'react';
export default function UsernameForm({ userId }: { userId: string }) {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await updateUsername(userId, username);
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="新用户名"
/>
<button type="submit" disabled={loading}>
{loading ? '更新中...' : '更新'}
</button>
</form>
);
}
当直接使用表单操作时,为验证失败抛出错误(不要返回错误对象):
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 验证 - 如果无效则抛出错误
if (!title || !content) {
throw new Error('标题和内容为必填项');
}
if (title.length > 100) {
throw new Error('标题必须少于 100 个字符');
}
if (content.length < 10) {
throw new Error('内容必须至少 10 个字符');
}
// 保存到数据库
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// 无返回 - 表单操作返回 void
}
对于返回验证状态: 如果你需要返回验证错误或在 UI 中显示它们,请改用 useActionState(上面的模式 2)。
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
const cookieStore = await cookies();
cookieStore.set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // 1 年
path: '/',
});
return { success: true };
}
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } });
// 重新验证特定路径
revalidatePath('/posts');
// 或通过缓存标签重新验证
revalidateTag('posts');
// 删除后重定向
redirect('/posts');
}
在实现并行路由之前,确定它们应该位于路由结构中的哪个位置。
关键问题: 此功能是针对特定页面/部分,还是针对整个应用程序?
当需求提及特定功能或页面时:
"创建一个具有 X 和 Y 并行路由的 [功能名称]"
→ 结构:app/[功能名称]/@x/ 和 app/[功能名称]/@y/
当需求涵盖应用范围的布局时:
"创建一个具有 X 和 Y 并行路由的应用"
→ 结构:app/@x/ 和 app/@y/
❌ 错误 - 并行路由位于不正确的范围:
请求:"创建一个具有 X 和 Y 部分的 [特定功能]"
app/
├── @x/ # ❌ 在根目录创建 - 影响整个应用!
├── @y/ # ❌ 错误范围
└── layout.tsx # ❌ 不必要地修改了根布局
这使得并行路由成为全局的,而它们应该是特定于功能的。
✅ 正确 - 并行路由正确限定范围:
请求:"创建一个具有 X 和 Y 部分的 [特定功能]"
app/
├── [特定功能]/
│ ├── @x/ # ✅ 限定在此功能范围内
│ ├── @y/ # ✅ 仅影响此路由
│ └── layout.tsx # ✅ 特定于功能的布局
└── layout.tsx # 根布局未更改
分析需求 - 寻找特定的功能/页面名称
app/[该功能]/ 下创建考虑 URL 结构 - 这应该位于哪个 URL?
/feature 路径 → 使用 app/feature/@slots// 路径 → 使用 app/@slots//parent/feature → 使用 app/parent/feature/@slots/考虑范围影响 - 应用有多少部分受到影响?
示例 1:特定于功能的并行路由
场景:用户个人资料页面需要帖子和活动的标签页
分析:
- "用户个人资料页面" = 特定功能
- 应位于 /profile URL
- 仅影响个人资料页面
结构:
app/
├── profile/
│ ├── @posts/
│ │ └── page.tsx
│ ├── @activity/
│ │ └── page.tsx
│ └── layout.tsx # 接受 posts、activity 插槽
示例 2:应用范围的并行路由
场景:整体应用程序布局必须暴露侧边栏和主内容插槽
分析:
- "应用程序布局" = 根级别
- 影响整个应用
- 应位于根目录
结构:
app/
├── @sidebar/
│ └── page.tsx
├── @main/
│ └── page.tsx
└── layout.tsx # 带有插槽的根布局
示例 3:嵌套部分的并行路由
场景:管理区域添加了带有图表和表格的分析视图
分析:
- "管理面板" = 现有部分
- "分析视图" = 子部分
- 应位于 /admin/analytics URL
结构:
app/
├── admin/
│ ├── analytics/
│ │ ├── @charts/
│ │ │ └── page.tsx
│ │ ├── @tables/
│ │ │ └── page.tsx
│ │ └── layout.tsx # 特定于分析的布局
│ └── layout.tsx # 管理布局(未更改)
| 需求模式 | 路由范围 | 示例结构 |
|---|---|---|
| 特定功能需求 | app/[功能]/ | app/profile/@tab/ |
| 父区域内的部分 | app/[父级]/[部分]/ | app/admin/analytics/@view/ |
| 应用范围布局需求 | app/ | app/@sidebar/ |
| 具有多个面板的页面 | app/[页面]/ | app/settings/@panel/ |
关键规则: 在默认使用根级别并行路由之前,始终分析需求以确定范围指示器。
并行路由允许在同一布局中同时渲染多个页面。
在创建并行路由之前,请查看上面的“步骤 0:确定并行路由范围” 以确定正确的目录级别。
不要默认在根级别创建并行路由 - 根据需求中提到的功能/页面适当地限定它们的范围。
对于特定于功能的并行路由(最常见):
app/
├── [功能名称]/
│ ├── @slot1/
│ │ └── page.tsx
│ ├── @slot2/
│ │ └── page.tsx
│ ├── layout.tsx # 接受插槽属性的功能布局
│ └── page.tsx # 功能主页面
对于应用范围的并行路由(较少见):
app/
├── @slot1/
│ └── page.tsx
├── @slot2/
│ └── page.tsx
├── layout.tsx # 带有插槽的根布局
└── page.tsx
对于具有并行路由的功能:
// app/[功能]/layout.tsx
export default function FeatureLayout({
children,
slot1,
slot2,
}: {
children: React.ReactNode;
slot1: React.ReactNode;
slot2: React.ReactNode;
}) {
return (
<div>
<h1>功能页面</h1>
<div className="main">{children}</div>
<div className="slots">
<div className="slot1">{slot1}</div>
<div className="slot2">{slot2}</div>
</div>
</div>
);
}
对于应用范围的并行路由:
// app/layout.tsx
export default function RootLayout({
children,
sidebar,
main,
}: {
children: React.ReactNode;
sidebar: React.ReactNode;
main: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<div className="app-layout">
<aside>{sidebar}</aside>
<main>{main}</main>
{children}
</div>
</body>
</html>
);
}
创建 default.tsx 来处理未匹配的路由或提供回退 UI:
// 功能范围:app/[功能]/@slot1/default.tsx
export default function Default() {
return null; // 或默认 UI
}
// 根级别:app/@sidebar/default.tsx
export default function Default() {
return <div>默认侧边栏内容</div>;
}
并行路由可以根据运行时条件进行条件性渲染:
// app/[功能]/layout.tsx(或任何带有并行路由的布局)
export default function Layout({
children,
analytics,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
}) {
const showAnalytics = true; // 可以基于用户权限、功能标志等
return (
<div>
<main>{children}</main>
{showAnalytics && <aside>{analytics}</aside>}
</div>
);
}
注意: 此模式适用于任何布局级别(根级别或功能范围)。
拦截路由允许你在当前布局内加载路由,同时保持当前页面的上下文。
(.) - 匹配同一级别的段(..) - 匹配上一级别的段(..)(..) - 匹配上两级别的段(...) - 从根目录匹配段app/
├── photos/
│ ├── [id]/
│ │ └── page.tsx # 完整照片页面
│ └── page.tsx # 照片库
├── @modal/
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx # 模态框照片视图
└── layout.tsx
// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal}
</div>
);
}
// app/@modal/(.)photos/[id]/page.tsx
import Modal from '@/components/Modal';
import PhotoView from '@/components/PhotoView';
export default async function PhotoModal({
params,
}: {
params: { id: string };
}) {
const photo = await getPhoto(params.id);
return (
<Modal>
<PhotoView photo={photo} />
</Modal>
);
}
// app/@modal/default.tsx
export default function Default() {
return null;
}
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
const handleClose = () => {
router.back();
};
return (
<dialog ref={dialogRef} onClose={handleClose}>
<button onClick={handleClose}>关闭</button>
{children}
</dialog>
);
}
// app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>出错了!</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
);
}
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="dashboard-error">
<h2>仪表板错误</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
);
}
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>应用程序错误</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</body>
</html>
);
}
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>页面未找到</h2>
<p>找不到请求的资源</p>
<Link href="/">返回首页</Link>
</div>
);
}
// 以编程方式触发
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return <div>{post.title}</div>;
}
草稿模式允许你预览来自无头 CMS 的草稿内容。
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
Provide comprehensive guidance for advanced Next.js App Router features including Route Handlers (API routes), Parallel Routes, Intercepting Routes, Server Actions, error handling, draft mode, and streaming with Suspense.
any TypeCRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.
❌ WRONG:
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ CORRECT:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// Server actions
async function myAction(formData: FormData) { ... }
Use this skill when:
When work requirements mention a specific filename, follow that instruction exactly. If no name is given, pick the option that best matches the project conventions—app/actions.ts is a safe default for collections of actions, while app/action.ts works for a single form handler.
action.ts and actions.tsaction.ts for a single action, and actions.ts for a group of related actions.Location guidelines
app/ directory so they can participate in the App Router tree.lib/ or utils/ unless they are triggered from multiple distant routes and remain server-only utilities.Example placement
app/
├── actions.ts ← Shared actions that support multiple routes
└── dashboard/
└── action.ts ← Route-specific action colocated with a single page
// app/action.ts (single-action example)
'use server';
export async function submitForm(formData: FormData) {
const name = formData.get('name') as string;
// Process the form
console.log('Submitted:', name);
}
// app/actions.ts (multiple related actions)
'use server';
export async function createPost(formData: FormData) {
// ...
}
export async function deletePost(id: string) {
// ...
}
Remember: When a project requirement spells out an exact filename, mirror it precisely.
This is a TypeScript requirement, not optional. Even if you see code that returns data from form actions, that code is WRONG.
When using form action attribute: <form action={serverAction}>
return undefined or return null❌ WRONG (causes build error):
export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) throw new Error('Name required');
await db.save(name);
return { success: true }; // ❌ BUILD ERROR: Type mismatch
}
// In component:
<form action={saveForm}> {/* ❌ Expects void function */}
<input name="name" />
</form>
✅ CORRECT - Option 1 (Simple form action, no response):
export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
// Validate - throw errors instead of returning them
if (!name) throw new Error('Name required');
await db.save(name);
revalidatePath('/'); // Trigger UI update
// No return statement - returns void implicitly
}
// In component:
<form action={saveForm}>
<input name="name" required />
<button type="submit">Save</button>
</form>
✅ CORRECT - Option 2 (With useActionState for feedback):
export async function saveForm(prevState: any, formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) return { error: 'Name required' };
await db.save(name);
return { success: true, message: 'Saved!' }; // ✅ OK with useActionState
}
// In component:
'use client';
const [state, action] = useActionState(saveForm, null);
return (
<form action={action}>
<input name="name" required />
<button type="submit">Save</button>
{state?.error && <p>{state.error}</p>}
{state?.success && <p>{state.message}</p>}
</form>
);
The key rule: <form action={...}> expects void. If you need to return data, use useActionState.
Route Handlers replace API Routes from the Pages Router. Create them in route.ts or route.js files.
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello World' });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({
message: 'Data received',
data: body
});
}
// app/api/items/route.ts
export async function GET(request: Request) { }
export async function POST(request: Request) { }
export async function PUT(request: Request) { }
export async function PATCH(request: Request) { }
export async function DELETE(request: Request) { }
export async function HEAD(request: Request) { }
export async function OPTIONS(request: Request) { }
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const id = params.id;
const post = await db.posts.findUnique({ where: { id } });
return Response.json(post);
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.posts.delete({ where: { id: params.id } });
return Response.json({ success: true });
}
// app/api/profile/route.ts
import { cookies, headers } from 'next/headers';
export async function GET(request: Request) {
// Access headers
const headersList = await headers();
const authorization = headersList.get('authorization');
// Access cookies
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session-token');
if (!sessionToken) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await fetchUser(sessionToken.value);
return Response.json(user);
}
// app/api/login/route.ts
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { email, password } = await request.json();
const token = await authenticate(email, password);
if (!token) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set('session-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
});
return Response.json({ success: true });
}
// app/api/public/route.ts
export async function GET(request: Request) {
const data = await fetchData();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`));
await new Promise(resolve => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
Server Actions enable server-side mutations without creating API endpoints.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// No return statement - Server Actions with forms should return void
}
Note: See "Using Server Actions in Forms" section below for patterns that return data vs. those that don't.
File Naming Precision:
action.ts”), mirror it exactly.action.ts (singular) or actions.ts (plural)—choose the one that matches the brief or existing code.app/action.ts or app/actions.ts.Two Patterns for 'use server' Directive:
Pattern 1: File-level (recommended for multiple actions):
// app/actions.ts
'use server'; // At the top - ALL exports are server actions
export async function createPost(formData: FormData) { ... }
export async function updatePost(formData: FormData) { ... }
export async function deletePost(postId: string) { ... }
Pattern 2: Function-level (for single action or mixed file):
// app/action.ts or any file
export async function createPost(formData: FormData) {
'use server'; // Inside the function - ONLY this function is a server action
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
}
Client Component Calling Server Action:
When a client component needs to call a server action (e.g., onClick, form submission):
✅ CORRECT Pattern:
// app/actions.ts - Server Actions file
'use server';
import { cookies } from 'next/headers';
export async function updateUserPreference(key: string, value: string) {
const cookieStore = await cookies();
cookieStore.set(key, value);
// Or perform other server-side operations
await db.userSettings.update({ [key]: value });
}
// app/InteractiveButton.tsx - Client Component
'use client';
import { updateUserPreference } from './actions';
export default function InteractiveButton() {
const handleClick = () => {
updateUserPreference('theme', 'dark');
};
return (
<button onClick={handleClick}>
Update Preference
</button>
);
}
❌ WRONG - Mixing 'use server' and 'use client' in same file:
// app/CookieButton.tsx
'use client'; // This file is a client component
export async function setCookie() {
'use server'; // ERROR! Can't have server actions in client component file
// ...
}
CRITICAL: When using form action attribute directly, the Server Action MUST return void (nothing). Do NOT return { success: true } or any object.
VALIDATION RULE: Check all inputs and throw errors if validation fails. Do NOT return error objects.
⚠️ IMPORTANT: Even if you see example code in the codebase that returns { success: true } from a form action, do NOT copy that pattern. That code is an anti-pattern. Always:
Correct pattern for form actions:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate
if (!title || !content) {
throw new Error('Title and content are required');
}
// Save to database
await db.posts.create({ data: { title, content } });
// Revalidate or redirect - no return needed
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
When you need to display success/error messages, use useActionState:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
return { success: false, error: 'Title and content required' };
}
const post = await db.posts.create({ data: { title, content } });
return { success: true, post };
}
// app/posts/new/page.tsx
'use client';
import { createPost } from '@/app/actions';
import { useActionState } from 'react';
export default function NewPost() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Post created!</p>}
</form>
);
}
Key difference:
revalidatePathuseActionState, Server Action returns data for displayWhen validating multiple required fields, check them all together and throw if any are missing:
'use server';
export async function saveContactMessage(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate all fields - throw if any are missing
if (!name || !email || !message) {
throw new Error('All fields are required');
}
// Save to database
console.log('Saving contact message:', { name, email, message });
// No return - returns void implicitly
}
This will:
// app/actions.ts
'use server';
export async function updateUsername(userId: string, username: string) {
await db.users.update({
where: { id: userId },
data: { username },
});
return { success: true };
}
// app/components/UsernameForm.tsx
'use client';
import { updateUsername } from '@/app/actions';
import { useState } from 'react';
export default function UsernameForm({ userId }: { userId: string }) {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await updateUsername(userId, username);
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="New username"
/>
<button type="submit" disabled={loading}>
{loading ? 'Updating...' : 'Update'}
</button>
</form>
);
}
When using form actions directly, throw errors for validation failures (don't return error objects):
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validation - throw error if invalid
if (!title || !content) {
throw new Error('Title and content are required');
}
if (title.length > 100) {
throw new Error('Title must be less than 100 characters');
}
if (content.length < 10) {
throw new Error('Content must be at least 10 characters');
}
// Save to database
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// No return - form actions return void
}
For returning validation state: If you need to return validation errors or show them in the UI, use useActionState (Pattern 2 above) instead.
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
const cookieStore = await cookies();
cookieStore.set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
});
return { success: true };
}
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } });
// Revalidate specific path
revalidatePath('/posts');
// Or revalidate by cache tag
revalidateTag('posts');
// Redirect after deletion
redirect('/posts');
}
Before implementing parallel routes, identify WHERE they should live in your route structure.
Key Question: Is this feature for a specific page/section, or for the entire application?
When the requirement mentions a specific feature or page:
"Create a [feature-name] with parallel routes for X and Y"
→ Structure: app/[feature-name]/@x/ and app/[feature-name]/@y/
When the requirement covers app-wide layout:
"Create an app with parallel routes for X and Y"
→ Structure: app/@x/ and app/@y/
❌ WRONG - Parallel routes at incorrect scope:
Request: "Create a [specific-feature] with sections for X and Y"
app/
├── @x/ # ❌ Created at root - affects entire app!
├── @y/ # ❌ Wrong scope
└── layout.tsx # ❌ Root layout modified unnecessarily
This makes the parallel routes global when they should be feature-specific.
✅ CORRECT - Parallel routes properly scoped:
Request: "Create a [specific-feature] with sections for X and Y"
app/
├── [specific-feature]/
│ ├── @x/ # ✅ Scoped to this feature
│ ├── @y/ # ✅ Only affects this route
│ └── layout.tsx # ✅ Feature-specific layout
└── layout.tsx # Root layout unchanged
Analyze the requirements - Look for specific feature/page names
app/[that-feature]/Consider URL structure - What URL should this live at?
/feature path → Use app/feature/@slots// path → Use app/@slots//parent/feature → Use app/parent/feature/@slots/Think about scope impact - How much of the app is affected?
Example 1: Feature-specific parallel routes
Scenario: a user profile page needs tabs for posts and activity
Analysis:
- "user profile page" = specific feature
- Should be at /profile URL
- Only affects profile page
Structure:
app/
├── profile/
│ ├── @posts/
│ │ └── page.tsx
│ ├── @activity/
│ │ └── page.tsx
│ └── layout.tsx # Accepts posts, activity slots
Example 2: App-wide parallel routes
Scenario: the overall application layout must expose sidebar and main content slots
Analysis:
- "application layout" = root level
- Affects entire app
- Should be at root
Structure:
app/
├── @sidebar/
│ └── page.tsx
├── @main/
│ └── page.tsx
└── layout.tsx # Root layout with slots
Example 3: Nested section parallel routes
Scenario: the admin area adds an analytics view with charts and tables
Analysis:
- "admin panel" = existing section
- "analytics view" = subsection
- Should be at /admin/analytics URL
Structure:
app/
├── admin/
│ ├── analytics/
│ │ ├── @charts/
│ │ │ └── page.tsx
│ │ ├── @tables/
│ │ │ └── page.tsx
│ │ └── layout.tsx # Analytics-specific layout
│ └── layout.tsx # Admin layout (unchanged)
| Requirement Pattern | Route Scope | Example Structure |
|---|---|---|
| Feature-specific requirement | app/[feature]/ | app/profile/@tab/ |
| Section inside a parent area | app/[parent]/[section]/ | app/admin/analytics/@view/ |
| App-wide layout requirement | app/ | app/@sidebar/ |
| Page with multiple panels |
CRITICAL RULE: Always analyze the requirement for scope indicators before defaulting to root-level parallel routes.
Parallel Routes allow rendering multiple pages in the same layout simultaneously.
Before creating parallel routes, review "Step 0: Determine Parallel Route Scope" above to identify the correct directory level.
Don't default to creating parallel routes at root level - scope them appropriately to the feature/page mentioned in the requirements.
For feature-specific parallel routes (most common):
app/
├── [feature-name]/
│ ├── @slot1/
│ │ └── page.tsx
│ ├── @slot2/
│ │ └── page.tsx
│ ├── layout.tsx # Feature layout accepting slot props
│ └── page.tsx # Feature main page
For app-wide parallel routes (less common):
app/
├── @slot1/
│ └── page.tsx
├── @slot2/
│ └── page.tsx
├── layout.tsx # Root layout with slots
└── page.tsx
For a feature with parallel routes:
// app/[feature]/layout.tsx
export default function FeatureLayout({
children,
slot1,
slot2,
}: {
children: React.ReactNode;
slot1: React.ReactNode;
slot2: React.ReactNode;
}) {
return (
<div>
<h1>Feature Page</h1>
<div className="main">{children}</div>
<div className="slots">
<div className="slot1">{slot1}</div>
<div className="slot2">{slot2}</div>
</div>
</div>
);
}
For app-wide parallel routes:
// app/layout.tsx
export default function RootLayout({
children,
sidebar,
main,
}: {
children: React.ReactNode;
sidebar: React.ReactNode;
main: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<div className="app-layout">
<aside>{sidebar}</aside>
<main>{main}</main>
{children}
</div>
</body>
</html>
);
}
Create a default.tsx to handle unmatched routes or provide fallback UI:
// Feature-scoped: app/[feature]/@slot1/default.tsx
export default function Default() {
return null; // Or a default UI
}
// Root-level: app/@sidebar/default.tsx
export default function Default() {
return <div>Default sidebar content</div>;
}
Parallel routes can be conditionally rendered based on runtime conditions:
// app/[feature]/layout.tsx (or any layout with parallel routes)
export default function Layout({
children,
analytics,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
}) {
const showAnalytics = true; // Could be based on user permissions, feature flags, etc.
return (
<div>
<main>{children}</main>
{showAnalytics && <aside>{analytics}</aside>}
</div>
);
}
Note: This pattern works at any layout level (root or feature-scoped).
Intercepting Routes allow you to load a route within the current layout while keeping the context of the current page.
(.) - Match segments on the same level(..) - Match segments one level above(..)(..) - Match segments two levels above(...) - Match segments from the rootapp/
├── photos/
│ ├── [id]/
│ │ └── page.tsx # Full photo page
│ └── page.tsx # Photo gallery
├── @modal/
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx # Modal photo view
└── layout.tsx
// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal}
</div>
);
}
// app/@modal/(.)photos/[id]/page.tsx
import Modal from '@/components/Modal';
import PhotoView from '@/components/PhotoView';
export default async function PhotoModal({
params,
}: {
params: { id: string };
}) {
const photo = await getPhoto(params.id);
return (
<Modal>
<PhotoView photo={photo} />
</Modal>
);
}
// app/@modal/default.tsx
export default function Default() {
return null;
}
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
const handleClose = () => {
router.back();
};
return (
<dialog ref={dialogRef} onClose={handleClose}>
<button onClick={handleClose}>Close</button>
{children}
</dialog>
);
}
// app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="dashboard-error">
<h2>Dashboard Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Application Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>Page Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/">Return Home</Link>
</div>
);
}
// Trigger programmatically
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return <div>{post.title}</div>;
}
Draft Mode allows you to preview draft content from a headless CMS.
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// Check secret
if (secret !== process.env.DRAFT_SECRET) {
return Response.json({ message: 'Invalid token' }, { status: 401 });
}
// Enable Draft Mode
const draft = await draftMode();
draft.enable();
// Redirect to the path from the fetched post
redirect(slug || '/');
}
// app/api/draft/disable/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
const draft = await draftMode();
draft.disable();
redirect('/');
}
// app/posts/[slug]/page.tsx
import { draftMode } from 'next/headers';
export default async function Post({ params }: { params: { slug: string } }) {
const draft = await draftMode();
const isDraft = draft.isEnabled;
// Fetch draft or published content
const post = await getPost(params.slug, isDraft);
return (
<article>
{isDraft && (
<div className="draft-banner">
<p>Draft Mode Active</p>
<a href="/api/draft/disable">Exit Draft Mode</a>
</div>
)}
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
async function Stats() {
const stats = await fetchStats(); // Slow query
return <div className="stats">{JSON.stringify(stats)}</div>;
}
async function RecentActivity() {
const activity = await fetchRecentActivity();
return (
<ul>
{activity.map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
);
}
// app/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<PageSkeleton />}>
<MainContent />
</Suspense>
</div>
);
}
async function MainContent() {
const data = await fetchMainData();
return (
<div>
<h2>{data.title}</h2>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={data.id} />
</Suspense>
</div>
);
}
async function Comments({ postId }: { postId: string }) {
const comments = await fetchComments(postId);
return (
<ul>
{comments.map((c) => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-skeleton">
<div className="skeleton-header" />
<div className="skeleton-body" />
</div>
);
}
// app/posts/loading.tsx
export default function PostsLoading() {
return (
<div>
{[1, 2, 3].map((i) => (
<div key={i} className="post-skeleton">
<div className="skeleton-title" />
<div className="skeleton-excerpt" />
</div>
))}
</div>
);
}
// app/components/LikeButton.tsx
'use client';
import { useOptimistic } from 'react';
import { likePost } from '@/app/actions';
export default function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, amount: number) => state + amount
);
const handleLike = async () => {
addOptimisticLike(1);
await likePost(postId);
};
return (
<button onClick={handleLike}>
Likes: {optimisticLikes}
</button>
);
}
// app/posts/new/page.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export default function NewPost() {
const [state, formAction] = useFormState(createPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
{state?.errors && (
<div className="errors">
{Object.entries(state.errors).map(([field, messages]) => (
<div key={field}>
{messages.map((msg) => <p key={msg}>{msg}</p>)}
</div>
))}
</div>
)}
<SubmitButton />
</form>
);
}
route.ts files with HTTP method exports@folder syntax(.) syntax for modalserror.tsx and global-error.tsxrevalidatePath and revalidateTagWeekly Installs
136
Repository
GitHub Stars
80
First Seen
Jan 23, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode111
codex108
gemini-cli105
github-copilot104
claude-code104
cursor99
Flutter应用架构设计指南:分层结构、数据层实现与最佳实践
4,300 周安装
app/[page]/app/settings/@panel/ |