npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@valbuild/next

v0.134.1

Published

Val NextJS: hard-coded content - super-charged

Readme

Table of contents

Installation

Using the NextJS starter templates

If you're starting from scratch, the easiest way to get up and running with Val and NextJS is to use npm (or similar) create script:

npm create @valbuild

Integrating into an existing project

  • Make sure you have NextJS (version 14 or higher) installed

  • Install the packages:

npm install @valbuild/core@latest @valbuild/next@latest
  • Optionally, but recommend add the eslint-plugin package:
npm install -D  @valbuild/eslint-plugin@latest
  • Run the init script:
npx @valbuild/init@latest

Manually

It is also possible to setup Val using the manual configuration guide.

Additional setup

  • If you have a monorepo, or have a project where the project is located in a subdirectory relative to the github repository see the monorepos section
  • See formatting published content if you use prettier (or similar) Val to do it as well.
  • If you want editors to update content in production, read up on how to setup remote mode.

Getting started

Create your first Val content file

Content in Val is always defined in .val.ts (or .js) files.

NOTE: the init script will generate an example Val content file (unless you opt out of it).

Val content files are evaluated by Val, therefore they need to abide a set of requirements. If you use the eslint plugins these requirements will be enforced. You can also validate val files using the @valbuild/cli: npx -p @valbuild/cli val validate.

For reference these requirements are:

  • they must export a default content definition (c.define) where the first argument equals the path of the file relative to the val.config file; and
  • they must be declared in the val.modules file; and
  • they must have a default export that is c.define; and
  • they can only import Val related files or types (using import type { MyType } from "./otherModule.ts")

Val content file example

// ./examples/val/example.val.ts
import { s /* s = schema */, c /* c = content */ } from "../../val.config";

/**
 * This is the schema for the content. It defines the structure of the content and the types of each field.
 */
export const schema = s.object({
  /**
   * Basic text field
   */
  text: s.string(),
});

/**
 * This is the content definition. Add your content below.
 *
 * NOTE: the first argument is the path of the file.
 */
export default c.define("/examples/val/example.val.ts", schema, {
  text: "Basic text content",
});

The val.modules file

Once you have created your Val content file, it must be declared in the val.modules.ts (or .js) file in the project root folder.

Example:

import { modules } from "@valbuild/next";
import { config } from "./val.config";

export default modules(config, [
  // Add your modules here
  { def: () => import("./examples/val/example.val") },
]);

Using Val in Client Components

In client components you can access your content with the useVal hook:

// ./app/page.tsx
"use client";
import { useVal } from "../val/val.client";
import exampleVal from "../examples/val/example.val";

export default function Home() {
  const { text } = useVal(exampleVal);
  return <main>{text}</main>;
}

Using Val in React Server Components

In React Server components you can access your content with the fetchVal function:

// ./app/page.tsx
"use server";
import { fetchVal } from "../val/val.rsc";
import exampleVal from "../examples/val/example.val";

export default async function Home() {
  const { text } = await fetchVal(exampleVal);
  return <main>{text}</main>;
}

Remote Mode

Enable remote mode to allow editors to update content online (outside of local development) by creating a project at admin.val.build.

NOTE: Your content remains yours. Hosting content from your repository does not require a subscription. However, to edit content online, a subscription is needed — unless your project is a public repository or qualifies for the free tier. Visit the pricing page for details.

WHY: Updating code involves creating a commit, which requires a server. We offer a hosted service for simplicity and efficiency, as self-hosted solutions takes time to setup and maintain. Additionally, the val.build team funds the ongoing development of this library.

Remote Mode Configuration

Once your project is set up in admin.val.build, configure your application to use it by setting the following:

Environment Variables

  • VAL_API_KEY: This is the API key used to authenticate server side API requests. You can find it under Settings in your project on admin.val.build.
  • VAL_SECRET: In addition to the VAL_API_KEY, you need to generate a random secret to secure communication between the UX client and your Next.js application. You can use any random string for this, but if you have openssl installed you can run the following command: openssl rand -hex 16

val.config Properties

Set these properties in the val.config file:

  • project: The fully qualified name of your project, formatted as <team>/<name>.
  • gitBranch: The Git branch your application uses. For Vercel, use VERCEL_GIT_COMMIT_REF.
  • gitCommit: The current Git commit your application is running on. For Vercel, use VERCEL_GIT_COMMIT_SHA.
  • root: Optional. The path to the val.config file. Typically empty or undefined. If the project folder is under web, root would be: /web.

