diff --git a/examples/next/app/[locale]/blog/[blog]/en-us.val.ts b/examples/next/app/[locale]/blog/[blog]/en-us.val.ts
new file mode 100644
index 000000000..85d9ffb0a
--- /dev/null
+++ b/examples/next/app/[locale]/blog/[blog]/en-us.val.ts
@@ -0,0 +1,20 @@
+import { c } from "../../../../val.config";
+import { schema } from "./schema.val";
+
+export default c.define("/app/[locale]/blogs/[blog]/en-us.val.ts", schema, {
+ "/en-us/blogs/my-page": {
+ title: "My page",
+ author: "freekh",
+ content: [
+ {
+ tag: "p",
+ children: ["English content"],
+ },
+ ],
+ link: {
+ href: "/",
+ label: "Home",
+ },
+ translation: "/nb-no/blogs/min-side",
+ },
+});
diff --git a/examples/next/app/[locale]/blog/[blog]/nb-no.val.ts b/examples/next/app/[locale]/blog/[blog]/nb-no.val.ts
new file mode 100644
index 000000000..ddedfd7be
--- /dev/null
+++ b/examples/next/app/[locale]/blog/[blog]/nb-no.val.ts
@@ -0,0 +1,20 @@
+import { c } from "../../../../val.config";
+import { schema } from "./schema.val";
+
+export default c.define("/app/[locale]/blogs/[blog]/nb-no.val.ts", schema, {
+ "/nb-no/blogs/min-side": {
+ title: "Min side",
+ author: "thoram",
+ content: [
+ {
+ tag: "p",
+ children: ["Norsk innhold"],
+ },
+ ],
+ link: {
+ href: "/",
+ label: "Hjem",
+ },
+ translation: "/en-us/blogs/my-page",
+ },
+});
diff --git a/examples/next/app/[locale]/blog/[blog]/page.tsx b/examples/next/app/[locale]/blog/[blog]/page.tsx
new file mode 100644
index 000000000..d48bb231c
--- /dev/null
+++ b/examples/next/app/[locale]/blog/[blog]/page.tsx
@@ -0,0 +1,45 @@
+"use server";
+import { notFound } from "next/navigation";
+import { fetchVal, fetchValRoute } from "../../../../val/rsc";
+import Link from "next/link";
+import authorsVal from "../../../../content/authors.val";
+import { ValRichText } from "@valbuild/next";
+import enVal from "./en-us.val";
+import nbVal from "./nb-no.val";
+import { Blog } from "./schema.val";
+import translationsVal from "./translations.val";
+
+export default async function BlogPage({
+ params,
+}: {
+ params: Promise<{ blog: string; locale: string }>;
+}) {
+ const { locale } = await params;
+ const translations = await fetchVal(translationsVal);
+ const blog = await fetchValRoute([enVal, nbVal], params);
+
+ const authors = await fetchVal(authorsVal);
+ if (!blog) {
+ return notFound();
+ }
+ const author = authors[blog.author];
+ return (
+
+
{blog.title}
+
+
{blog.content}
+
{blog.link.label}
+ {blog.translations?.length > 0 && (
+
+ {translations[locale]?.translationCanBeFound}
+ {blog.translations.map((translation) => (
+
+ {translation}
+
+ ))}
+
+ )}
+ s
+
+ );
+}
diff --git a/examples/next/app/[locale]/blog/[blog]/schema.val.ts b/examples/next/app/[locale]/blog/[blog]/schema.val.ts
new file mode 100644
index 000000000..a928634c7
--- /dev/null
+++ b/examples/next/app/[locale]/blog/[blog]/schema.val.ts
@@ -0,0 +1,28 @@
+import authorsVal from "../../../../content/authors.val";
+import { nextAppRouter, s, t } from "../../../../val.config";
+
+const blogSchema = s.object({
+ title: s.string(),
+ translations: s.array(
+ s.object({
+ locale: s.string(),
+ key: s.string(),
+ }),
+ ),
+ author: s.keyOf(authorsVal),
+ content: s.richtext(),
+ link: s.object({
+ href: s.string(),
+ label: s.string(),
+ }),
+});
+
+export type Blog = t.inferSchema;
+
+export const schema = s.record(blogSchema).router(
+ nextAppRouter.localize({
+ moduleName: "locale",
+ segment: "locale",
+ translations: "translations",
+ }),
+);
diff --git a/examples/next/app/[locale]/blog/[blog]/translations.val.ts b/examples/next/app/[locale]/blog/[blog]/translations.val.ts
new file mode 100644
index 000000000..025fe785f
--- /dev/null
+++ b/examples/next/app/[locale]/blog/[blog]/translations.val.ts
@@ -0,0 +1,28 @@
+import { c, s } from "../../../../val.config";
+
+export default c.define(
+ "/app/[locale]/blog/[blog]/translations.val.ts",
+ s
+ .record(
+ s.object({
+ "en-us": s.string(),
+ "nb-no": s.string(),
+ "translation-is-available-in": s.string(),
+ }),
+ )
+ .keys({ locale: { required: ["en-us", "nb-no"] } }),
+ {
+ "en-us": {
+ "en-us": "English",
+ "nb-no": "Norwegian",
+ "translation-is-available-in":
+ "This blog is also available in the following languages:",
+ },
+ "nb-no": {
+ "en-us": "Engelsk",
+ "nb-no": "Norsk",
+ "translation-is-available-in":
+ "Denne bloggen er også tilgjengelig på følgende språk:",
+ },
+ },
+);
diff --git a/examples/next/app/blogs/[blog]/page.tsx b/examples/next/app/blogs/[blog]/page.tsx
deleted file mode 100644
index 9c1436a7a..000000000
--- a/examples/next/app/blogs/[blog]/page.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use server";
-import { notFound } from "next/navigation";
-import { fetchVal, fetchValRoute } from "../../../val/rsc";
-import blogsVal from "./page.val";
-import Link from "next/link";
-import authorsVal from "../../../content/authors.val";
-import { ValRichText } from "@valbuild/next";
-
-export default async function BlogPage({
- params,
-}: {
- params: Promise<{ blog: string }>;
-}) {
- const blog = await fetchValRoute(blogsVal, params);
- const authors = await fetchVal(authorsVal);
- if (!blog) {
- return notFound();
- }
- const author = authors[blog.author];
- return (
-
-
{blog.title}
-
-
{blog.content}
-
{blog.link.label}
-
- );
-}
diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts
deleted file mode 100644
index 3017a8327..000000000
--- a/examples/next/app/blogs/[blog]/page.val.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { c, nextAppRouter, s } from "_/val.config";
-import authorsVal from "../../../content/authors.val";
-import { linkSchema } from "../../../components/link.val";
-
-const blogSchema = s.object({
- title: s.string(),
- content: s.richtext(),
- author: s.keyOf(authorsVal),
- get link() {
- return linkSchema;
- },
-});
-
-export default c.define(
- "/app/blogs/[blog]/page.val.ts",
- s.record(blogSchema).router(nextAppRouter),
- {
- "/blogs/blog2": {
- title: "Blog 2",
- content: [
- {
- tag: "p",
- children: ["Blog 2 content"],
- },
- ],
- author: "freekh",
- link: {
- label: "Read more",
- type: "blog",
- href: "/blogs/blog1",
- },
- },
- "/blogs/blog1": {
- title: "Blog 1",
- content: [
- {
- tag: "p",
- children: ["Blog 1 content"],
- },
- ],
- author: "freekh",
- link: {
- label: "See more",
- type: "generic",
- href: "/generic/test/foo",
- },
- },
- },
-);
diff --git a/examples/next/components/link.val.ts b/examples/next/components/link.val.ts
index 605e74650..fe05a6293 100644
--- a/examples/next/components/link.val.ts
+++ b/examples/next/components/link.val.ts
@@ -1,5 +1,5 @@
import { s } from "../val.config";
-import blogsVal from "../app/blogs/[blog]/page.val";
+import blogsVal from "../app/[locale]/blog/[blog]/page.val";
import genericPageVal from "../app/generic/[[...path]]/page.val";
import { Schema } from "@valbuild/core";
import pageVal from "../app/page.val";
diff --git a/examples/next/val.config.ts b/examples/next/val.config.ts
index bb601ca86..7d8a3b927 100644
--- a/examples/next/val.config.ts
+++ b/examples/next/val.config.ts
@@ -3,7 +3,6 @@ import { initVal } from "@valbuild/next";
const { s, c, val, config, nextAppRouter } = initVal({
project: "valbuild/val-examples-next",
root: "/examples/next",
- defaultTheme: "dark",
});
export type { t } from "@valbuild/next";
diff --git a/examples/next/val.modules.ts b/examples/next/val.modules.ts
index 685a27ebe..a39feacec 100644
--- a/examples/next/val.modules.ts
+++ b/examples/next/val.modules.ts
@@ -3,7 +3,8 @@ import { config } from "./val.config";
export default modules(config, [
{ def: () => import("./content/authors.val") },
- { def: () => import("./app/blogs/[blog]/page.val") },
+ { def: () => import("./app/[locale]/blog/[blog]/en-us.val") },
+ { def: () => import("./app/[locale]/blog/[blog]/nb-no.val") },
{ def: () => import("./app/generic/[[...path]]/page.val") },
{ def: () => import("./app/page.val") },
]);
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8dca9b034..8dbcf6f7b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -128,7 +128,7 @@ export {
type ListArrayRender,
type ReifiedRender,
} from "./render";
-export type { ValRouter, RouteValidationError } from "./router";
+export type { ValRouter, NextAppRouter, RouteValidationError } from "./router";
import { nextAppRouter } from "./router";
export const FATAL_ERROR_TYPES = [
diff --git a/packages/core/src/locale.ts b/packages/core/src/locale.ts
new file mode 100644
index 000000000..ff75e8b13
--- /dev/null
+++ b/packages/core/src/locale.ts
@@ -0,0 +1,6 @@
+export function validateLocale(locale: string): false | string {
+ if (locale.match(/^[a-z]{2}-[a-z]{2}$/)) {
+ return false;
+ }
+ return "Invalid locale format. Must be two lower case letters for language and two lowercase letters for country, separated by a hyphen. Expected format: xx-xx (e.g. en-us, nb-no)";
+}
diff --git a/packages/core/src/router.test.ts b/packages/core/src/router.test.ts
index e8b7f6a5a..9019637f4 100644
--- a/packages/core/src/router.test.ts
+++ b/packages/core/src/router.test.ts
@@ -1,4 +1,8 @@
-import { parseNextJsRoutePattern } from "./router";
+import { NextAppRouterImpl, parseNextJsRoutePattern } from "./router";
+import { object } from "./schema/object";
+import { record } from "./schema/record";
+import { string } from "./schema/string";
+import { ModuleFilePath } from "./val";
describe("parseNextJsRoutePattern", () => {
describe("App Router patterns", () => {
@@ -155,4 +159,23 @@ describe("parseNextJsRoutePattern", () => {
).toEqual(["docs", "[...slug]"]);
});
});
+
+ describe("Localization", () => {
+ const router = new NextAppRouterImpl(
+ record(object({ title: string() })),
+ ).localize({
+ type: "directory",
+ segment: "locale",
+ translation: "translation",
+ });
+
+ test("validate", () => {
+ expect(
+ router.validate(
+ "/app/[locale]/blogs/[blog]/page.val.ts" as ModuleFilePath,
+ ["/en/blogs/test"],
+ ),
+ ).toEqual([]);
+ });
+ });
});
diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts
index acdfc0d3b..24725cd63 100644
--- a/packages/core/src/router.ts
+++ b/packages/core/src/router.ts
@@ -1,3 +1,5 @@
+import { Schema } from "./schema";
+import { SelectorSource } from "./selector";
import { ModuleFilePath } from "./val";
export type RouteValidationError = {
@@ -126,10 +128,52 @@ function validateUrlAgainstPattern(
return { isValid: true };
}
-// This router should not be in core package
-export const nextAppRouter: ValRouter = {
- getRouterId: () => "next-app-router",
- validate: (moduleFilePath, urlPaths) => {
+// Next App router should not be in core package (it should be in the next package)
+export class NextAppRouterImpl implements NextAppRouter {
+ constructor(
+ private readonly schema: Schema | null = null,
+ private readonly thisLocalize: {
+ type: "directory";
+ segment: string;
+ translation: string;
+ } | null = null,
+ ) {}
+
+ withSchema(schema: Schema): NextAppRouter {
+ return new NextAppRouterImpl(schema, this.thisLocalize);
+ }
+ getRouterId(): string {
+ return "next-app-router";
+ }
+ localize(input: {
+ type: "directory";
+ segment: string;
+ translation: string;
+ }): NextAppRouter {
+ return new NextAppRouterImpl(this.schema, input);
+ }
+
+ validate(
+ moduleFilePath: ModuleFilePath,
+ urlPaths: string[],
+ ): RouteValidationError[] {
+ if (this.thisLocalize) {
+ this.routerLocalizedValidate(moduleFilePath, urlPaths);
+ }
+ return this.routerValidate(moduleFilePath, urlPaths);
+ }
+
+ private routerLocalizedValidate(
+ moduleFilePath: ModuleFilePath,
+ urlPaths: string[],
+ ): RouteValidationError[] {
+ return this.routerValidate(moduleFilePath, urlPaths);
+ }
+
+ private routerValidate(
+ moduleFilePath: ModuleFilePath,
+ urlPaths: string[],
+ ): RouteValidationError[] {
const routePattern = parseNextJsRoutePattern(moduleFilePath);
const errors: RouteValidationError[] = [];
@@ -148,8 +192,10 @@ export const nextAppRouter: ValRouter = {
}
return errors;
- },
-};
+ }
+}
+
+export const nextAppRouter: NextAppRouter = new NextAppRouterImpl();
/**
* Parse Next.js route pattern from file path
@@ -209,4 +255,54 @@ export interface ValRouter {
moduleFilePath: ModuleFilePath,
urlPaths: string[],
): RouteValidationError[];
+ withSchema(schema: Schema): ValRouter;
+}
+
+export interface NextAppRouter extends ValRouter {
+ /**
+ * Configure localization for this router.
+ *
+ * When localized, the file organization changes from a single `page.val.ts` file to
+ * separate `.val.ts` files for each locale (e.g., `en-us.val.ts`, `nb-no.val.ts`).
+ *
+ * @param input - Localization configuration
+ * @param input.moduleName - Must be `"locale"` to indicate locale-based filenames (e.g., `en-us.val.ts`, `nb-no.val.ts`)
+ * @param input.segment - The URL segment name for the locale (e.g., `"locale"` for `/[locale]/...`)
+ * @param input.translations - The field name in your schema that links translations across locales.
+ * This field should contain the URL path to the translation in another locale.
+ *
+ * @example
+ * ```typescript
+ * // In schema.val.ts
+ * export const schema = s.record(blogSchema).router(
+ * nextAppRouter.localize({
+ * moduleName: "locale",
+ * segment: "locale",
+ * translations: "translations",
+ * })
+ * );
+ *
+ * // Creates locale-specific files:
+ * // - en-us.val.ts
+ * // - nb-no.val.ts
+ * // - fr-fr.val.ts
+ *
+ * // In en-us.val.ts
+ * export default c.define("/app/[locale]/blogs/[blog]/en-us.val.ts", schema, {
+ * "/en-us/blogs/my-post": {
+ * title: "My Post",
+ * translations: ["/nb-no/blogs/min-post", "/fr-fr/blogs/mon-post"], // or path to translated version
+ * },
+ * });
+ * ```
+ *
+ * @remarks
+ * Locale identifiers must follow BCP 47 format in lowercase (e.g., `en-us`, `nb-no`, `fr-fr`)
+ * to ensure web-safe file names and URL paths.
+ */
+ localize: (input: {
+ moduleName: "locale";
+ segment: string;
+ translations?: string;
+ }) => NextAppRouter;
}
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index a05b665c8..37e59334d 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -83,6 +83,7 @@ export abstract class Schema {
path: SourcePath,
src: Src,
): ValidationErrors;
+
protected executeCustomValidateFunctions(
src: Src,
customValidateFunctions: CustomValidateFunction[],
diff --git a/packages/core/src/schema/record.ts b/packages/core/src/schema/record.ts
index 66f4d2daa..b21e96b3a 100644
--- a/packages/core/src/schema/record.ts
+++ b/packages/core/src/schema/record.ts
@@ -21,12 +21,14 @@ import {
ValidationError,
ValidationErrors,
} from "./validation/ValidationError";
+import { validateLocale } from "../locale";
export type SerializedRecordSchema = {
type: "record";
item: SerializedSchema;
opt: boolean;
router?: string;
+ locale?: string | null;
customValidate?: boolean;
};
@@ -39,6 +41,7 @@ export class RecordSchema<
private readonly opt: boolean = false,
private readonly customValidateFunctions: CustomValidateFunction[] = [],
private readonly currentRouter: ValRouter | null = null,
+ private readonly currentLocale: string | null = null,
) {
super();
}
@@ -91,6 +94,13 @@ export class RecordSchema<
if (routerValidations) {
return routerValidations;
}
+ if (this.currentLocale) {
+ const localeValidation = validateLocale(this.currentLocale);
+ if (localeValidation) {
+ const message = localeValidation;
+ error = this.appendValidationError(error, path, message, src, true);
+ }
+ }
for (const customValidationError of customValidationErrors) {
error = this.appendValidationError(
error,
@@ -177,7 +187,7 @@ export class RecordSchema<
this.item,
this.opt,
this.customValidateFunctions,
- router,
+ router.withSchema(this),
);
}
@@ -249,6 +259,7 @@ export class RecordSchema<
item: this.item["executeSerialize"](),
opt: this.opt,
router: this.currentRouter?.getRouterId(),
+ locale: this.currentLocale,
customValidate:
this.customValidateFunctions &&
this.customValidateFunctions?.length > 0,
diff --git a/packages/next/src/initVal.ts b/packages/next/src/initVal.ts
index 8a7e1f8e8..d549949b9 100644
--- a/packages/next/src/initVal.ts
+++ b/packages/next/src/initVal.ts
@@ -7,7 +7,7 @@ import {
SelectorSource,
Json,
Internal,
- ValRouter,
+ type NextAppRouter,
} from "@valbuild/core";
import { raw } from "./raw";
import { getUnpatchedUnencodedVal } from "./getUnpatchedUnencodedVal";
@@ -16,7 +16,7 @@ import { ValEncodedString } from "./external_exempt_from_val_quickjs";
type ValAttrs = { "data-val-path"?: string };
-const nextAppRouter: ValRouter = Internal.nextAppRouter;
+const nextAppRouter: NextAppRouter = Internal.nextAppRouter;
export const initVal = (
config?: ValConfig,
@@ -39,7 +39,7 @@ export const initVal = (
attrs: | Json>(target: T) => ValAttrs;
unstable_decodeValPathOfString: typeof decodeValPathOfString;
};
- nextAppRouter: ValRouter;
+ nextAppRouter: NextAppRouter;
} => {
const { s, c, val, config: systemConfig } = createValSystem(config);
const currentConfig = {
diff --git a/packages/next/src/rsc/initValRsc.ts b/packages/next/src/rsc/initValRsc.ts
index 4637bccf7..5e4004e5e 100644
--- a/packages/next/src/rsc/initValRsc.ts
+++ b/packages/next/src/rsc/initValRsc.ts
@@ -188,7 +188,7 @@ const initFetchValRouteStega =
}>,
) =>
async >>(
- selector: T,
+ selector: T | T[],
params:
| Promise>
| Record
@@ -203,17 +203,20 @@ const initFetchValRouteStega =
getCookies,
);
const resolvedParams = await Promise.resolve(params);
- const path = selector && Internal.getValPath(selector);
- const schema = selector && Internal.getSchema(selector);
- const val = selector && (await fetchVal(selector));
- const route = initValRouteFromVal(
- resolvedParams,
- "fetchValRoute",
- path,
- schema,
- val,
- );
- return route;
+ for (const s of Array.isArray(selector) ? selector : [selector]) {
+ const path = s && Internal.getValPath(s);
+ const schema = s && Internal.getSchema(s);
+ const val = s && (await fetchVal(s));
+ const route = initValRouteFromVal(
+ resolvedParams,
+ "fetchValRoute",
+ path,
+ schema,
+ val,
+ );
+ return route;
+ }
+ return null as FetchValRouteReturnType;
};
const initFetchValRouteUrl =