跳到主要内容

在 Cursor 中的 Remix 框架开发:完整指南

Remix development in Cursor

Remix 是一个基于 React Router 构建的全栈 Web 框架,强调渐进增强、Web 标准和服务端渲染。Remix v2 引入了扁平路由、改进的错误处理和简化的数据加载模型。Cursor 凭借其 VS Code 兼容的基础架构和集成的 AI 助手,是 Remix 开发的理想编辑器——它能理解 .tsx 路由文件,为 loader 返回类型提供 IntelliSense,并能从自然语言提示生成完整的路由模块。

本指南涵盖在 Cursor 中完整的 Remix 开发工作流:项目设置、路由约定、loader 和 action、嵌套路由、错误边界以及 AI 辅助路由生成。

在 Cursor 中设置 Remix 开发​

前置条件​

Remix v2 需要 Node.js 18 或更新版本(推荐使用 Node 20 LTS)。验证你的安装:

node --version
npm --version
提示

Cursor 的集成终端(Ctrl+`)与资源管理器面板同步,因此你可以搭建项目并立即编辑生成的文件,无需离开代码。

安装 Remix 扩展​

Cursor 继承了 VS Code 扩展兼容性。对于 Remix 开发,从扩展视图(Ctrl+Shift+X)安装以下扩展:

  1. ES7 React/Redux/GraphQL/React-Native snippets -- 适用于 React 组件搭建。
  2. Tailwind CSS IntelliSense -- 如果你使用 Tailwind 进行样式开发。
  3. Prisma -- 如果你的 Remix 应用使用 Prisma 处理数据。

Cursor 内置的 TypeScript 支持开箱即用地处理 Remix 类型推断,包括 MetaFunction、LoaderFunction 和 ActionFunction 类型。

注意

不要安装已弃用的 "Remix" 语法高亮扩展。Remix 文件是标准的 .tsx/.ts 文件,Cursor 已原生支持。已弃用的扩展会与 Cursor 的 TypeScript 语言服务器冲突。

搭建 Remix 项目​

使用官方 Remix CLI 创建新项目:

npx create-remix@latest my-remix-app --template remix-run/default --no-install --no-git

cd my-remix-app
npm install

在 Cursor 中打开项目:

cursor .

默认项目结构:

my-remix-app/
├── app/
│ ├── components/
│ ├── routes/
│ │ └── _index.tsx
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ ├── tailwind.css
│ └── entry.client.tsx
├── public/
├── vite.config.ts
├── tsconfig.json
└── package.json
提示

让 Cursor 搭建一个完整项目:"创建一个支持 TypeScript、Tailwind CSS 和 Prisma 的 Remix v2 项目。设置 vite 配置和带导航栏的根布局。" Cursor 会生成所有配置文件和初始路由结构。

路由约定​

Remix v2 使用扁平文件路由。app/routes/ 目录中的路由文件直接映射到 URL 路径。

基本路由​

文件URL 路径
app/routes/_index.tsx/
app/routes/about.tsx/about
app/routes/blog.tsx/blog
app/routes/blog.$slug.tsx/blog/:slug

使用扁平路由的嵌套路由​

嵌套布局使用点表示法。名为 app/routes/blog._index.tsx 的文件渲染在 app/routes/blog.tsx 布局内:

app/routes/
├── _index.tsx → /
├── about.tsx → /about
├── blog.tsx → /blog (layout)
├── blog._index.tsx → /blog (index, renders in blog layout)
├── blog.$slug.tsx → /blog/:slug (renders in blog layout)
├── blog.new.tsx → /blog/new
└── blog.edit.$id.tsx → /blog/edit/:id

无路径布局路由​

使用下划线前缀创建不添加 URL 段的布局路由:

app/routes/
├── _auth.tsx → Layout wrapper (no URL segment)
├── _auth.login.tsx → /login (renders inside _auth layout)
├── _auth.register.tsx → /register (renders inside _auth layout)
└── _auth.forgot-password.tsx → /forgot-password

使用括号的路由分组​

使用括号对路由进行分组而不影响 URL:

app/routes/
├── (marketing)/
│ ├── _index.tsx → /
│ ├── pricing.tsx → /pricing
│ └── features.tsx → /features
├── (app)/
│ ├── dashboard.tsx → /dashboard
│ └── settings.tsx → /settings
提示

问 Cursor:"为 Remix 应用生成扁平路由文件结构,包含公共营销页面(首页、定价、关于)、认证应用部分(仪表盘、设置、个人资料)以及带个人文章页面的博客。" Cursor 会创建所有必要文件并使用正确的命名约定。

Loader 和 Action​

Loader 在页面渲染之前在服务端获取数据。Action 处理表单提交和数据变更。两者都在服务端运行,让你可以直接访问数据库和 API。

定义 Loader​

// app/routes/blog.$slug.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getPost } from '~/models/post.server';

export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) {
return [{ title: 'Post not found' }];
}
return [
{ title: data.post.title },
{ name: 'description', content: data.post.excerpt },
];
};

export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);

if (!post) {
throw new Response('Not Found', { status: 404 });
}

return json({ post });
}

export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();

return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
注意

始终用 typeof loader 为 useLoaderData 添加类型。这为你的组件中的数据结构提供完整的 IntelliSense。如果没有泛型,Cursor 无法为 post.title、post.content 等提供准确的自动补全。

定义 Action​

Action 处理非 GET 请求(POST、PUT、DELETE、PATCH)。它们与 HTML 表单天然配对:

// app/routes/blog.$slug.tsx (continued)
import { Form, redirect, useActionData } from '@remix-run/react';
import type { ActionFunctionArgs } from '@remix-run/node';
import { createComment } from '~/models/comment.server';

interface ActionData {
errors?: {
name?: string;
body?: string;
};
}

export async function action({ params, request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get('name');
const body = formData.get('body');

const errors: ActionData['errors'] = {};

if (!name || typeof name !== 'string' || name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters';
}

if (!body || typeof body !== 'string' || body.trim().length < 5) {
errors.body = 'Comment must be at least 5 characters';
}

if (Object.keys(errors).length > 0) {
return json<ActionData>({ errors }, { status: 400 });
}

await createComment({
postId: params.slug!,
authorName: name as string,
body: body as string,
});

return redirect(`/blog/${params.slug}`);
}

export default function BlogPost() {
const actionData = useActionData<typeof action>();

return (
<article>
{/* Post content */}
<section>
<h2>Leave a comment</h2>
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input type="text" name="name" id="name" />
{actionData?.errors?.name && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
<div>
<label htmlFor="body">Comment</label>
<textarea name="body" id="body" rows={4} />
{actionData?.errors?.body && (
<p className="error">{actionData.errors.body}</p>
)}
</div>
<button type="submit">Submit</button>
</Form>
</section>
</article>
);
}
提示

问 Cursor:"创建一个 Remix 路由,loader 从我的 Prisma 数据库获取产品列表,action 处理带查询和类别过滤的搜索表单。" Cursor 会生成 loader、action 和带正确验证的表单 UI。

每个路由的多个 Action​

使用 intent 字段在单个路由中处理多个 action:

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get('intent');

switch (intent) {
case 'create':
return handleCreate(formData);
case 'update':
return handleUpdate(formData);
case 'delete':
return handleDelete(formData);
default:
return json({ error: 'Unknown intent' }, { status: 400 });
}
}
<Form method="post">
<input type="hidden" name="intent" value="create" />
<button type="submit">Create</button>
</Form>

<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={item.id} />
<button type="submit">Delete</button>
</Form>

嵌套路由​

嵌套路由允许你从父布局和子内容组合页面。父路由渲染一个 <Outlet />,子路由在其中显示。

使用 Outlet 创建布局​

// app/routes/blog.tsx
import { Outlet, NavLink, useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { getCategories } from '~/models/category.server';

export async function loader({}: LoaderFunctionArgs) {
const categories = await getCategories();
return json({ categories });
}

export default function BlogLayout() {
const { categories } = useLoaderData<typeof loader>();

return (
<div className="blog-layout">
<nav className="sidebar">
<h2>Categories</h2>
<ul>
{categories.map((cat) => (
<li key={cat.id}>
<NavLink to={`/blog/category/${cat.slug}`}>
{cat.name}
</NavLink>
</li>
))}
</ul>
</nav>
<main className="content">
<Outlet />
</main>
</div>
);
}
注意

如果你的嵌套路由渲染出空白页,请检查父布局是否包含 <Outlet /> 组件。没有它,子路由内容无处渲染。

嵌套数据加载​

嵌套层级中的每个路由并行运行其 loader。这意味着父路由和子路由的 loader 同时执行,提升感知性能:

// app/routes/blog.$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
return json({ post });
}

// app/routes/blog.tsx (parent)
export async function loader() {
const categories = await getCategories();
return json({ categories });
}

使用 useNavigation 的乐观 UI​

跟踪导航状态以在提交期间显示乐观 UI:

import { useNavigation, Form } from '@remix-run/react';

export default function CreatePost() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';

return (
<Form method="post">
<input name="title" placeholder="Title" disabled={isSubmitting} />
<textarea name="content" placeholder="Content" disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</Form>
);
}
提示

问 Cursor:"为我的 Remix 表单添加乐观 UI,使提交按钮显示加载状态,表单字段在提交期间被禁用。" Cursor 会连接 useNavigation 并正确处理禁用状态。

错误边界​

Remix v2 引入了每路由错误边界。如果路由抛出错误,只有该路由的错误边界会渲染——页面其余部分保持不变。

路由级错误边界​

// app/routes/blog.$slug.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';

export function ErrorBoundary() {
const error = useRouteError();

if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>Post not found</h1>
<p>The blog post you are looking for does not exist.</p>
<a href="/blog">Back to blog</a>
</div>
);
}
return (
<div>
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
</div>
);
}

if (error instanceof Error) {
return (
<div>
<h1>Unexpected Error</h1>
<p>{error.message}</p>
</div>
);
}

return <h1>Unknown error occurred</h1>;
}
注意

错误边界必须作为命名导出(export function ErrorBoundary)。默认导出仅用于路由组件。如果你将其作为默认导出,Remix 不会将其识别为错误边界。

根错误边界​

根布局(app/root.tsx)也应导出一个 ErrorBoundary,用于捕获冒泡上来的错误:

// app/root.tsx
import { Links, Meta, Outlet, Scripts, useRouteError } from '@remix-run/react';

export function ErrorBoundary() {
const error = useRouteError();

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Application Error</title>
<Links />
</head>
<body>
<main>
<h1>Something went wrong</h1>
<p>Please try refreshing the page.</p>
</main>
<Scripts />
</body>
</html>
);
}

在 Loader 中处理错误​

export async function loader({ params }: LoaderFunctionArgs) {
const user = await getUser(params.id);

if (!user) {
throw new Response('User not found', {
status: 404,
statusText: 'Not Found',
});
}

const posts = await getUserPosts(user.id);

if (posts.length === 0) {
throw new Response('No posts found', {
status: 404,
});
}

return json({ user, posts });
}
提示

问 Cursor:"为我的 Remix 路由添加全面的错误处理。包含资源未找到时的 404 响应、数据库错误的 500 响应,以及针对每种情况有适当 UI 的路由级错误边界。" Cursor 会生成 loader 错误逻辑和错误边界组件。

在 Cursor 中 AI 辅助路由生成​

Cursor 的 AI 助手可以从单个提示生成完整的路由模块——包括 loader、action、组件、meta 函数和错误边界。

从提示生成完整路由​

打开 Cursor Chat(Ctrl+L)并描述路由:

Create a Remix v2 route at app/routes/tasks.$id.tsx that:
1. Loads a single task from Prisma by ID in the loader
2. Has an action to update the task (title, description, status)
3. Shows a form pre-filled with the task data
4. Validates inputs: title (min 3 chars), description (min 10 chars)
5. Includes a meta function with the task title
6. Has an error boundary for 404 (task not found)
7. Redirects to /tasks after successful update

Cursor 会生成一个包含 loader、action、表单组件、meta 函数和错误边界的完整路由文件。生成的代码将使用 Remix v2 API(LoaderFunctionArgs、ActionFunctionArgs),并包含正确的表单验证及内联错误消息、预选当前任务值的状态下拉框,以及成功后重定向到 /tasks。

提示

让 Cursor 生成路由时,始终指定 Remix 版本("v2")和扁平路由文件路径。这确保 Cursor 使用正确的 API 界面(例如 LoaderFunctionArgs 而非已弃用的带 params 的 LoaderFunction)。

AI 辅助资源路由​

资源路由返回数据而不渲染 UI——非常适合 API 端点:

Create a Remix resource route at app/routes/api.health.tsx that returns
JSON with status "ok", server uptime, and current timestamp. Support GET only.
// app/routes/api.health.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';

export async function loader({}: LoaderFunctionArgs) {
return json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
}

测试和部署​

部署到 Vercel​

安装 Vercel 适配器:

npm install @remix-run/vercel

配置 vite.config.ts:

import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
});
注意

部署前,使用 npm run build && npm start 在本地测试生产构建。在开发模式下工作的服务端代码在生产环境中可能表现不同——特别是依赖 process.env 变量或部署平台可能不支持的 Node.js 内置模块的代码。

总结​

Remix v2 与 Cursor 的结合创造了强大的全栈开发环境。框架对 Web 标准、渐进增强和嵌套路由的强调干净地映射到文件系统,而 Cursor 的 AI 助手可以从自然语言描述搭建整个路由——包括 loader、action、错误边界和表单。从扁平路由约定开始,用类型化的 loader 和验证过的 action 构建数据层,添加带 <Outlet /> 的嵌套布局,让 Cursor 生成重复的样板代码,这样你就可以专注于应用程序的业务逻辑。