Example val.config.ts

import { initVal } from "@valbuild/next";

const { s, c, val, config } = initVal({
  project: "myteam/myproject",
  //root: "/subdir", // only required for monorepos. Use the path where val.config is located. The path should start with /
  gitBranch: process.env.VERCEL_GIT_COMMIT_REF,
  gitCommit: process.env.VERCEL_GIT_COMMIT_SHA,
});

export type { t } from "@valbuild/next";
export { s, c, val, config };

Previewing unpublished pages

An editor who creates a page in the Val editor has not published it yet: the new route exists only as a pending change. Your app knows nothing about it, so opening that URL hits a page.tsx whose content lookup finds no such key — and a page component that answers a missing key with notFound() shows the editor a 404 for the page they just made.

suspend on ValProvider is what makes that page load. Set it when the editor should be able to preview unpublished pages:

// ./app/layout.tsx
<ValProvider config={config} suspend>
  <ValModulesClient />
  {children}
</ValProvider>

With it set, a component reading content through useVal, useValRoute, fetchVal or fetchValRoute suspends until the editor's pending changes have been applied, instead of resolving against published content and rendering something the editor did not ask for. Put a Suspense boundary where you want the loading state to show; without one, Next uses the nearest loading.tsx.

It only ever waits for editors. Nothing here runs for a visitor: the gate is behind the same val_enable cookie the editor overlay is, so a normal request makes no extra requests, waits for nothing, and renders exactly as it would without suspend. It is safe to leave on in production, which is the point — previewing unpublished content is something editors do against the deployed site.

What it costs an editor. Pending changes come from the editor's browser, so a page opened with the editor active waits for them to arrive before it renders. That is the trade: a short wait instead of published content flashing up and being replaced, or a 404 for a page that exists.

Known limitation. A page whose route exists only in an unpublished change can still 404 on the very first render: suspend is activated in the browser after hydration, and the render before that resolves against published content. A page that calls notFound() at that point cannot recover, since the response has already been sent. Reloading the page in the editor works around it. Pages that read content for a route they already have — the common case, editing an existing page — are not affected.

Formatting published content

If you are using prettier or another code formatting tool, it is recommended to setup formatting of code after changes have been applied.

Setting up formatting using Prettier

  • Install prettier as RUNTIME dependency, by moving the prettier dependency from devDependencies to dependencies. The reason you need to do this, is that Val will be using it at runtime in production, and it has to be part of your build for this to work.

  • Optionally create a .prettierrc.json file unless you have one already. We recommend doing this, so that you can be sure that formatting is applied consistently in both your development environment and by Val. You can set this to be an empty object, if you are want to keep using prettiers defaults:

    {}
  • Add a formatter to the /val/val.server:

    formatter: (code: string, filePath: string) => {
      return prettier.format(code, {
        filepath: filePath,
        ...prettierOptions, // <- use the same rules as in development
      } as prettier.Options);
    },

    Unless you have any modifications in your val.server file, the complete file should now look like this:

    import "server-only";
    import { initValServer } from "@valbuild/next/server";
    import { config } from "../val.config";
    import { draftMode } from "next/headers";
    import valModules from "../val.modules";
    import prettier from "prettier";
    import prettierOptions from "../.prettierrc.json";
    
    const { valNextAppRouter } = initValServer(
      valModules,
      { ...config },
      {
        draftMode,
        formatter: (code: string, filePath: string) => {
          return prettier.format(code, {
            filepath: filePath,
            ...prettierOptions, // <- use the same rules as in development
          } as prettier.Options);
        },
      },
    );
    
    export { valNextAppRouter };

You should now be able to hit the save button locally and see prettier rules being applied.

Other formatters

Val is formatter agnostic, so it is possible to use the same flow as the one described for prettier above to any formatter you might want to use.

NOTE: this will be applied at runtime in production so you need make sure that the formatting dependencies are in the dependencies section of your package.json

Monorepos

Val supports projects that are not under the root path in GitHub, and therefore monorepos. To configure your project for monorepos, you can use the root parameter described in the config section.

Schema types

String

import { s } from "./val.config";

s.string(); // <- Schema<string>

s.string().multiline(); // edited in a growing text box, not a single-line input

Number

import { s } from "./val.config";

