Epic Stack 表单实战指南:基于 Conform 与 Zod 的渐进增强表单体系

发布时间:2026/9/17 12:03:46
Epic Stack 表单实战指南:基于 Conform 与 Zod 的渐进增强表单体系
Epic Stack 表单实战指南基于 Conform 与 Zod 的渐进增强表单体系【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack导读本指南聚焦 Epic Stack 项目中表单开发的核心方法论与实践模式以Conform作为表单状态与提交管理框架、以Zod作为唯一的校验规则声明源构建显式校验、快速失败的表单体系。文中将覆盖从 Schema 定义、客户端/服务端双端校验、内置字段组件、异步校验、蜜罐防垃圾、文件上传、数组字段集到提交状态按钮的完整链路。阅读完成后你将能够按照 Epic Stack 的既有约定在 app/routes 中独立落地可访问、可渐进增强、可维护的生产级表单并理解每一条规则在仓库源码中的落点。何时使用这套技能在 Epic Stack 应用中凡是涉及用户输入交互的场景都应遵循本指南包括但不限于创建任意类型的表单登录、注册、笔记编辑、设置修改等使用 Zod 实现结构化、类型安全的表单校验借助 Conform 实现渐进增强无 JavaScript 也能提交、有 JavaScript 时获得即时反馈处理文件上传如图片为公开表单加入蜜罐Honeypot字段防垃圾提交处理字段级与表单级错误展示构建复杂表单字段集 fieldset、数组字段如多图片上传。仓库中可直接对照的参考实现登录页 app/routes/_auth/login.tsx、注册页 app/routes/_auth/signup.tsx、注册引导页 app/routes/_auth/onboarding/index.tsx、笔记编辑含图片上传app/routes/users/$username/notes/shared/note-editor.tsx。校验哲学显式优于隐式快速失败Epic Stack 遵循 Epic Web 的表单设计原则其核心有两条显式优于隐式Explicit is better than implicit所有校验规则都必须在 Zod Schema 中清晰可见不能散落在业务逻辑里。错误消息应当具体且可操作直接告诉用户哪里错了、怎么改。仓库将可复用的校验 Schema 集中放在 app/utils/user-validation.ts例如EmailSchema、PasswordSchema等任何表单通过组合即可获得一致的、显式的校验。设计成快速失败Design to fail fast and early输入要尽可能早地被校验——理想情况下在提交前的客户端完成服务端则必须始终校验。错误应立即返回避免用户反复试错。在 React Router 的action中拿到formData后第一件事就是parseWithZod校验失败立即以 4xx 状态码返回。显式校验示例// ✅ Good - 显式校验带清晰错误消息 const SignupSchema z.object({ email: z .string({ required_error: Email is required }) .email({ message: Please enter a valid email address }) .min(3, { message: Email must be at least 3 characters }) .max(100, { message: Email must be less than 100 characters }) .transform((val) val.toLowerCase().trim()), password: z .string({ required_error: Password is required }) .min(6, { message: Password must be at least 6 characters }) .max(72, { message: Password must be less than 72 characters }), }) // ❌ Avoid - 隐式校验错误消息缺失或过于笼统 const SignupSchema z.object({ email: z.string().email(), // 没有清晰的错误消息 password: z.string().min(6), // 通用错误 })仓库中 app/utils/user-validation.ts 的EmailSchema正是这种显式写法的范本它不仅链式定义了required_error、email、min、max的具体消息还通过.transform((value) value.toLowerCase())统一小写化存储。快速失败示例// ✅ Good - 立即校验立即返回具体错误 export async function action({ request }: Route.ActionArgs) { const formData await request.formData() // 立即校验 - fail fast const submission await parseWithZod(formData, { schema: SignupSchema, }) // 校验失败立即返回 if (submission.status ! success) { return data( { result: submission.reply() }, { status: 400 }, // 明确的错误状态码 ) } // 只有校验通过才继续 const { email, password } submission.value // ... 继续注册流程 } // ❌ Avoid - 延迟或散落的校验 export async function action({ request }: Route.ActionArgs) { const formData await request.formData() const email formData.get(email) const password formData.get(password) // 校验逻辑散落在函数各处 if (!email) { // 通用错误不具体 return json({ error: Invalid }, { status: 400 }) } // ... 更多散落校验 }需要说明的是仓库实际写法中状态码判断会区分error与idle见下文异步校验部分这里的status: 400是简化示意完整形态参考 app/routes/_auth/signup.tsx。与 Conform 的基础集成Epic Stack 使用Conform处理表单的提交与状态管理其优势在于渐进增强纯 HTML 表单在无 JavaScript 时依然可用有 JS 时自动获得客户端即时校验与增强交互。基础装配流程分为三块定义 Zod Schema校验规则的唯一事实来源useForm初始化表单传入id、constraint由getZodConstraint从 Schema 推导原生 HTML5 校验属性、lastResult接收服务端action返回的结果以回显错误、onValidate客户端校验回调、shouldRevalidate渲染Form用getFormProps(form)展开表单属性字段用getInputProps(field, ...)展开。import { getFormProps, useForm } from conform-to/react import { getZodConstraint, parseWithZod } from conform-to/zod import { z } from zod import { Form } from react-router const SignupSchema z.object({ email: z.string().email(), password: z.string().min(6), }) export default function SignupRoute({ actionData }: Route.ComponentProps) { const [form, fields] useForm({ id: signup-form, constraint: getZodConstraint(SignupSchema), lastResult: actionData?.result, onValidate({ formData }) { return parseWithZod(formData, { schema: SignupSchema }) }, shouldRevalidate: onBlur, }) return ( Form methodPOST {...getFormProps(form)} {/* Form fields */} /Form ) }几个关键参数在仓库中的实际取值shouldRevalidate: onBlur失焦时重新校验字段改善交互体验登录、注册、笔记编辑器均如此配置lastResult: actionData?.result这是服务端错误回显的桥梁缺失它则服务端返回的错误无法显示参考 app/routes/_auth/login.tsxdefaultValue编辑场景预填初始值如笔记编辑器defaultValue: { ...note, images: note?.images ?? [{}] }app/routes/users/$username/notes/shared/note-editor.tsx。与 Zod 的深度集成Conform 通过conform-to/zod与 Zod 无缝衔接支持 schema 组合、跨字段校验与异步校验。Schema 定义与跨字段校验import { z } from zod const SignupSchema z .object({ email: z.string().email(Invalid email), password: z.string().min(6, Password must be at least 6 characters), confirmPassword: z.string(), }) .superRefine(({ confirmPassword, password }, ctx) { if (confirmPassword ! password) { ctx.addIssue({ path: [confirmPassword], code: custom, message: Passwords must match, }) } })superRefine通过ctx.addIssue把错误精确挂到confirmPassword字段上。仓库中这一模式被提炼为可复用的 PasswordAndConfirmPasswordSchema密码一致性校验在多处表单中直接引用。Action 中的快速失败校验export async function action({ request }: Route.ActionArgs) { const formData await request.formData() // 立即校验 - 显式且快速失败 const submission await parseWithZod(formData, { schema: SignupSchema, }) // 校验失败立即返回显式错误 if (submission.status ! success) { return data( { result: submission.reply() }, { status: submission.status error ? 400 : 200 }, ) } // 只有校验通过才继续 - submission.value 是类型安全的 const { email, password } submission.value // ... 使用校验后的数据继续处理 }submission.reply()生成可被useForm的lastResult消费的序列化结果错误会通过fields.xxx.errors/form.errors自动回显。状态码约定校验错误返回 400空提交idle返回 200该写法在 app/routes/_auth/signup.tsx 与 app/routes/_auth/login.tsx 完全一致。异步校验数据库查询等当校验需要查询数据库如检查邮箱是否已被注册时使用superRefine的异步版本并务必在parseWithZod中开启async: trueexport async function action({ request }: Route.ActionArgs) { const formData await request.formData() const submission await parseWithZod(formData, { schema: SignupSchema.superRefine(async (data, ctx) { const existingUser await prisma.user.findUnique({ where: { email: data.email }, select: { id: true }, }) if (existingUser) { ctx.addIssue({ path: [email], code: z.ZodIssueCode.custom, message: A user already exists with this email, }) } }), async: true, // 重要开启异步校验 }) if (submission.status ! success) { return data( { result: submission.reply() }, { status: submission.status error ? 400 : 200 }, ) } // ... }这正是 app/routes/_auth/signup.tsx 的真实实现注册前查询prisma.user判断邮箱是否已存在。此外 app/routes/_auth/login.tsx 展示了一个进阶形态——schema: (intent) LoginFormSchema.transform(...)把login(data)的凭证校验也纳入 Zod 校验管线失败时通过ctx.addIssue添加Invalid username or password并return z.NEVER中断。异步校验不仅能校验格式还能承载业务级校验凭据是否正确、笔记是否存在这正是显式优于隐式的延伸。内置字段组件Epic Stack 在 app/components/forms.tsx 中提供了一套预先封装好的字段组件统一处理label关联、aria-invalid/aria-describedby无障碍属性与错误区占位。Field单行文本输入支持任意input类型TextareaField多行文本CheckboxField复选框基于 app/components/ui/checkbox.tsx通过useInputControl与 Conform 状态桥接OTPField一次性验证码输入6 位数字字母基于 app/components/ui/input-otp.tsx用于验证码/2FA 场景ErrorList错误列表渲染过滤空值后逐条展示。Fieldimport { Field, ErrorList } from #app/components/forms.tsx import { getInputProps } from conform-to/react Field labelProps{{ htmlFor: fields.email.id, children: Email, }} inputProps{{ ...getInputProps(fields.email, { type: email }), autoFocus: true, autoComplete: email, }} errors{fields.email.errors} /TextareaFieldimport { TextareaField } from #app/components/forms.tsx import { getTextareaProps } from conform-to/react TextareaField labelProps{{ htmlFor: fields.content.id, children: Content, }} textareaProps{{ ...getTextareaProps(fields.content), rows: 10, }} errors{fields.content.errors} /CheckboxFieldimport { CheckboxField } from #app/components/forms.tsx import { getInputProps } from conform-to/react CheckboxField labelProps{{ htmlFor: fields.remember.id, children: Remember me, }} buttonProps{getInputProps(fields.remember, { type: checkbox })} errors{fields.remember.errors} /登录页的Remember me即为此用法app/routes/_auth/login.tsx。OTPFieldimport { OTPField } from #app/components/forms.tsx OTPField labelProps{{ htmlFor: fields.code.id, children: Verification Code, }} inputProps{{ ...getInputProps(fields.code), maxLength: 6, }} errors{fields.code.errors} /OTPField 的实际应用在 app/routes/_auth/verify.tsx配合autoComplete: one-time-code与autoFocus服务 onboarding、reset-password、change-email、2fa 四类验证码提交。错误处理字段错误Field // ... props errors{fields.email.errors} // 字段级具体错误 /表单级错误import { ErrorList } from #app/components/forms.tsx ErrorList errors{form.errors} id{form.errorId} /错误结构fields.fieldName.errors—— 特定字段的错误数组form.errors—— 表单级错误如formErrors。除展示外app/components/forms.tsx 中的Field还会根据errors?.length计算errorId为输入框挂上aria-invalid与aria-describedby确保屏幕阅读器能感知错误app/components/forms.tsx。服务端还可以用submission.reply({ hideFields: [password] })屏蔽敏感字段的回显app/routes/_auth/login.tsx或submission.reply({ formErrors: [message] })注入自定义表单级错误app/routes/_auth/signup.tsx。蜜罐字段HoneypotEpic Stack 为所有公开表单内置了蜜罐防垃圾提交机制其原理与决策记录见 docs/decisions/033-honeypot.md在表单中放置视觉隐藏的字段垃圾机器人会忠实地填满所有字段而真实用户不会触碰提交时若蜜罐字段非空即判定为机器人并拒绝。方案选用remix-utils实现。表单侧import { HoneypotInputs } from remix-utils/honeypot/react Form methodPOST {...getFormProps(form)} HoneypotInputs / {/* 公开表单必须包含 */} {/* 其余字段 */} /FormAction 侧import { checkHoneypot } from #app/utils/honeypot.server.ts export async function action({ request }: Route.ActionArgs) { const formData await request.formData() await checkHoneypot(formData) // 检测到垃圾则抛错 // ... 其余逻辑 }服务端实现位于 app/utils/honeypot.server.ts创建Honeypot实例时使用process.env.HONEYPOT_SECRET作为加密种子检测到SpamError时抛出Response(Form not submitted properly, { status: 400 })。测试环境下validFromFieldName置为null以方便测试。登录app/routes/_auth/login.tsx、注册app/routes/_auth/signup.tsx等公开表单均在校验前先执行checkHoneypot。注意已认证用户可见的表单如笔记编辑无需蜜罐因为机器人无法访问。文件上传含文件上传的表单必须设置encTypemultipart/form-data。文件 Schemaconst MAX_UPLOAD_SIZE 1024 * 1024 * 3 // 3MB const ImageFieldsetSchema z.object({ id: z.string().optional(), file: z .instanceof(File) .optional() .refine((file) { return !file || file.size MAX_UPLOAD_SIZE }, File must be less than 3MB), altText: z.string().optional(), }) const NoteEditorSchema z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(10000), images: z.array(ImageFieldsetSchema).max(5).optional(), })注意refine内!file || ...的写法可选文件在未选择时同样通过校验。这正是 app/routes/users/$username/notes/shared/note-editor.tsx 中MAX_UPLOAD_SIZE3MB与NoteEditorSchema的真实定义其中title/content的上下限被抽为具名常量。表单声明Form methodPOST encTypemultipart/form-data {...getFormProps(form)} {/* Fields */} /FormAction 中处理文件export async function action({ request }: Route.ActionArgs) { const formData await request.formData() const submission await parseWithZod(formData, { schema: NoteEditorSchema, }) if (submission.status ! success) { return data({ result: submission.reply() }, { status: 400 }) } const { images } submission.value // 处理文件 for (const image of images ?? []) { if (image.file) { // 上传文件、保存到存储等 } } // ... }仓库中的完整落地比上述示例更进一步见 app/routes/users/$username/notes/shared/note-editor.server.tsx用mjackson/form-data-parser的parseFormData(request, { maxFileSize: MAX_UPLOAD_SIZE })流式解析 multipart 请求避免整个文件加载进内存随后parseWithZod校验并在.transform中区分已有 id 的旧图更新imageHasId与带文件的新图imageHasFile通过uploadNoteImage(userId, noteId, file)上传至对象存储最后用prisma.note.upsert落库。这展示了文件表单在真实项目中的完整链路Schema 限制 → 流式解析 → 文件上传 → 数据库持久化。字段集与数组Fieldsets Arrays对重复结构字段如多张图片使用 Conform 的getFieldList()与getFieldsetProps()。Schemaconst ImageFieldsetSchema z.object({ id: z.string().optional(), file: z.instanceof(File).optional(), altText: z.string().optional(), }) const FormSchema z.object({ images: z.array(ImageFieldsetSchema).max(5).optional(), })组件内渲染import { FormProvider, getFieldsetProps } from conform-to/react const [form, fields] useForm({ // ... defaultValue: { images: note?.images ?? [{}], }, }) const imageList fields.images.getFieldList() return ( FormProvider context{form.context} Form methodPOST {...getFormProps(form)} {imageList.map((image, index) { const imageFieldset getFieldsetProps(fields.images[index]) return ( fieldset key{image.key} {...imageFieldset} input {...getInputProps(fields.images[index].file, { type: file })} / input {...getInputProps(fields.images[index].altText, { type: text })} placeholderAlt text / /fieldset ) })} button typebutton onClick{() fields.images.append()} Add Image /button /Form /FormProvider )仓库中 app/routes/users/$username/notes/shared/note-editor.tsx 是这一模式的完整实现且封装得更精致用form.insert.getButtonProps({ name: fields.images.name })生成添加图片按钮无需手写onClicknote-editor.tsx用form.remove.getButtonProps({ name: fields.images.name, index })生成每项删除按钮note-editor.tsx表单内还埋了一个隐藏的typesubmit按钮确保用户按回车时提交的是主表单而非添加/删除图片按钮note-editor.tsxImageChooser子组件内部用meta.getFieldset()拆分字段并对旧图existingImage渲染隐藏id字段、用 FileReader 实现选图即时预览、对file/altText/fieldset 三级分别渲染ErrorList。StatusButton 提交状态按钮用StatusButton展示提交状态pending/success/error/idle配合useIsPending在提交期间禁用按钮并显示旋转图标import { StatusButton } from #app/components/ui/status-button.tsx import { useIsPending } from #app/utils/misc.tsx const isPending useIsPending() StatusButton classNamew-full status{isPending ? pending : (form.status ?? idle)} typesubmit disabled{isPending} Submit /StatusButton底层实现见 app/components/ui/status-button.tsx通过useSpinDelay延迟 400ms、最短展示 300ms 后才显示 spinner避免快速请求时按钮闪烁pending状态渲染旋转的update图标、success渲染对勾、error渲染红色圆底叉号。useIsPending定义于 app/utils/misc.tsx内部通过useNavigation()判断当前导航是否正在提交当前路由的表单默认匹配当前 action 与 POST 方法保证只有本表单的提交才会触发 pending。渐进增强Progressive EnhancementConform 使表单在无 JavaScript 环境下依然可用原生 HTML5 校验getZodConstraint自动把 Zod 规则映射为required、typeemail、maxLength等原生约束浏览器无 JS 也能拦截大部分非法输入服务端校验action中的parseWithZod是最终防线有 JS 时的增强客户端即时校验、pending 状态、乐观更新等。无需额外编码——Conform 自动处理这一切。这正是 Epic Stack以标准 HTML 表单为地基、JS 渐进增强设计理念的体现。可复用 Schema 校验将可复用的 Schema 集中放在 app/utils/user-validation.ts// app/utils/user-validation.ts import { z } from zod export const EmailSchema z .string({ required_error: Email is required }) .email({ message: Email is invalid }) .min(3, { message: Email is too short }) .max(100, { message: Email is too long }) .transform((value) value.toLowerCase()) export const PasswordSchema z .string({ required_error: Password is required }) .min(6, { message: Password is too short }) .refine((val) new TextEncoder().encode(val).length 72, { message: Password is too long, }) export const PasswordAndConfirmPasswordSchema z .object({ password: PasswordSchema, confirmPassword: PasswordSchema }) .superRefine(({ confirmPassword, password }, ctx) { if (confirmPassword ! password) { ctx.addIssue({ path: [confirmPassword], code: custom, message: The passwords must match, }) } })组合使用import { EmailSchema, PasswordAndConfirmPasswordSchema, } from #app/utils/user-validation.ts const SignupSchema z .object({ email: EmailSchema, username: UsernameSchema, }) .and(PasswordAndConfirmPasswordSchema)仓库中该文件还包含UsernameSchema与NameSchemaapp/utils/user-validation.ts并导出USERNAME_MIN_LENGTH等具名常量方便在 UI 文案中同步引用。几个值得注意的实现细节PasswordSchema通过new TextEncoder().encode(val).length 72校验字节长度因为 bcrypt 有 72 字节上限源码注释引用了 epic-stack issue #918EmailSchema与UsernameSchema通过.transform统一转小写让用户无论大小写输入都按小写存储成为约定的一部分代码注释已说明登录页的LoginFormSchema直接组合UsernameSchema、PasswordSchema并额外加redirectTo、remember字段app/routes/_auth/login.tsx体现基础 Schema 复用 场景字段扩展的组织方式。完整示例示例 1简单登录表单// app/routes/_auth/login.tsx import { getFormProps, getInputProps, useForm } from conform-to/react import { getZodConstraint, parseWithZod } from conform-to/zod import { z } from zod import { Field, ErrorList } from #app/components/forms.tsx import { StatusButton } from #app/components/ui/status-button.tsx const LoginSchema z.object({ email: z.string().email(), password: z.string().min(1), }) export default function LoginRoute({ actionData }: Route.ComponentProps) { const isPending useIsPending() const [form, fields] useForm({ id: login-form, constraint: getZodConstraint(LoginSchema), lastResult: actionData?.result, onValidate({ formData }) { return parseWithZod(formData, { schema: LoginSchema }) }, shouldRevalidate: onBlur, }) return ( Form methodPOST {...getFormProps(form)} Field labelProps{{ htmlFor: fields.email.id, children: Email }} inputProps{{ ...getInputProps(fields.email, { type: email }), autoFocus: true, autoComplete: email, }} errors{fields.email.errors} / Field labelProps{{ htmlFor: fields.password.id, children: Password }} inputProps{{ ...getInputProps(fields.password, { type: password }), autoComplete: current-password, }} errors{fields.password.errors} / ErrorList errors{form.errors} id{form.errorId} / StatusButton status{isPending ? pending : (form.status ?? idle)} typesubmit Login /StatusButton /Form ) }仓库真实登录页比这更完整HoneypotInputs、CheckboxFieldRemember me、隐藏redirectTo字段、passkey 登录与 OAuth Provider 连接见 app/routes/_auth/login.tsx。示例 2带异步校验的表单// app/routes/_auth/signup.tsx export async function action({ request }: Route.ActionArgs) { const formData await request.formData() await checkHoneypot(formData) const submission await parseWithZod(formData, { schema: SignupSchema.superRefine(async (data, ctx) { const existingUser await prisma.user.findUnique({ where: { email: data.email }, select: { id: true }, }) if (existingUser) { ctx.addIssue({ path: [email], code: z.ZodIssueCode.custom, message: A user already exists with this email, }) } }), async: true, }) if (submission.status ! success) { return data( { result: submission.reply() }, { status: submission.status error ? 400 : 200 }, ) } // 继续注册流程... }完整实现见 app/routes/_auth/signup.tsx校验通过后生成 10 分钟有效的 onboarding 验证链接与 OTP调用sendEmail发送验证邮件成功后重定向到验证页。示例 3带文件上传的表单// app/routes/users/$username/notes/new.tsx const NoteEditorSchema z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(10000), images: z.array(ImageFieldsetSchema).max(5).optional(), }) export default function NewNoteRoute({ actionData }: Route.ComponentProps) { const [form, fields] useForm({ id: note-editor, constraint: getZodConstraint(NoteEditorSchema), lastResult: actionData?.result, onValidate({ formData }) { return parseWithZod(formData, { schema: NoteEditorSchema }) }, defaultValue: { images: [{}], }, shouldRevalidate: onBlur, }) const imageList fields.images.getFieldList() return ( FormProvider context{form.context} Form methodPOST encTypemultipart/form-data {...getFormProps(form)} Field labelProps{{ children: Title }} inputProps{{ autoFocus: true, ...getInputProps(fields.title, { type: text }), }} errors{fields.title.errors} / TextareaField labelProps{{ children: Content }} textareaProps{{ ...getTextareaProps(fields.content), rows: 10, }} errors{fields.content.errors} / {imageList.map((image, index) { const imageFieldset getFieldsetProps(fields.images[index]) return ( fieldset key{image.key} {...imageFieldset} input {...getInputProps(fields.images[index].file, { type: file })} acceptimage/* / input {...getInputProps(fields.images[index].altText, { type: text })} placeholderAlt text / /fieldset ) })} button typebutton onClick{() fields.images.append()} disabled{imageList.length 5} Add Image /button ErrorList errors{form.errors} id{form.errorId} / StatusButton typesubmitCreate Note/StatusButton /Form /FormProvider ) }笔记编辑/新建的实际实现见 app/routes/users/$username/notes/shared/note-editor.tsx 与 note-editor.server.tsx前者负责组件渲染与交互选图预览、增删图片、浮动工具栏中的 Reset/Submit后者负责服务端解析、上传与持久化。常见错误与规避❌隐式校验始终使用带清晰错误消息的显式 Zod Schema不要写藏在业务逻辑里的校验❌延迟校验拿到formData立即校验并快速失败不要拖到函数末尾❌笼统的错误消息错误消息要具体、可操作准确指出问题所在❌异步校验忘记async: true使用superRefine异步回调时务必在parseWithZod中开启async: true❌公开表单漏掉HoneypotInputs所有无需认证即可访问的表单都要加蜜罐❌忘记encTypemultipart/form-data文件上传表单必须声明❌不校验文件大小文件大小限制必须在 Schema 中校验参考MAX_UPLOAD_SIZE❌不用getZodConstraint它是原生 HTML5 校验渐进增强的基石的来源❌useForm漏传lastResult没有它服务端错误无法回显❌不用shouldRevalidate: onBlur失焦校验显著改善交互体验❌不使用内置字段组件Field、TextareaField等已处理无障碍与错误展示手写易遗漏aria属性。参考资源Conform 官方文档conform.guideZod 官方文档zod.devEpic Web 设计原则epicweb.dev/principles字段组件实现app/components/forms.tsx完整注册示例app/routes/_auth/signup.tsx复杂表单示例onboardingapp/routes/_auth/onboarding/index.tsx文件上传示例app/routes/users/$username/notes/shared/note-editor.tsx可复用 Schemaapp/utils/user-validation.ts蜜罐决策记录docs/decisions/033-honeypot.md蜜罐服务端实现app/utils/honeypot.server.ts提交状态按钮app/components/ui/status-button.tsx【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考