소스 정보
- 저장소
- epicweb-dev/epic-stack
- 최근 소스 활동
- 2026년 1월 30일 00:59
- 감지된 SKILL.md 언어
- 영어
- 스타
- 5,546
- 포크
- 464
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/epicweb-dev/epic-stack --skill epic-forms명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
SOC 직업 분류 기준
SKILL.md 표시 중
| name | epic-forms |
| description | Guide on forms with Conform and validation with Zod for Epic Stack |
| categories | ["forms","conform","zod","validation"] |
Use this skill when you need to:
Following Epic Web principles:
Explicit is better than implicit - Make validation rules clear and explicit using Zod schemas. Every validation rule should be visible in the schema, not hidden in business logic. Error messages should be specific and helpful, telling users exactly what went wrong and how to fix it.
Design to fail fast and early - Validate input as early as possible, ideally on the client side before submission, and always on the server side. Return clear, specific error messages immediately so users can fix issues without frustration.
Example - Explicit validation:
// ✅ Good - Explicit validation with clear error messages
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 - Implicit validation
const SignupSchema = z.object({
email: z.string().email(), // No clear error messages
password: z.string().min(6), // Generic error
})
Example - Fail fast validation:
// ✅ Good - Validate early and return specific errors immediately
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
// Validate immediately - fail fast
const submission = await parseWithZod(formData, {
schema: SignupSchema,
})
// Return errors immediately if validation fails
if (submission.status !== 'success') {
return data(
{ result: submission.reply() },
{ status: 400 }, // Clear error status
)
}
// Only proceed if validation passed
const { email, password } = submission.value
// ... continue with signup
}
// ❌ Avoid - Delayed or unclear validation
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const email = formData.get('email')
const password = formData.get()
(!email) {
({ : }, { : })
}
}
Epic Stack uses Conform to handle forms with progressive enhancement.
Basic setup:
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 method= {()}>
{/* Form fields */}
)
}
Conform integrates seamlessly with Zod for validation.
Define 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',
})
}
})
Validation in action (fail fast):
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
// Validate immediately - explicit and fail fast
const submission = await parseWithZod(formData, {
schema: SignupSchema,
})
// Return explicit errors immediately if validation fails
if (submission.status !== 'success') {
return data(
{ result: submission.reply() },
{ status: submission.status === 'error' ? 400 : 200 },
)
}
// Only proceed if validation passed - submission.value is type-safe
const { email, password } = submission.value
// ... process with validated data
}
For validations that require querying the database:
export 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, // Important: enable async validation
})
if (submission.status !== 'success') {
return data(
{ result: submission.reply() },
{ status: submission.status === 'error' ? 400 : },
)
}
}
Epic Stack provides pre-built field components:
Basic Field:
import { 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}
/>
TextareaField:
import { 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}
/>
CheckboxField:
import { 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}
/>
OTPField:
import { 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}
/>
Display field errors:
<Field
// ... props
errors={fields.email.errors} // Errores específicos del campo
/>
Display form errors:
import { ErrorList } from '#app/components/forms.tsx'
<ErrorList errors={form.errors} id={form.errorId} />
Error structure:
fields.fieldName.errors - Errors for a specific fieldform.errors - General form errors (like formErrors)Epic Stack includes spam protection with honeypot fields.
In the form:
import { HoneypotInputs } from 'remix-utils/honeypot/react'
<Form method="POST" {...getFormProps(form)}>
<HoneypotInputs /> {/* Always include in public forms */}
{/* Rest of fields */}
</Form>
In the action:
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
await checkHoneypot(formData) // Throws error if spam
// ... rest of code
}
For forms with file uploads, use encType="multipart/form-data".
Schema for files:
const 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(),
})
Form with file upload:
<Form
method="POST"
encType="multipart/form-data"
{...getFormProps(form)}
>
{/* Fields */}
</Form>
Process files in action:
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
// Process files
for (const image of images ?? []) {
if (image.file) {
// Upload file, save to storage, etc.
}
}
// ...
}
For forms with repetitive fields (like multiple images):
Schema:
const 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(),
})
In the component:
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 method="POST" {...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' })}
placeholder="Alt text"
/>
</fieldset>
)
})}
<button
type="button"
= => fields.images.append()}
>
Add Image
)
Use StatusButton to display submission status:
import { StatusButton } from '#app/components/ui/status-button.tsx'
import { useIsPending } from '#app/utils/misc.tsx'
const isPending = useIsPending()
<StatusButton
className="w-full"
status={isPending ? 'pending' : (form.status ?? 'idle')}
type="submit"
disabled={isPending}
>
Submit
</StatusButton>
Forms work without JavaScript thanks to Conform:
You don't need to do anything special - Conform handles this automatically.
Create reusable schemas in 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 !== password) {
ctx.({
: [],
: ,
: ,
})
}
})
Use in forms:
import {
EmailSchema,
PasswordAndConfirmPasswordSchema,
} from '#app/utils/user-validation.ts'
const SignupSchema = z
.object({
email: EmailSchema,
username: UsernameSchema,
})
.and(PasswordAndConfirmPasswordSchema)
// 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 (formData, { : })
},
: ,
})
(
)
}
// 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 === ? : },
)
}
}
// 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.()
(
)
}
async: true in async validation: Always include
async: true when using superRefine with asyncHoneypotInputs in public forms: Always include honeypot
in forms accessible without authenticationencType="multipart/form-data": Required for file uploadsgetZodConstraint: Required for native HTML5 validationlastResult in useForm: Required to display server errorsshouldRevalidate: 'onBlur': Improves UX by validating on
field blurField, TextareaField, etc.
already handle accessibility and errorsapp/components/forms.tsx - Field componentsapp/routes/_auth/signup.tsx - Complete signup exampleapp/routes/_auth/onboarding/index.tsx - Complex form exampleapp/routes/users/$username/notes/+shared/note-editor.tsx - File uploads
exampleapp/utils/user-validation.ts - Reusable schemasdocs/decisions/033-honeypot.md - Honeypot documentation