s.number(); // <- Schema<number>

Boolean

import { s } from "./val.config";

s.boolean(); // <- Schema<boolean>

Nullable

All schema types can be nullable (optional). A nullable schema creates a union of the type and null.

import { s } from "./val.config";

s.string().nullable(); // <- Schema<string | null>

.nullable() can go before or after .validate(...) — a validator declared on either side of it is kept, and runs on null too, so the validator decides for itself what an unset value means:

s.string()
  .nullable()
  .validate((val) => (val === null ? "Please fill this in" : false));

Read-only and hidden fields

.readonly() renders a field disabled in the Val editor, and .hidden() leaves it out of the editor entirely. Both are UI-only: the value is still stored, validated and serialized as normal.

Both take an optional flag which defaults to true, so .readonly() and .readonly(true) are the same thing. Pass it when the decision comes from a variable rather than being written out:

import { s } from "./val.config";

s.string().readonly(); // same as .readonly(true)
s.string().readonly(!canEdit);
s.image().hidden(hideMedia);

Description

All schema types can be given a human-readable description with .describe(text). Descriptions are shown in the Val editor UI as muted helper text under the field label, helping editors understand what a field is for without leaving the page.

import { s } from "./val.config";

s.object({
  name: s.string().describe("The author's full name"),
}).describe("Author of the blog post");

For records, calling .describe() on the key schema labels the record keys in the editor (useful when the key is something like an email or slug that benefits from extra context), while calling it on the value schema describes the entry itself.

s.record(
  s.string().describe("Email"),
  s.object({ name: s.string() }).describe("Author"),
);

.describe() can be combined freely with other modifiers and is preserved through .nullable(), .validate(), .minLength(), etc.:

s.string().describe("Slug").minLength(1).maxLength(64);

Pass null to clear a previously set description (useful when extending a base schema):

s.string().describe("Original").describe(null); // no description on the resulting schema

Array

s.array(t.string()); // <- Schema<string[]>

Record

The type of s.record is Record.

It is similar to an array, in that editors can add and remove items in it, however it has a unique key which can be used as, for example, the slug or as a part of an route.

NOTE: records can also be used with keyOf.

s.record(t.number()); // <- Schema<Record<string, number>>

Router

The router schema is a convenient shorthand for creating a record with router configuration. It combines s.record() and .router() into a single call.

import { s, c, nextAppRouter } from "../val.config";

const pageSchema = s.object({ title: s.string() });

// Using s.router() - shorthand
const pagesSchema = s.router(nextAppRouter, pageSchema);

// Equivalent to:
const pagesSchema = s.record(pageSchema).router(nextAppRouter);

Example:

import { s, c, nextAppRouter } from "../val.config";

const pageSchema = s.object({ title: s.string() });

// NOTE: to use router(nextAppRouter) - the module must be a sibling of the page.tsx
export default c.define(
  "/app/[slug]/page.val.ts",
  s.router(nextAppRouter, pageSchema),
  {
    "/test-page": {
      // This is the full pathname of the page - it must match the pattern of the Next JS route
      title: "Test page",
    },
  },
);

Object

s.object({
  myProperty: s.string(),
});

Page router (using .router() method)

You can configure Val to track your page structure and display content as navigable web pages in the editor interface.

For editors, this creates an intuitive page-based editing experience where they can navigate through your site structure and edit content directly within the context of each page.

You can use the .router method on record to achieve this.

When using the .router with the nextAppRouter, it will automatically generate routes based on your record keys and integrate seamlessly with Next.js App Router conventions.

Example:

import { s, c, nextAppRouter } from "../val.config";

const pageSchema = s.object({ title: s.string() });
const pagesSchema = s.record(pageSchema).router(nextAppRouter);

// NOTE: to use router(nextAppRouter) - the module must be a sibling of the page.tsx. In other words it must with page.val.ts or page.val.js
export default c.define("/app/[slug]/page.val.ts", pageSchema, {
  "/test-page": {
    // This is the full pathname of the page - it must match the pattern of the Next JS route of the page.tsx you are consuming this from
    title: "Test page",
  },
});

To consume a page route from a NextJS "page component", it is recommended you use fetchValRoute or useValRoute.

NOTE: a page an editor has created but not published yet is not in the record, so fetchValRoute / useValRoute return null for it and a page component that calls notFound() shows the editor a 404. See previewing unpublished pages.

