Form validation with Yup, react-hook-form, and i18next often starts simple—and quickly turns messy.
Every rule needs a translated message, leading to repetitive t('validation.required') calls all over your schemas.
There’s a better way.
The key is Yup’s global setLocale configuration. It lets you define all validation messages once and automatically apply them everywhere — fully translated and consistent.
The Problem (What usually goes wrong)
const schema = yup.object({
email: yup
.string()
.required(t('validation.required'))
.email(t('validation.email')),
password: yup
.string()
.required(t('validation.required'))
.min(8, t('validation.minLength', { min: 8 })),
});
Issues:
- ❌ Repetitive translation calls
- ❌ Hard to maintain
- ❌ Easy to forget translations
- ❌ Inconsistent wording across forms
In the project I am working on we had the same Problem. Even worse we did a mix of a lot of different things to display the error message which resulted in a big unmaintainable mess.
Here is how you can solve it!
The Solution: Global Yup Locale + i18next
1️⃣ Configure Yup once (with translations)
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as yup from 'yup';
export const useYupLocale = () => {
const { t, i18n } = useTranslation();
useEffect(() => {
yup.setLocale({
mixed: {
required: t('validation.required'),
notType: t('validation.invalid'),
},
string: {
email: t('validation.email'),
min: t('validation.minLength', { min: '${min}' }),
max: t('validation.maxLength', { max: '${max}' }),
url: t('validation.url'),
},
number: {
min: t('validation.numberMin', { min: '${min}' }),
max: t('validation.numberMax', { max: '${max}' }),
integer: t('validation.integer'),
positive: t('validation.positive'),
},
});
}, [i18n.language]);
};
✅ Yup automatically replaces ${min} / ${max}
✅ Messages update instantly when the language changes
2️⃣ Translation files
// en.json
{
"validation": {
"required": "This field is required",
"email": "Please enter a valid email address",
"minLength": "Must be at least ${min} characters",
"maxLength": "Must be no more than ${max} characters",
"integer": "Must be a whole number"
}
}
// de.json
{
"validation": {
"required": "Dieses Feld ist erforderlich",
"email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"minLength": "Mindestens ${min} Zeichen",
"maxLength": "Höchstens ${max} Zeichen",
"integer": "Muss eine ganze Zahl sein"
}
}
3️⃣ Initialize once at app level
export const App = () => {
useYupLocale();
return <YourApp />;
};
The Result: Clean, readable schemas
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8),
age: yup.number().required().min(18).integer(),
});
No translations.
No duplication.
Still fully localized.
Custom messages? Still possible
confirmPassword: yup
.string()
.required(t('validation.passwordMatch'))
Benefits
- ✅ DRY – define messages once
- ✅ Consistent validation across all forms
- ✅ Easy maintenance (change text in one place)
- ✅ Automatic language switching
- ✅ Much cleaner schemas
- ✅ Better developer experience
Takeaway
If you use Yup + i18next, configuring Yup’s global locale is the cleanest way to get translated, consistent, and maintainable validation errors.
You focus on validation logic.
Yup and i18next handle the messages.
Once you try this pattern, you won’t go back.
Top comments (0)