sndp-easy-form
v1.0.2
Published
A lightweight, performant form management library for React and Next.js
Downloads
302
Maintainers
Readme
sndp-easy-form 🚀
A lightweight (~9kb), high-performance form management library for React and Next.js with full TypeScript support.
✨ Features
- 🚀 High Performance - Minimal re-renders with uncontrolled inputs
- 📦 Lightweight - Only ~9kb gzipped, zero dependencies
- 🎯 TypeScript First - Full type safety and IntelliSense support
- ⚡ React Hooks - Modern hooks-based API
- 🔄 Next.js Ready - Works with both CSR and SSR
- 🎨 Flexible Validation - Built-in and custom validation rules
- 📝 Dynamic Forms - Support for field arrays and conditional fields
- 🌐 Native HTML - Works with standard HTML form elements
📦 Installation
npm install sndp-easy-formyarn add sndp-easy-formpnpm add sndp-easy-form🚀 Quick Start
import { useForm } from 'sndp-easy-form';
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username', { required: 'Username is required' })} />
{errors.username && <span>{errors.username.message}</span>}
<button type="submit">Submit</button>
</form>
);
}📚 API Reference
useForm()
The main hook for managing form state and validation.
Options
const formMethods = useForm({
mode: 'onSubmit', // 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all'
reValidateMode: 'onChange', // 'onSubmit' | 'onBlur' | 'onChange'
defaultValues: {}, // Default form values
criteriaMode: 'firstError' // 'firstError' | 'all'
});Return Values
- register - Register input fields with validation rules
- handleSubmit - Handle form submission
- watch - Watch field values
- setValue - Programmatically set field values
- getValue - Get a single field value
- getValues - Get all form values
- reset - Reset form to default values
- setError - Set field error manually
- clearErrors - Clear field errors
- setFocus - Set focus on a field
- unregister - Unregister a field
- formState - Form state object
formState
{
isDirty: boolean; // Form has been modified
dirtyFields: object; // Fields that have been modified
touchedFields: object; // Fields that have been touched
isSubmitted: boolean; // Form has been submitted
isSubmitting: boolean; // Form is currently submitting
isSubmitSuccessful: boolean; // Last submission was successful
isValid: boolean; // Form is valid
submitCount: number; // Number of submission attempts
errors: object; // Field errors
}📖 Complete Examples
Basic Form with Validation
import { useForm } from 'sndp-easy-form';
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm();
const onSubmit = async (data) => {
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Email</label>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
})}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label>Password</label>
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters'
}
})}
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}Custom Validation
import { useForm } from 'sndp-easy-form';
function SignupForm() {
const { register, handleSubmit, watch, formState: { errors } } = useForm();
const password = watch('password');
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Minimum 3 characters' },
validate: async (value) => {
const response = await fetch(`/api/check-username?username=${value}`);
const available = await response.json();
return available || 'Username already taken';
}
})}
placeholder="Username"
/>
{errors.username && <span>{errors.username.message}</span>}
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Minimum 8 characters' }
})}
placeholder="Password"
/>
{errors.password && <span>{errors.password.message}</span>}
<input
type="password"
{...register('confirmPassword', {
required: 'Please confirm password',
validate: (value) =>
value === password || 'Passwords do not match'
})}
placeholder="Confirm Password"
/>
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}Dynamic Field Array
import { useForm, useFieldArray } from 'sndp-easy-form';
function ContactForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: {
contacts: [{ name: '', email: '', phone: '' }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts'
});
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h3>Contacts</h3>
{fields.map((field, index) => (
<div key={field._id} style={{ marginBottom: '20px', padding: '10px', border: '1px solid #ddd' }}>
<input
{...register(`contacts.${index}.name`, {
required: 'Name is required'
})}
placeholder="Name"
/>
<input
{...register(`contacts.${index}.email`, {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email'
}
})}
placeholder="Email"
/>
<input
{...register(`contacts.${index}.phone`)}
placeholder="Phone (optional)"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', email: '', phone: '' })}
>
Add Contact
</button>
<button type="submit">Submit</button>
</form>
);
}Form Context (Multiple Components)
import { useForm, FormProvider, useFormContext } from 'sndp-easy-form';
function ParentForm() {
const methods = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<FormProvider formMethods={methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<PersonalInfo />
<AddressInfo />
<button type="submit">Submit</button>
</form>
</FormProvider>
);
}
function PersonalInfo() {
const { register, formState: { errors } } = useFormContext();
return (
<div>
<h3>Personal Information</h3>
<input
{...register('firstName', { required: 'First name is required' })}
placeholder="First Name"
/>
{errors.firstName && <span>{errors.firstName.message}</span>}
<input
{...register('lastName', { required: 'Last name is required' })}
placeholder="Last Name"
/>
{errors.lastName && <span>{errors.lastName.message}</span>}
</div>
);
}
function AddressInfo() {
const { register, formState: { errors } } = useFormContext();
return (
<div>
<h3>Address</h3>
<input
{...register('street', { required: 'Street is required' })}
placeholder="Street"
/>
{errors.street && <span>{errors.street.message}</span>}
<input
{...register('city', { required: 'City is required' })}
placeholder="City"
/>
{errors.city && <span>{errors.city.message}</span>}
</div>
);
}Watching Form Values
import { useForm } from 'sndp-easy-form';
function ConditionalForm() {
const { register, handleSubmit, watch } = useForm();
const showAddress = watch('needsShipping');
const country = watch('country');
return (
<form onSubmit={handleSubmit(console.log)}>
<label>
<input
type="checkbox"
{...register('needsShipping')}
/>
Need shipping?
</label>
{showAddress && (
<div>
<input {...register('address')} placeholder="Address" />
<select {...register('country')}>
<option value="">Select Country</option>
<option value="US">United States</option>
<option value="IN">India</option>
<option value="UK">United Kingdom</option>
</select>
{country === 'US' && (
<input {...register('zipCode')} placeholder="ZIP Code" />
)}
{country === 'IN' && (
<input {...register('pinCode')} placeholder="PIN Code" />
)}
</div>
)}
<button type="submit">Submit</button>
</form>
);
}Programmatic Form Control
import { useForm } from 'sndp-easy-form';
function ProgrammaticForm() {
const { register, handleSubmit, setValue, reset, setFocus } = useForm({
defaultValues: {
username: '',
email: '',
age: ''
}
});
const fillDemoData = () => {
setValue('username', 'johndoe');
setValue('email', '[email protected]');
setValue('age', '25');
setFocus('username');
};
const resetForm = () => {
reset();
};
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('username')} placeholder="Username" />
<input {...register('email')} placeholder="Email" />
<input {...register('age')} type="number" placeholder="Age" />
<div>
<button type="button" onClick={fillDemoData}>
Fill Demo Data
</button>
<button type="button" onClick={resetForm}>
Reset Form
</button>
<button type="submit">Submit</button>
</div>
</form>
);
}🎯 Validation Rules
Built-in Validation Rules
register('fieldName', {
required: 'This field is required',
// or
required: { value: true, message: 'Required' },
min: { value: 18, message: 'Must be at least 18' },
max: { value: 100, message: 'Must be less than 100' },
minLength: { value: 3, message: 'Minimum 3 characters' },
maxLength: { value: 20, message: 'Maximum 20 characters' },
pattern: {
value: /^[A-Za-z]+$/,
message: 'Only letters allowed'
},
validate: (value) => {
return value.includes('@') || 'Must contain @';
},
// or multiple validation functions
validate: {
positive: (value) => parseFloat(value) > 0 || 'Must be positive',
lessThan100: (value) => parseFloat(value) < 100 || 'Must be less than 100'
}
})50+ Custom Validation Examples
Text Input Validations
// No numbers allowed
register('name', {
validate: (v) => v.split('').every(c => isNaN(Number(c)) || c === ' ') || 'Numbers not allowed'
});
// No special characters
register('username', {
validate: (v) => v.split('').every(c => "abcdefghijklmnopqrstuvwxyz ".includes(c.toLowerCase())) || 'Special characters not allowed'
});
// Must be uppercase
register('code', {
validate: (v) => v === v.toUpperCase() || 'Must be in uppercase'
});
// Must be lowercase
register('slug', {
validate: (v) => v === v.toLowerCase() || 'Must be in lowercase'
});
// Not empty (no spaces only)
register('title', {
validate: (v) => v.trim().length > 0 || 'Cannot be only spaces'
});
// Forbidden words
register('username', {
validate: (v) => !v.toLowerCase().includes('admin') || 'Cannot contain word admin'
});
// Single word (no spaces)
register('identifier', {
validate: (v) => !v.trim().includes(' ') || 'Spaces not allowed'
});
// Must start with specific letter
register('productCode', {
validate: (v) => v.toLowerCase().startsWith('a') || 'Must start with letter A'
});Password Validations
const { register, watch } = useForm();
const username = watch('username');
const password = watch('password');
// Strong password with multiple validations
register('password', {
validate: {
hasSymbol: (v) => v.includes('@') || v.includes('#') || 'Must include @ or #',
hasNumber: (v) => v.split('').some(c => !isNaN(Number(c))) || 'Must include a number',
hasUpper: (v) => v.split('').some(c => c === c.toUpperCase() && c !== c.toLowerCase()) || 'Must have uppercase letter',
hasLower: (v) => v.split('').some(c => c === c.toLowerCase() && c !== c.toUpperCase()) || 'Must have lowercase letter',
min8Chars: (v) => v.length >= 8 || 'Password too short (min 8)',
noSpace: (v) => !v.includes(' ') || 'No spaces allowed',
isNotCommon: (v) => !['12345678', 'password', 'qwerty'].includes(v) || 'Too common password',
hasSpecial: (v) => "!@#$%^&*".split('').some(s => v.includes(s)) || 'Special character required'
}
});
// Password cannot contain username
register('password', {
validate: (v) => !v.toLowerCase().includes(username?.toLowerCase()) || 'Password cannot contain username'
});
// Confirm password match
register('confirmPassword', {
validate: (v) => v === password || 'Passwords do not match'
});Number Input Validations
// Must be positive
register('amount', {
validate: (v) => Number(v) > 0 || 'Must be a positive number'
});
// Age validation
register('age', {
validate: {
isAdult: (v) => Number(v) >= 18 || 'Must be at least 18',
maxAge: (v) => Number(v) <= 100 || 'Age cannot exceed 100'
}
});
// Even/Odd numbers
register('evenNumber', {
validate: (v) => Number(v) % 2 === 0 || 'Only even numbers allowed'
});
register('oddNumber', {
validate: (v) => Number(v) % 2 !== 0 || 'Only odd numbers allowed'
});
// Divisible by specific number
register('quantity', {
validate: (v) => Number(v) % 5 === 0 || 'Must be multiple of 5'
});
// Range validation
register('score', {
validate: (v) => (Number(v) >= 10 && Number(v) <= 50) || 'Must be between 10 and 50'
});
// Not zero
register('value', {
validate: (v) => Number(v) !== 0 || 'Value cannot be zero'
});
// Integers only (no decimals)
register('count', {
validate: (v) => Number.isInteger(Number(v)) || 'Decimals not allowed'
});
// Maximum price
register('price', {
validate: (v) => Number(v) <= 5000 || 'Price cannot be more than 5000'
});Email & URL Validations
// Basic email validations
register('email', {
validate: {
hasAt: (v) => v.includes('@') || 'Missing @ symbol',
hasDot: (v) => v.includes('.') || 'Missing . (dot) in email',
longEnough: (v) => v.length > 10 || 'Email address too short'
}
});
// Exclude specific domains
register('email', {
validate: (v) => !v.endsWith('@gmail.com') || 'Gmail not allowed'
});
// Company email only
register('workEmail', {
validate: (v) => v.endsWith('@company.com') || 'Use company email only'
});
// No underscore in email
register('email', {
validate: (v) => !v.includes('_') || 'Underscore not allowed in email'
});
// URL must start with http/https
register('website', {
validate: (v) => v.startsWith('http') || 'URL must start with http or https'
});Date & Time Validations
// Date cannot be in future
register('birthdate', {
validate: (v) => new Date(v) <= new Date() || 'Date cannot be in future'
});
// Only specific day of week (Sunday)
register('meetingDate', {
validate: (v) => new Date(v).getDay() === 0 || 'Only Sundays allowed'
});
// Date range
register('startDate', {
validate: (v) => new Date(v) >= new Date('2020-01-01') || 'Date must be after 2020'
});
// Specific year only
register('eventDate', {
validate: (v) => new Date(v).getFullYear() === 2024 || 'Only 2024 dates allowed'
});Checkbox, Radio & File Validations
// Terms must be accepted
register('terms', {
validate: (v) => v === true || 'Must accept terms and conditions'
});
// Privacy policy agreement
register('privacy', {
validate: (v) => v === true || 'Privacy policy agreement required'
});
// Select/dropdown required
register('country', {
validate: (v) => v !== '' || 'Please select an option'
});
// Blocked options
register('region', {
validate: (v) => v !== 'Blocked' || 'Service not available in your area'
});File & Image Upload Validations
// File is required
register('avatar', {
validate: (v) => (v && v.length > 0) || 'File is required'
});
// File size validation (min and max)
register('document', {
validate: {
minSize: (v) => v[0]?.size >= 10 * 1024 || 'File is too small (min 10KB)',
maxSize: (v) => v[0]?.size <= 2 * 1024 * 1024 || 'Max size 2MB'
}
});
// Only images allowed
register('photo', {
validate: (v) => v[0]?.type.startsWith('image/') || 'Only images allowed'
});
// Only PDF allowed
register('resume', {
validate: (v) => v[0]?.type === 'application/pdf' || 'Only PDF allowed'
});
// Specific image formats only (PNG/JPG)
register('profilePic', {
validate: (v) => ['image/png', 'image/jpeg'].includes(v[0]?.type) || 'Only PNG/JPG allowed'
});
// Multiple file formats allowed
register('attachment', {
validate: (v) => {
const allowedTypes = ['application/pdf', 'image/png', 'image/jpeg', 'application/msword'];
return allowedTypes.includes(v[0]?.type) || 'Invalid file format';
}
});
// Maximum number of files
register('gallery', {
validate: (v) => v.length <= 3 || 'Maximum 3 files allowed'
});
// Minimum number of files
register('certificates', {
validate: (v) => v.length >= 2 || 'At least 2 files required'
});
// File name length validation
register('file', {
validate: (v) => v[0]?.name.length < 50 || 'File name too long (max 50 chars)'
});
// No special characters in file name
register('upload', {
validate: (v) => !v[0]?.name.includes('#') || 'File name cannot contain #'
});
// File extension validation
register('document', {
validate: (v) => {
const validExtensions = ['.pdf', '.doc', '.docx'];
const fileName = v[0]?.name.toLowerCase();
return validExtensions.some(ext => fileName.endsWith(ext)) || 'Invalid file type';
}
});
// Combined file validation
register('coverImage', {
validate: {
required: (v) => (v && v.length > 0) || 'Image is required',
isImage: (v) => v[0]?.type.startsWith('image/') || 'Only images allowed',
maxSize: (v) => v[0]?.size <= 5 * 1024 * 1024 || 'Max size 5MB',
specificFormat: (v) => ['image/png', 'image/jpeg', 'image/webp'].includes(v[0]?.type) ||
'Only PNG, JPG, or WebP allowed',
nameLength: (v) => v[0]?.name.length < 100 || 'File name too long'
}
});Advanced Image Validation with Dimensions
function ImageUploadForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
// Validate image dimensions
const validateImageDimensions = (files) => {
return new Promise((resolve) => {
if (!files || files.length === 0) {
resolve('Image is required');
return;
}
const file = files[0];
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
// Check minimum dimensions
if (img.width < 800 || img.height < 600) {
resolve('Image must be at least 800x600 pixels');
return;
}
// Check maximum dimensions
if (img.width > 4000 || img.height > 4000) {
resolve('Image too large (max 4000x4000 pixels)');
return;
}
// Check aspect ratio (16:9)
const aspectRatio = img.width / img.height;
if (Math.abs(aspectRatio - 16/9) > 0.1) {
resolve('Image must have 16:9 aspect ratio');
return;
}
resolve(true);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
resolve('Invalid image file');
};
img.src = objectUrl;
});
};
return (
<form onSubmit={handleSubmit(console.log)}>
<input
type="file"
accept="image/*"
{...register('banner', {
validate: {
required: (v) => (v && v.length > 0) || 'Banner image is required',
fileSize: (v) => v[0]?.size <= 3 * 1024 * 1024 || 'Max size 3MB',
fileType: (v) => ['image/png', 'image/jpeg'].includes(v[0]?.type) || 'Only PNG/JPG',
dimensions: validateImageDimensions
}
})}
/>
{errors.banner && <span className="error">{errors.banner.message}</span>}
<button type="submit">Upload</button>
</form>
);
}Multiple Files with Individual Validation
function MultiFileUpload() {
const { register, handleSubmit, formState: { errors } } = useForm();
const validateAllFiles = (files) => {
if (!files || files.length === 0) {
return 'Please select at least one file';
}
// Check total number of files
if (files.length > 5) {
return 'Maximum 5 files allowed';
}
// Check each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check file size
if (file.size > 10 * 1024 * 1024) {
return `File "${file.name}" is too large (max 10MB)`;
}
// Check file type
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
return `File "${file.name}" has invalid format`;
}
// Check file name
if (file.name.length > 100) {
return `File "${file.name}" has too long name`;
}
}
// Check total size of all files
const totalSize = Array.from(files).reduce((sum, file) => sum + file.size, 0);
if (totalSize > 20 * 1024 * 1024) {
return 'Total file size cannot exceed 20MB';
}
return true;
};
return (
<form onSubmit={handleSubmit(console.log)}>
<input
type="file"
multiple
accept="image/*,.pdf"
{...register('documents', {
validate: validateAllFiles
})}
/>
{errors.documents && <span className="error">{errors.documents.message}</span>}
<button type="submit">Upload Files</button>
</form>
);
}Complex Combined Validations
function RegistrationForm() {
const { register, handleSubmit, watch, formState: { errors } } = useForm();
const password = watch('password');
const username = watch('username');
return (
<form onSubmit={handleSubmit(console.log)}>
<input
{...register('username', {
validate: {
minLength: (v) => v.length >= 5 || 'Minimum 5 characters required',
maxLength: (v) => v.length <= 20 || 'Maximum 20 characters allowed',
noSpecialChars: (v) => v.split('').every(c =>
"abcdefghijklmnopqrstuvwxyz0-9".includes(c.toLowerCase())
) || 'Only letters and numbers allowed',
noAdmin: (v) => !v.toLowerCase().includes('admin') || 'Cannot contain "admin"'
}
})}
placeholder="Username"
/>
{errors.username && <span>{errors.username.message}</span>}
<input
type="password"
{...register('password', {
validate: {
min8: (v) => v.length >= 8 || 'Minimum 8 characters',
hasUpper: (v) => /[A-Z]/.test(v) || 'Must have uppercase letter',
hasLower: (v) => /[a-z]/.test(v) || 'Must have lowercase letter',
hasNumber: (v) => /\d/.test(v) || 'Must have a number',
hasSpecial: (v) => /[!@#$%^&*]/.test(v) || 'Must have special character',
noUsername: (v) => !v.toLowerCase().includes(username?.toLowerCase()) ||
'Password cannot contain username',
notCommon: (v) => !['password', '12345678', 'qwerty123'].includes(v.toLowerCase()) ||
'Password too common'
}
})}
placeholder="Password"
/>
{errors.password && <span>{errors.password.message}</span>}
<input
type="password"
{...register('confirmPassword', {
validate: (v) => v === password || 'Passwords must match'
})}
placeholder="Confirm Password"
/>
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Register</button>
</form>
);
}🔧 Next.js Integration
App Router (Next.js 13+)
'use client';
import { useForm } from 'sndp-easy-form';
export default function ContactPage() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = async (data) => {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
alert('Message sent successfully!');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name', { required: 'Name is required' })} />
{errors.name && <p>{errors.name.message}</p>}
<textarea {...register('message', { required: 'Message is required' })} />
{errors.message && <p>{errors.message.message}</p>}
<button type="submit">Send</button>
</form>
);
}Pages Router (Next.js 12 and below)
import { useForm } from 'sndp-easy-form';
export default function ContactPage() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = async (data) => {
// Handle form submission
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Your form fields */}
</form>
);
}🎨 TypeScript Support
import { useForm } from 'sndp-easy-form';
interface FormData {
username: string;
email: string;
age: number;
terms: boolean;
}
function TypedForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormData>({
defaultValues: {
username: '',
email: '',
age: 0,
terms: false
}
});
const onSubmit = (data: FormData) => {
console.log(data);
// TypeScript knows the exact shape of data
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username', { required: true })} />
<input {...register('email', { required: true })} />
<input type="number" {...register('age', { min: 18 })} />
<input type="checkbox" {...register('terms', { required: true })} />
<button type="submit">Submit</button>
</form>
);
}📊 Comparison with Other Libraries
| Feature | sndp-easy-form | react-hook-form | formik | |---------|----------------|-----------------|--------| | Bundle Size | ~9kb | ~8.5kb | ~15kb | | TypeScript | ✅ Full | ✅ Full | ✅ Full | | Uncontrolled | ✅ | ✅ | ❌ | | Performance | ⚡ Excellent | ⚡ Excellent | 🐢 Good | | Learning Curve | 📚 Easy | 📚 Easy | 📚 Medium | | Field Arrays | ✅ | ✅ | ✅ | | Next.js Support | ✅ | ✅ | ✅ |
🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
📄 License
MIT © sndp bag
🐛 Bug Reports & Feature Requests
If you encounter any bugs or have feature requests, please create an issue on GitHub.
📧 Contact
For questions or support, reach out to: sndp bag
Made with ❤️ by sndp bag