NOTE: to be refactor proof (i.e. not break when changing the route), you should always provide the params of the NextJS page component.

Example fetchValRoute

export default async function MyPage({ params }: { params:
 Promise<{ slug: string }> // use params: Promise<unknown> if this page route has no params
}) {
  const page = await fetchValRoute(pageVal, params)
  return <MyPageComponent {...page}>
}

Previews

A preview is how a VALUE is shown wherever the Val editor shows a preview of it rather than opening it: a row in a list, an entry in a keyOf dropdown, a search hit, a reference. Declare one with .preview() on the schema of the value being previewed, and return a title, and optionally a subtitle and an image.

Example

const pageSchema = s
  .object({ title: s.string(), image: s.image() })
  .preview(({ val }) => ({ title: val.title, image: val.image }));

// Every row of this record previews with the closure above
const pagesSchema = s.record(pageSchema).router(nextAppRouter);

The container reifies its rows by running each ITEM's closure, so an array works the same way:

const sectionsSchema = s.array(
  s
    .object({ heading: s.string(), body: s.string() })
    .preview(({ val }) => ({ title: val.heading, subtitle: val.body })),
);

A discriminated union with no preview of its own previews as the VARIANT the value takes, so a page-builder list previews each block by its own block type.

Your function is run on demand, for the rows actually on screen, so it is fine for it to read into the value's content.

Changed in the release that added .render({ as: "inline" }). A .preview() on s.array(...) / s.record(...) used to describe the container's ROWS; it now describes the container ITSELF as a value, for when it is someone else's item. Move the closure onto the item schema. The record closure no longer receives key — derive the title from val. And .jsonValues() must come before .preview(...), like .validate(...).

Multi-line strings and code

A string that holds more than one line says so with .multiline(): the editor gives it a growing text box instead of a single-line input. Nothing else changes — the value is a plain string.

const articleSchema = s.object({
  title: s.string(),
  // A multi-line box instead of a single-line input
  summary: s.string().multiline(),
  // A syntax-highlighted code editor
  snippet: s.code({ language: "typescript" }),
});

s.code() is its own schema type, edited in a code editor. Its language option — typescript, javascript, json, html, css, markdown, python, sql and others; see CodeLanguage in @valbuild/core for the full list — decides the syntax highlighting. Leave it out for a plain monospaced editor with no highlighting.

The value is a string like any other, with one difference: a code value is never stega encoded, so what reaches your app is exactly what was written. Invisible characters are an edit tag in prose and corruption in source code.

Breaking. s.string().render({ as: "textarea" }) and s.string().render({ as: "code", language }) have been removed. Neither was about layout: whether a string may hold line breaks is a fact about the content, and a language is part of what the value is.

s.string().multiline(); // was .render({ as: "textarea" })
s.code({ language: "typescript" }); // was .render({ as: "code", language })

.render(...) now takes only { as: "inline" }, on every field alike.

Field rendering

A render is how ONE field is laid out in the editor when you are LOOKING at that field. It is static configuration rather than a function, and it is a different thing from a preview: a render is the field's own layout, a preview is how the value shows where it is navigable to. A schema can carry both, and a second .render(...) replaces the first rather than merging with it.

Editing list items in place

Every field takes .render({ as: "inline" }). On the ITEM of an array or record it means: edit the item right there in the (sortable) list row, instead of showing a preview row that navigates into it. This is what a page-builder list is made of.

const sectionsSchema = s.array(
  s.object({ title: s.string(), body: s.richtext() }).render({ as: "inline" }),
);

Breaking. Strings in arrays are no longer inlined implicitly. s.array(s.string()) now renders preview rows and its items are navigation stops, like every other item type. Add .render({ as: "inline" }) to the string schema for the old behavior:

s.array(s.string().render({ as: "inline" }));

RichText

This means that content will be accessible and according to spec out of the box. The flip-side is that Val will not support RichText that includes elements that is not part of the html 5 standard.

This opinionated approach was chosen since rendering anything, makes it hard for developers to maintain and hard for editors to understand.

RichText Schema

s.richtext({
  // options
});

Initializing RichText content

To initialize some text content using a RichText schema, you can use follow the example below:

import { s, c } from "./val.config";

export const schema = s.richtext({
  // styling
  bold: true, // enables bold
  italic: true, // enables italic text
  lineThrough: true, // enables line/strike-through
  // tags:
  //ul: true, // enables unordered lists
  //ol: true, // enables ordered lists
  // headings:
  h1: true,
  h2: true,
  // h3: true,
  // h4: true,
  // h5: true,
  // h6: true,
  //a: true, // enables links
  //img: true, // enables images
});

export default c.define("/src/app/content", schema, [
  {
    tag: "p",
    children: ["This is richtext"],
  },
  {
    tag: "p",
    children: [{ tag: "span", styles: ["bold"], children: ["Bold"] }, "text"],
  },
]);

Rendering RichText

You can use the ValRichText component to render content.

"use client";
import { ValRichText } from "@valbuild/next";
import contentVal from "./content.val";
import { useVal } from "./val/val.client";

export default function Page() {
  const content = useVal(contentVal);
  return (
    <main>
      <ValRichText
        theme={{
          bold: "font-bold", // <- maps bold to a class. NOTE: tailwind classes are supported
          //
        }}
        content={content}
      />
    </main>
  );
}

ValRichText: theme property

To add classes to ValRichText you can use the theme property:

<ValRichText
  theme={{
    p: "font-sans",
    // etc
  }}
  content={content}
/>

NOTE: if a theme is defined, you must define a mapping for every tag you can get. What tags you have is decided based on the options defined on the s.richtext() schema. For example: s.richtext({ bold: true }) requires that you add a bold theme.

<ValRichText
  theme={{
    h1: "text-4xl font-bold",
    bold: "font-bold",
    img: null, // either a string or null is required
  }}
  content={content}
/>

NOTE: the reason you must define themes for every tag that the RichText is that this will force you to revisit the themes that are used if the schema changes. The alternative would be to accept changes to the schema.

ValRichText: transform property

Vals RichText type maps RichText 1-to-1 with semantic HTML5.

If you want to customize / override the type of elements which are rendered, you can use the transform property.

<ValRichText
  transform={(node, _children, className) => {
    if (typeof node !== "string" && node.tag === "img") {
      return (
        <div className="my-wrapper-class">
          <img {...node} className={className} />
        </div>
      );
    }
    // if transform returns undefined the default render will be used
  }}
  content={content}
/>

The RichText type

The RichText type is actually an AST (abstract syntax tree) representing semantic HTML5 elements.

That means they look something like this:

type RichTextNode = {
  tag:
    | "img"
    | "a"
    | "ul"
    | "ol"
    | "h1"
    | "h2"
    | "h3"
    | "h4"
    | "h5"
    | "h6"
    | "br"
    | "p"
    | "li"
    | "span";
  classes: "bold" | "line-through" | "italic"; // all styling classes
  children: RichTextNode[] | undefined;
};

RichText: full custom

The RichText type maps 1-to-1 to HTML. That means it is straightforward to build your own implementation of a React component that renders RichText.

This example is a simplified version of the ValRichText component. You can use this as a template to create your own.

NOTE: before writing your own, make sure you check out the theme and transform properties on the ValRichText - most simpler cases should be covered by them.

export function ValRichText({
  content,
}: {
  content: RichText<MyRichTextOptions>;
}) {
  function build(
    node: RichTextNode<MyRichTextOptions>,
    key?: number,
  ): JSX.Element | string {
    if (typeof node === "string") {
      return node;
    }
    // you can map the classes to something else here
    const className = node.classes.join(" ");
    const tag = node.tag; // one of: "img" | "a" | "ul" | "ol" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "br" | "p" | "li" | "span"

    // Example of rendering img with MyOwnImageComponent:
    if (tag === "img") {
      return <MyOwnImageComponent {...node} />;
    }
    return React.createElement(
      tag,
      {
        key,
        className,
      },
      "children" in node ? node.children.map(build) : null,
    );
  }
  return <div {...val.attrs(content)}>{content.map(build)}</div>;
}
type MyRichTextOptions = AnyRichTextOptions; // you can reduce the surface of what you need to render, by restricting the `options` in `s.richtext(options)`

Image

Image Schema

s.image();

Initializing image content

Local images must be stored under the /public/val folder.

import { s, c } from "../val.config";

export const schema = s.image();

export default c.define("/image", schema, {
  path: "/public/myfile.jpg",
});

NOTE: This will not validate, since images requires width, height and mimeType. You can fix validation errors like this by using the CLI or by using the VS Code plugin.

Rendering images

The ValImage component is a wrapper around next/image that accepts a Val Image type.

You can use it like this:

const content = useVal(contentVal); // schema of contentVal: s.object({ image: s.image() })

return <ValImage src={content.image} />;

Using images in components

Images are transformed to object that have a url property which can be used to render them.

Example:

// in a Functional Component
const image = useVal(imageVal);

return <img src={image.url} />;

Discriminated Union

A discriminated union is a union of objects which all have the same field (of the same type). This field determines (or "discriminates") which of the union's types a value is.

It is useful when editors should be able to choose from a set of objects that are different.

Example: let us say you have a page that can be one of the following: blog (page) or product (page). In this case your schema could look like this:

s.discriminatedUnion(
  "type", // the key of the "discriminator"
  s.object({
    type: s.literal("blogPage"), // <- each type must have a UNIQUE value
    author: s.string(),
    // ...
  }),
  s.object({
    type: s.literal("productPage"),
    sku: s.number(),
    // ...
  }),
); // <- Schema<{ type: "blogPage", author: string } | { type: "productPage", sku: number }>

Enum

Use s.enum for a fixed set of strings. It gives you a type-safe way to describe the valid values an editor can choose from, and it presents as a dropdown in Val Studio.

s.enum("one", "two"); // <- Schema<"one" | "two">

s.union is deprecated

s.union did both of these jobs, deciding which one you meant from its first argument. It still works, and produces exactly the schemas above, but name the one you mean instead:

s.union(s.literal("one"), s.literal("two")); // -> s.enum("one", "two")
s.union("type", pageA, pageB); // -> s.discriminatedUnion("type", pageA, pageB)

KeyOf

You can use keyOf to reference a key in a record of a Val module.

NOTE: currently you must reference keys in Val modules, you cannot reference keys of values nested inside a Val module. This is a feature on the roadmap.

const schema = s.record(s.object({ nested: s.record(s.string()) }));

export default c.define("/keyof.val.ts", schema, {
  "you-can-reference-me": {
    // <- this can be referenced
    nested: {
      "but-not-me": ":(", // <- this cannot be referenced
    },
  },
});

KeyOf Schema

import otherVal from "./other.val"; // NOTE: this must be a record

s.keyOf(otherVal);

Initializing keyOf

Using keyOf to reference content

const article = useVal(articleVal); // s.object({ author: s.keyOf(otherVal) })
const authors = useVal(otherVal); // s.record(s.object({ name: s.string() }))

const nameOfAuthor = authors[articleVal.author].name;

Route

The route schema represents a string that references a route path in your application. It can be used with include and exclude patterns to constrain which routes are valid.

Route Schema

s.route(); // <- Schema<string>

Route with patterns

You can use include and exclude to constrain valid routes using regular expressions:

// Only allow API routes
s.route().include(/^\/api\//);

// Exclude admin routes
s.route().exclude(/^\/admin\//);

// Combine both: API routes except internal ones
s.route()
  .include(/^\/api\//)
  .exclude(/^\/api\/internal\//);

Pattern semantics:

  • If only include is set: route must match the include pattern
  • If only exclude is set: route must NOT match the exclude pattern
  • If both are set: route must match include AND must NOT match exclude

Using routes

Routes are validated against router modules (records with .router() or s.router()). The route value must exist as a key in one of your router modules.

import { s, c } from "../val.config";

const linkSchema = s.object({
  label: s.string(),
  href: s.route().include(/^\//), // Only allow routes starting with /
});

export default c.define("/components/link.val.ts", linkSchema, {
  label: "Home",
  href: "/", // This must exist in a router module
});

Color

The color schema represents a color, stored as a CSS color string. Because the value is a plain CSS string, it can be used directly in a style attribute or set as a CSS custom property - there is nothing to convert in your components.

Color Schema

s.color(); // <- Schema<string>

Output format

The format option decides which CSS notation the color is stored in. It defaults to "hsl".

s.color(); // hsl(217.22 91.22% 59.8%)
s.color({ format: "hex" }); // #3b82f6
s.color({ format: "rgb" }); // rgb(59 130 246)
s.color({ format: "oklch" }); // oklch(0.6231 0.188 259.81)

The Val editor writes the value back in this format, so an editor who pastes #3b82f6 into a field declared as s.color() gets hsl(217.22 91.22% 59.8%) stored.

Transparency

Colors are fully opaque unless you opt into an alpha channel with alpha: true. A color with an alpha channel is a validation error in a field that does not allow it, so you can be sure an opaque color stays opaque.

s.color({ format: "hsl", alpha: true }); // hsl(217.22 91.22% 59.8% / 0.5)

With alpha: true the editor also gets an alpha slider next to the color picker.

Validation

Validation is lenient about the syntax and strict about the format:

  • both the modern and the legacy notation of the declared format are accepted, so hsl(0 100% 50%) and hsl(0, 100%, 50%) are both valid hsl values, and #f00 is a valid hex value
  • a color written in another format is an error which tells you the equivalent value in the right notation: Expected a color in the 'hsl' format (e.g. 'hsl(217.22 91.22% 59.8%)'), got '#ff0000'. Did you mean 'hsl(0 100% 50%)'?
  • named colors (red), lab(), color() and color-mix() are not supported

Colors can be nullable and can use .describe() and .validate() like any other schema:

s.color().nullable().describe("Optional highlight color");

s.color({ format: "hex" }).validate((color) => {
  if (color === "#000000") {
    return "Pure black is too harsh - pick a dark grey instead";
  }
  return false;
});

Initializing color content

import { s, c, type t } from "../val.config";

export const schema = s.object({
  brand: s.color().describe("Primary brand color"),
  background: s.color({ format: "hex" }).describe("Page background"),
  overlay: s.color({ format: "hsl", alpha: true }).describe("Overlay tint"),
});

export type Theme = t.inferSchema<typeof schema>;
export default c.define("/content/theme.val.ts", schema, {
  brand: "hsl(217.22 91.22% 59.8%)",
  background: "#0b1020",
  overlay: "hsl(217.22 91.22% 59.8% / 0.15)",
});

Using colors

The value is a string, so use it wherever CSS expects a color:

import { fetchVal } from "../val/rsc";
import themeVal from "../content/theme.val";

export default async function Hero() {
  const theme = await fetchVal(themeVal);
  return (
    <section style={{ background: theme.background, color: theme.brand }}>
      <h1 style={{ borderBottom: `2px solid ${theme.brand}` }}>Hello</h1>
    </section>
  );
}

To hand a color to a stylesheet instead, set it as a CSS custom property:

<div style={{ "--brand": theme.brand } as React.CSSProperties}>

NOTE: colors are not steganographically tagged, since the value ends up in CSS where the invisible characters would break the declaration. Colors therefore do not participate in click-then-edit visual editing (the same is true of dates) - edit them from the studio instead.

Code

The code schema is a string edited in a code editor. It is a schema type of its own rather than a layout on s.string(), because the language is part of what the value is — and because a code value is never stega encoded, so what reaches your app is exactly what was written.

Code Schema

s.code(); // <- Schema<string>, a monospaced editor with no highlighting
s.code({ language: "typescript" }); // syntax highlighted

Languages

language is one of typescript, javascript, typescriptreact, javascriptreact, json, java, html, css, xml, markdown, sql, python, rust, php, go, cpp, sass, vue or angular — the CodeLanguage type exported from @valbuild/core. Leave it out for a plain monospaced editor.

The language decides the highlighting only: the content is never checked against it, since a half-written snippet is a normal thing to save an editor in. Use .validate() if you need more than that.

Initializing code content

import { s, c, type t } from "../val.config";

export const schema = s.object({
  snippet: s.code({ language: "typescript" }).describe("Example usage"),
  styles: s.code({ language: "css" }).nullable(),
});

export type Example = t.inferSchema<typeof schema>;
export default c.define("/content/example.val.ts", schema, {
  snippet: "const val = initVal();",
  styles: null,
});

Date

The date schema represents a calendar day, with no time and no timezone. It is stored as a YYYY-MM-DD string.

Date Schema

s.date(); // <- Schema<string>

Date bounds

Use .from() and .to() to constrain which days are valid. Both bounds are inclusive.

s.date().from("1900-01-01"); // this day or later
s.date().to("2024-01-01"); // this day or earlier
s.date().from("1900-01-01").to("2024-01-01"); // within the range

These are methods, not options - s.date({ from: "1900-01-01" }) does not type check.

Bounds are compared as strings, which is exactly right for YYYY-MM-DD (it sorts chronologically) and wrong for anything else, so write bounds in that format.

NOTE: the schema checks the bounds, but not the shape of the string: a value like "the 3rd of May" is stored and validated without complaint. The editor always writes YYYY-MM-DD, so this only bites hand-written content. Use .validate() if you want it enforced:

s.date().validate((day) =>
  /^\d{4}-\d{2}-\d{2}$/.test(day) ? false : "Must be a YYYY-MM-DD date",
);

Editing dates

Editors get a calendar. from and to limit which days can be picked, and a value already outside the bounds is shown clamped to the nearest one.

Initializing date content

import { s, c } from "../val.config";

export const schema = s.object({
  birthdate: s
    .date()
    .from("1900-01-01")
    .to("2024-01-01")
    .nullable()
    .describe("Author's birthdate"),
});

export default c.define("/content/author.val.ts", schema, {
  birthdate: "1981-12-30",
});

Using dates

The value is a plain string, so it can be compared and sorted as one:

const authors = [...allAuthors].sort((a, b) =>
  (a.birthdate ?? "").localeCompare(b.birthdate ?? ""),
);

To format it, hand it to Date or a date library. Note that new Date("2024-05-03") parses as UTC midnight, so formatting it in a local timezone west of UTC shows the previous day - format from the parts, or use a library that treats the value as a plain day:

const [year, month, day] = author.birthdate.split("-").map(Number);
const label = new Date(year, month - 1, day).toLocaleDateString();

DateTime

The dateTime schema represents an instant in time. It is stored as an ISO 8601 string in UTC, for example 2023-04-12T09:30:00.000Z.

DateTime Schema

s.datetime(); // <- Schema<string>

The factory is spelled datetime, all lowercase. The schema type it serializes to is dateTime - that name shows up in validation output and in the editor, but you never write it yourself.

DateTime bounds

As with date, use the inclusive .from() and .to() methods. They accept any ISO 8601 datetime that Date.parse understands, and are compared as instants rather than as strings, so bounds and values may be written in different notations:

s.datetime().from("2020-01-01T00:00:00Z");
s.datetime().from("2020-01-01T00:00:00Z").to("2030-12-31T23:59:59Z");

Unlike date, the value itself is checked: a string that Date.parse cannot read is a validation error.

Value 'yesterday' is not a valid ISO 8601 datetime

Editing datetimes

The editor shows a calendar, a time input (down to seconds) and a timezone picker. The picker starts on the browser's timezone and remembers the last choice, so an editor in one place can enter a time as it will be experienced somewhere else. Whichever zone is chosen, the value is converted and stored as UTC - the timezone is a property of the editor, never of the content.

Initializing datetime content

import { s, c } from "../val.config";

export const schema = s.object({
  joinedAt: s.datetime().nullable().describe("When the author joined"),
});

export default c.define("/content/author.val.ts", schema, {
  joinedAt: "2023-04-12T09:30:00.000Z",
});

Using datetimes

Since the value is an ISO 8601 UTC string, Date parses it directly:

<time dateTime={author.joinedAt}>
  {new Date(author.joinedAt).toLocaleString()}
</time>

Rendering a UTC instant in the visitor's local timezone means server and client can format it differently. In Next.js that shows up as a hydration mismatch, so format inside a client component (or pass a fixed timeZone to toLocaleString) when the exact time matters.

NOTE: neither date nor dateTime values are steganographically tagged, so they do not participate in click-then-edit visual editing - edit them from the studio instead. Both support .nullable(), .describe(), .validate(), .readonly() and .hidden() like any other schema.

Custom validation

All schema can use the validate method to show custom validation errors to editors.

Example

s.string().validate((val) => {
  if (val.startsWith("something")) {
    return "Cannot have something in this string";
  }
  return false; // no validation error
});

.validate(...) and .nullable() can be written in either order: the validator is carried through the copy .nullable() makes. On a nullable schema the value reaching the validator can be null, and it is handed over rather than skipped — so a validator declared before the .nullable() has to guard for it, since its argument is still typed as non-null there:

s.string()
  .validate((val) => (val !== null && val.length > 80 ? "Too long" : false))
  .nullable();

// Declared after, and the argument is typed `string | null`:
s.string()
  .nullable()
  .validate((val) => (val !== null && val.length > 80 ? "Too long" : false));

Not every modifier is order-free, though: a record's .jsonValues() changes the source shape, so it must come before .validate(...) and .preview(...), and throws with that message if it does not.

Get in touch

Join us on discord to get help or give us feedback.