Repository: Thinkmill/keystatic
Pull request #243 (2023-04-17 00:51:48)

Seperate parsing and validation in fields
This will enable some things in the future, for now the main impact for users is improved error reporting for invalid fields.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: aa9fdaedb33ec43e03cadb72c62ef9a06fa60cdf

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Thinkmill/keystatic/new/seperate-parsing-and-validation?filename=.changeset/little-eyes-destroy.md&value=---%0A%22%40voussoir%2Fdocs%22%3A%20patch%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0ASeperate%20parsing%20and%20validation%20in%20fields%0A)


vercel[bot]: [vc]: #ZXs8xLW0gHBD8Rrmk5gMR5or8gskt0W8YsBvNtbLOGQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5Ijoia2V5c3RhdGljIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC1sYWJzL2tleXN0YXRpYy83QXg3akxOcUE3dlh3aE1qcXp0Rm42am5Nem5IIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkcifSx7Im5hbWUiOiJ2b3Vzc29pciIsInJvb3REaXJlY3RvcnkiOiJkZXNpZ24tc3lzdGVtL2RvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpbmttaWxsLWxhYnMvdm91c3NvaXIvNENRQzdGQXQxdVAzb011Qmg1YmtZekN3SnFqbiIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **keystatic** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/keystatic/7Ax7jLNqA7vXwhMjqztFn6jnMznH)) |  | Apr 17, 2023 0:51am |
| **voussoir** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/voussoir/4CQC7FAt1uP3oMuBh5bkYzCwJqjn)) |  | Apr 17, 2023 0:51am |




Patch:
diff --git a/packages/keystatic/DocumentEditor/component-blocks/api.tsx b/packages/keystatic/DocumentEditor/component-blocks/api.tsx
index c218bd43..1b667118 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/api.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/api.tsx
@@ -3,14 +3,6 @@ import { Glob } from '../../config';
 
 import { ChildField } from './fields/child';
 
-export type FormFieldValue =
-  | string
-  | number
-  | boolean
-  | null
-  | readonly FormFieldValue[]
-  | { [key: string]: FormFieldValue | undefined };
-
 export type FormFieldInputProps<Value> = {
   value: Value;
   onChange(value: Value): void;
@@ -22,143 +14,170 @@ export type FormFieldInputProps<Value> = {
   forceValidation: boolean;
 };
 
-export type BasicFormField<Value> = {
+export type JsonValue =
+  | string
+  | number
+  | boolean
+  | null
+  | readonly JsonValue[]
+  | { [key: string]: JsonValue };
+
+type JsonValueWithoutNull = JsonValue & {};
+
+export type FormFieldStoredValue = JsonValueWithoutNull | undefined;
+
+export type BasicFormField<
+  ParsedValue extends {} | null,
+  ValidatedValue extends ParsedValue = ParsedValue,
+  ReaderValue = ValidatedValue
+> = {
   kind: 'form';
-  Input(props: FormFieldInputProps<Value>): ReactElement | null;
-  defaultValue: Value;
+  formKind?: undefined;
+  Input(props: FormFieldInputProps<ParsedValue>): ReactElement | null;
+  defaultValue(): ParsedValue;
+  parse(value: FormFieldStoredValue): ParsedValue;
   /**
-   * validate will be called in two cases:
-   * - on the client in the editor when a user is changing the value.
-   *   Returning `false` will block closing the form
-   *   and saving the item.
-   * - on the server when a change is received before allowing it to be saved
-   *   if `true` is returned
-   * @param value The value of the form field. You should NOT trust
-   * this value to be of the correct type because it could come from
-   * a potentially malicious client
+   * If undefined is returned, the field will generally not be written,
+   * except in array fields where it will be stored as null
    */
-  validate(value: unknown): boolean;
+  serialize(value: ParsedValue): { value: FormFieldStoredValue };
+  validate(value: ParsedValue): ValidatedValue;
+  reader: {
+    parse(value: FormFieldStoredValue): ReaderValue;
+  };
 };
 
-export type FormField<Value> =
-  | BasicFormField<Value>
-  | FormFieldWithFile<Value, any>
-  | SlugFormField<Value, any>;
-
-export type SlugFormField<Value, SerializedValue> = Omit<
-  BasicFormField<Value>,
-  'validate'
-> & {
-  slug: {
-    serialize(value: Value): { slug: string; value: SerializedValue };
-    parse(data: { slug: string; value: unknown }): Value;
+export type SlugFormField<
+  ParsedValue extends {} | null,
+  ValidatedValue extends ParsedValue,
+  ReaderValue,
+  ReaderValueAsSlugField
+> = {
+  kind: 'form';
+  formKind: 'slug';
+  Input(props: FormFieldInputProps<ParsedValue>): ReactElement | null;
+  defaultValue(): ParsedValue;
+  parse(
+    value: FormFieldStoredValue,
+    extra: { slug: string } | undefined
+  ): ParsedValue;
+
+  serialize(value: ParsedValue): { value: FormFieldStoredValue };
+  serializeWithSlug(value: ParsedValue): {
+    slug: string;
+    value: FormFieldStoredValue;
   };
   validate(
-    value: unknown,
-    slugFieldInfo: { slugs: Set<string>; glob: Glob } | undefined
-  ): boolean;
-};
-
-export type FormFieldWithFile<Value, DataInReader> =
-  | FormFieldWithFileRequiringContentsForReader<Value, DataInReader>
-  | FormFieldWithFileNotRequiringContentsForReader<Value, DataInReader>;
-
-export type FormFieldWithFileRequiringContentsForReader<Value, DataInReader> =
-  BasicFormField<Value> & {
-    serializeToFile:
-      | (BaseSerializeToSingleFile<Value> & {
-          reader: {
-            requiresContentInReader: true;
-            parseToReader(data: {
-              content: Uint8Array | undefined;
-              value: unknown;
-              suggestedFilenamePrefix: string | undefined;
-            }): DataInReader;
-          };
-        })
-      | (BaseSerializeToFiles<Value> & {
-          reader: {
-            requiresContentInReader: true;
-            parseToReader(data: {
-              value: unknown;
-              primary: Uint8Array | undefined;
-            }): DataInReader;
-          };
-        });
+    value: ParsedValue,
+    extra: { slugField: { slugs: Set<string>; glob: Glob } } | undefined
+  ): ValidatedValue;
+  reader: {
+    parse(value: FormFieldStoredValue): ReaderValue;
+    parseWithSlug(
+      value: FormFieldStoredValue,
+      extra: {
+        slug: string;
+        glob: Glob;
+      }
+    ): ReaderValueAsSlugField;
   };
-export type FormFieldWithFileNotRequiringContentsForReader<
-  Value,
-  DataInReader
-> = BasicFormField<Value> & {
-  serializeToFile:
-    | (BaseSerializeToSingleFile<Value> & {
-        reader: {
-          requiresContentInReader: false;
-          parseToReader(data: {
-            value: unknown;
-            suggestedFilenamePrefix: string | undefined;
-          }): DataInReader;
-        };
-      })
-    | (BaseSerializeToFiles<Value> & {
-        reader: {
-          requiresContentInReader: false;
-          parseToReader(data: { value: unknown }): DataInReader;
-        };
-      });
 };
 
-type BaseSerializeToSingleFile<Value> = {
-  kind: 'asset';
+export type AssetFormField<
+  ParsedValue extends {} | null,
+  ValidatedValue extends ParsedValue,
+  ReaderValue
+> = {
+  kind: 'form';
+  formKind: 'asset';
+  Input(props: FormFieldInputProps<ParsedValue>): ReactElement | null;
   directory?: string;
+  defaultValue(): ParsedValue;
   filename(
-    value: unknown,
-    suggestedFilenamePrefix: string | undefined,
-    slug: string | undefined
+    value: FormFieldStoredValue,
+    extra: {
+      suggestedFilenamePrefix: string | undefined;
+      slug: string | undefined;
+    }
   ): string | undefined;
+  parse(
+    value: FormFieldStoredValue,
+    extra: {
+      asset: Uint8Array | undefined;
+      slug: string | undefined;
+    }
+  ): ParsedValue;
   serialize(
-    value: Value,
-    suggestedFilenamePrefix: string | undefined,
-    slug: string | undefined
+    value: ParsedValue,
+    extra: {
+      suggestedFilenamePrefix: string | undefined;
+      slug: string | undefined;
+    }
   ): {
-    value: unknown;
-  } & (
-    | { content: undefined }
-    | {
-        content: Uint8Array;
-        filename: string;
-      }
-  );
-  parse(data: {
-    content: Uint8Array | undefined;
-    value: unknown;
-    suggestedFilenamePrefix: string | undefined;
-    slug: string | undefined;
-  }): Value;
+    value: FormFieldStoredValue;
+    asset: { content: Uint8Array; filename: string } | undefined;
+  };
+
+  validate(value: ParsedValue): ValidatedValue;
+  reader: {
+    parse(value: FormFieldStoredValue): ReaderValue;
+  };
 };
 
-type BaseSerializeToFiles<Value> = {
-  kind: 'multi';
-  primaryExtension: string;
+export type ContentFormField<
+  ParsedValue extends {} | null,
+  ValidatedValue extends ParsedValue,
+  ReaderValue
+> = {
+  kind: 'form';
+  formKind: 'content';
+  contentExtension: string;
   directories?: string[];
+
+  Input(props: FormFieldInputProps<ParsedValue>): ReactElement | null;
+  defaultValue(): ParsedValue;
+  parse(
+    value: FormFieldStoredValue,
+    args: {
+      content: Uint8Array | undefined;
+      other: ReadonlyMap<string, Uint8Array>;
+      external: ReadonlyMap<string, ReadonlyMap<string, Uint8Array>>;
+      slug: string | undefined;
+    }
+  ): ParsedValue;
   serialize(
-    value: Value,
-    slug: string | undefined
-  ): Promise<{
-    value: unknown;
-    primary: Uint8Array | undefined;
+    value: ParsedValue,
+    extra: {
+      slug: string | undefined;
+    }
+  ): {
+    value: FormFieldStoredValue;
+    content: Uint8Array | undefined;
     other: ReadonlyMap<string, Uint8Array>;
     external: ReadonlyMap<string, ReadonlyMap<string, Uint8Array>>;
-  }>;
-  parse(data: {
-    value: unknown;
-    primary: Uint8Array | undefined;
-    other: ReadonlyMap<string, Uint8Array>;
-    external?: ReadonlyMap<string, ReadonlyMap<string, Uint8Array>>;
-    slug: string | undefined;
-  }): Value;
+  };
+
+  validate(value: ParsedValue): ValidatedValue;
+  reader: {
+    parse(
+      value: FormFieldStoredValue,
+      extra: {
+        content: Uint8Array | undefined;
+      }
+    ): ReaderValue;
+  };
 };
 
+export type FormField<
+  ParsedValue extends {} | null,
+  ValidatedValue extends ParsedValue,
+  ReaderValue
+> =
+  | BasicFormField<ParsedValue, ValidatedValue, ReaderValue>
+  | SlugFormField<ParsedValue, ValidatedValue, ReaderValue, any>
+  | AssetFormField<ParsedValue, ValidatedValue, ReaderValue>
+  | ContentFormField<ParsedValue, ValidatedValue, ReaderValue>;
+
 export type DocumentNode = DocumentElement | DocumentText;
 
 export type DocumentElement = {
@@ -195,9 +214,15 @@ export interface ObjectField<
 }
 
 export type ConditionalField<
-  DiscriminantField extends BasicFormField<string | boolean>,
+  DiscriminantField extends BasicFormField<
+    string | boolean,
+    string | boolean,
+    string | boolean
+  >,
   ConditionalValues extends {
-    [Key in `${DiscriminantField['defaultValue']}`]: ComponentSchema;
+    [Key in `${ReturnType<
+      DiscriminantField['defaultValue']
+    >}`]: ComponentSchema;
   }
 > = {
   kind: 'conditional';
@@ -219,9 +244,12 @@ type ArrayFieldInComponentSchema = {
 
 export type ComponentSchema =
   | ChildField
-  | FormField<any>
+  | FormField<any, any, any>
   | ObjectField
-  | ConditionalField<BasicFormField<any>, { [key: string]: ComponentSchema }>
+  | ConditionalField<
+      BasicFormField<any, any, any>,
+      { [key: string]: ComponentSchema }
+    >
   | ArrayFieldInComponentSchema;
 
 export * as fields from './fields';
@@ -259,9 +287,9 @@ type ChildFieldPreviewProps<Schema extends ChildField, ChildFieldElement> = {
   readonly schema: Schema;
 };
 
-type FormFieldPreviewProps<Schema extends FormField<any>> = {
-  readonly value: Schema['defaultValue'];
-  onChange(value: Schema['defaultValue']): void;
+type FormFieldPreviewProps<Schema extends FormField<any, any, any>> = {
+  readonly value: ReturnType<Schema['defaultValue']>;
+  onChange(value: ReturnType<Schema['defaultValue']>): void;
   readonly schema: Schema;
 };
 
@@ -292,7 +320,9 @@ type ConditionalFieldPreviewProps<
       Schema['discriminant'],
       Key
     >;
-    onChange<Discriminant extends Schema['discriminant']['defaultValue']>(
+    onChange<
+      Discriminant extends ReturnType<Schema['discriminant']['defaultValue']>
+    >(
       discriminant: Discriminant,
       value?: InitialOrUpdateValueFromComponentPropField<
         Schema['values'][`${Discriminant}`]
@@ -330,7 +360,7 @@ export type GenericPreviewProps<
   ChildFieldElement
 > = Schema extends ChildField
   ? ChildFieldPreviewProps<Schema, ChildFieldElement>
-  : Schema extends FormField<any>
+  : Schema extends FormField<any, any, any>
   ? FormFieldPreviewProps<Schema>
   : Schema extends ObjectField<any>
   ? ObjectFieldPreviewProps<Schema, ChildFieldElement>
@@ -349,8 +379,8 @@ export type InitialOrUpdateValueFromComponentPropField<
   Schema extends ComponentSchema
 > = Schema extends ChildField
   ? undefined
-  : Schema extends FormField<infer Value>
-  ? Value | undefined
+  : Schema extends FormField<infer ParsedValue, any, any>
+  ? ParsedValue | undefined
   : Schema extends ObjectField<infer Value>
   ? {
       readonly [Key in keyof Value]?: InitialOrUpdateValueFromComponentPropField<
@@ -377,9 +407,9 @@ export type InitialOrUpdateValueFromComponentPropField<
   : never;
 
 type DiscriminantStringToDiscriminantValue<
-  DiscriminantField extends FormField<any>,
+  DiscriminantField extends FormField<any, any, any>,
   DiscriminantString extends PropertyKey
-> = DiscriminantField['defaultValue'] extends boolean
+> = ReturnType<DiscriminantField['defaultValue']> extends boolean
   ? 'true' extends DiscriminantString
     ? true
     : 'false' extends DiscriminantString
@@ -425,14 +455,16 @@ export function component<
 
 type Comp<Props> = (props: Props) => ReactElement | null;
 
-export type ValueForComponentSchema<Schema extends ComponentSchema> =
+export type ParsedValueForComponentSchema<Schema extends ComponentSchema> =
   Schema extends ChildField
     ? null
-    : Schema extends FormField<infer Value>
+    : Schema extends FormField<infer Value, any, any>
     ? Value
     : Schema extends ObjectField<infer Value>
     ? {
-        readonly [Key in keyof Value]: ValueForComponentSchema<Value[Key]>;
+        readonly [Key in keyof Value]: ParsedValueForComponentSchema<
+          Value[Key]
+        >;
       }
     : Schema extends ConditionalField<infer DiscriminantField, infer Values>
     ? {
@@ -441,25 +473,23 @@ export type ValueForComponentSchema<Schema extends ComponentSchema> =
             DiscriminantField,
             Key
           >;
-          readonly value: ValueForComponentSchema<Values[Key]>;
+          readonly value: ParsedValueForComponentSchema<Values[Key]>;
         };
       }[keyof Values]
     : Schema extends ArrayField<infer ElementField>
-    ? readonly ValueForComponentSchema<ElementField>[]
+    ? readonly ParsedValueForComponentSchema<ElementField>[]
     : never;
 
 export type ValueForReading<Schema extends ComponentSchema> =
   Schema extends ChildField
     ? null
-    : Schema extends FormFieldWithFileRequiringContentsForReader<
-        any,
-        infer Value
-      >
+    : Schema extends ContentFormField<any, any, infer Value>
     ? () => Promise<Value>
-    : Schema extends
-        | FormFieldWithFileNotRequiringContentsForReader<any, infer Value>
-        | SlugFormField<infer Value, any>
-        | BasicFormField<infer Value>
+    : Schema extends BasicFormField<any, any, infer Value>
+    ? Value
+    : Schema extends SlugFormField<any, any, infer Value, any>
+    ? Value
+    : Schema extends AssetFormField<any, any, infer Value>
     ? Value
     : Schema extends ObjectField<infer Value>
     ? {
@@ -482,11 +512,7 @@ export type ValueForReading<Schema extends ComponentSchema> =
 export type ValueForReadingDeep<Schema extends ComponentSchema> =
   Schema extends ChildField
     ? null
-    : Schema extends
-        | FormFieldWithFileRequiringContentsForReader<any, infer Value>
-        | FormFieldWithFileNotRequiringContentsForReader<any, infer Value>
-        | SlugFormField<infer Value, any>
-        | BasicFormField<infer Value>
+    : Schema extends FormField<any, any, infer Value>
     ? Value
     : Schema extends ObjectField<infer Value>
     ? {
diff --git a/packages/keystatic/DocumentEditor/component-blocks/document-field.tsx b/packages/keystatic/DocumentEditor/component-blocks/document-field.tsx
index bd166851..7797cbc5 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/document-field.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/document-field.tsx
@@ -2,119 +2,9 @@ import { Descendant } from 'slate';
 import { fixPath } from '../../app/path-utils';
 import { DocumentFeatures } from '../document-features';
 import { ComponentBlock, ComponentSchema } from './api';
-import { getSrcPrefix } from './fields/image';
 import { transformProps } from './props-value';
 import { object } from './fields/object';
-
-export type CollectedFile = {
-  data: Uint8Array;
-  filename: string;
-  parent: string | undefined;
-};
-
-export function collectFiles(
-  nodes: Descendant[],
-  componentBlocks: Record<string, ComponentBlock>,
-  collectedFiles: CollectedFile[],
-  documentFeatures: DocumentFeatures,
-  slug: string | undefined
-): Descendant[] {
-  return nodes.map((node): Descendant => {
-    if (node.type === 'component-block') {
-      const componentBlock = componentBlocks[node.component];
-      if (!componentBlock) return node;
-      const schema = object(componentBlock.schema);
-      return {
-        ...node,
-        props: transformPropsToFiles(
-          schema,
-          node.props,
-          collectedFiles,
-          slug
-        ) as Record<string, any>,
-      };
-    }
-    if (node.type === 'image') {
-      collectedFiles.push({
-        data: node.src.content,
-        filename: node.src.filename,
-        parent:
-          typeof documentFeatures.images === 'object' &&
-          typeof documentFeatures.images.directory === 'string'
-            ? fixPath(documentFeatures.images.directory)
-            : undefined,
-      });
-      return {
-        type: 'image',
-        src: `${getSrcPrefixForImageBlock(documentFeatures, slug)}${
-          node.src.filename
-        }` as any,
-        alt: node.alt,
-        title: node.title,
-        children: [],
-      };
-    }
-    if (typeof node.type === 'string') {
-      const children = collectFiles(
-        node.children,
-        componentBlocks,
-        collectedFiles,
-        documentFeatures,
-        slug
-      );
-      return { ...node, children };
-    }
-    return node;
-  });
-}
-
-function transformPropsToFiles(
-  schema: ComponentSchema,
-  value: unknown,
-  collectedFiles: CollectedFile[],
-  slug: string | undefined
-): unknown {
-  return transformProps(schema, value, {
-    form(schema, value) {
-      if ('serializeToFile' in schema && schema.serializeToFile) {
-        if (schema.serializeToFile.kind === 'asset') {
-          const { content, value: forYaml } = schema.serializeToFile.serialize(
-            value,
-            undefined,
-            slug
-          );
-          const filename = schema.serializeToFile.filename(
-            forYaml,
-            undefined,
-            slug
-          );
-          if (filename && content) {
-            collectedFiles.push({
-              data: content,
-              filename,
-              parent: schema.serializeToFile.directory,
-            });
-          }
-          return forYaml;
-        }
-        throw new Error('not implemented');
-      }
-      return value;
-    },
-  });
-}
-
-function getSrcPrefixForImageBlock(
-  documentFeatures: DocumentFeatures,
-  slug: string | undefined
-) {
-  return getSrcPrefix(
-    typeof documentFeatures.images === 'object'
-      ? documentFeatures.images.publicPath
-      : undefined,
-    slug
-  );
-}
+import { getSrcPrefix } from './fields/image';
 
 export function deserializeFiles(
   nodes: Descendant[],
@@ -195,43 +85,43 @@ function deserializeProps(
 ) {
   return transformProps(schema, value, {
     form: (schema, value) => {
-      if ('serializeToFile' in schema && schema.serializeToFile) {
-        if (schema.serializeToFile.kind === 'asset') {
-          if (
-            !schema.serializeToFile.reader.requiresContentInReader &&
-            mode === 'read'
-          ) {
-            return schema.serializeToFile.reader.parseToReader({
-              value: value,
-              suggestedFilenamePrefix: undefined,
-            });
-          }
-          const filename = schema.serializeToFile.filename(
-            value,
-            undefined,
-            slug
-          );
-          return (
-            mode === 'read' &&
-              schema.serializeToFile.reader.requiresContentInReader
-              ? schema.serializeToFile.reader.parseToReader
-              : schema.serializeToFile.parse
-          )({
-            value: value,
-            content: filename
-              ? schema.serializeToFile.directory
-                ? otherFiles
-                    .get(schema.serializeToFile.directory)
-                    ?.get(filename)
-                : files.get(filename)
-              : undefined,
-            suggestedFilenamePrefix: undefined,
-            slug,
-          });
+      if (schema.formKind === 'asset') {
+        if (mode === 'read') {
+          return schema.reader.parse(value);
         }
-        throw new Error('not implemented');
+        const filename = schema.filename(value, {
+          slug,
+          suggestedFilenamePrefix: undefined,
+        });
+        return schema.parse(value, {
+          asset: filename
+            ? schema.directory
+              ? otherFiles.get(schema.directory)?.get(filename)
+              : files.get(filename)
+            : undefined,
+          slug,
+        });
       }
-      return value;
+
+      if (schema.formKind === 'content') {
+        throw new Error('Not implemented');
+      }
+      if (mode === 'read') {
+        return schema.reader.parse(value);
+      }
+      return schema.parse(value, undefined);
     },
   });
 }
+
+export function getSrcPrefixForImageBlock(
+  documentFeatures: DocumentFeatures,
+  slug: string | undefined
+) {
+  return getSrcPrefix(
+    typeof documentFeatures.images === 'object'
+      ? documentFeatures.images.publicPath
+      : undefined,
+    slug
+  );
+}
diff --git a/packages/keystatic/DocumentEditor/component-blocks/field-assertions.ts b/packages/keystatic/DocumentEditor/component-blocks/field-assertions.ts
index aa340dde..5b07e372 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/field-assertions.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/field-assertions.ts
@@ -62,8 +62,9 @@ function assertValidComponentSchemaInner(
     }
     if (schema.kind === 'conditional') {
       schemaAncestors.push(schema);
-      const stringifiedDefaultDiscriminant =
-        schema.discriminant.defaultValue.toString();
+      const stringifiedDefaultDiscriminant = schema.discriminant
+        .defaultValue()
+        .toString();
       for (const [key, innerProp] of Object.entries(schema.values)) {
         propPath.push(key);
         if (schema.values[key] !== innerProp) {
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx
index 81cc4417..08146d3f 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx
@@ -15,6 +15,8 @@ export function array<ElementField extends ComponentSchema>(
     slugField?: ElementField extends { kind: 'object' }
       ? {
           [K in keyof ElementField['fields']]: ElementField['fields'][K] extends SlugFormField<
+            any,
+            any,
             any,
             any
           >
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/checkbox.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/checkbox.tsx
index ea6757e9..ecf873a3 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/checkbox.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/checkbox.tsx
@@ -1,6 +1,8 @@
 import { Checkbox } from '@voussoir/checkbox';
 import { Text } from '@voussoir/typography';
 import { BasicFormField } from '../api';
+import { FieldDataError } from './error';
+import { basicFormFieldWithSimpleReaderParse } from './utils';
 
 export function checkbox({
   label,
@@ -11,8 +13,7 @@ export function checkbox({
   defaultValue?: boolean;
   description?: string;
 }): BasicFormField<boolean> {
-  return {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input({ value, onChange, autoFocus }) {
       return (
         <Checkbox isSelected={value} onChange={onChange} autoFocus={autoFocus}>
@@ -21,9 +22,21 @@ export function checkbox({
         </Checkbox>
       );
     },
-    defaultValue,
+    defaultValue() {
+      return defaultValue;
+    },
+    parse(value) {
+      if (value === undefined) return defaultValue;
+      if (typeof value !== 'boolean') {
+        throw new FieldDataError('Must be a boolean');
+      }
+      return value;
+    },
     validate(value) {
-      return typeof value === 'boolean';
+      return value;
+    },
+    serialize(value) {
+      return { value };
     },
-  };
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/conditional.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/conditional.tsx
index bd255b76..84a29bdb 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/conditional.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/conditional.tsx
@@ -1,22 +1,16 @@
 import { BasicFormField, ComponentSchema, ConditionalField } from '../api';
 
 export function conditional<
-  DiscriminantField extends BasicFormField<string | boolean>,
+  DiscriminantField extends BasicFormField<string> | BasicFormField<boolean>,
   ConditionalValues extends {
-    [Key in `${DiscriminantField['defaultValue']}`]: ComponentSchema;
+    [Key in `${ReturnType<
+      DiscriminantField['defaultValue']
+    >}`]: ComponentSchema;
   }
 >(
   discriminant: DiscriminantField,
   values: ConditionalValues
 ): ConditionalField<DiscriminantField, ConditionalValues> {
-  if (
-    (discriminant.validate('true') || discriminant.validate('false')) &&
-    (discriminant.validate(true) || discriminant.validate(false))
-  ) {
-    throw new Error(
-      'The discriminant of a conditional field only supports string values, or boolean values, not both.'
-    );
-  }
   return {
     kind: 'conditional',
     discriminant,
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/date/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/date/index.tsx
index 5e6d87dc..fba030f1 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/date/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/date/index.tsx
@@ -1,16 +1,18 @@
 import { BasicFormField } from '../../api';
-import { RequiredValidation } from '../utils';
+import { FieldDataError } from '../error';
+import {
+  RequiredValidation,
+  assertRequired,
+  basicFormFieldWithSimpleReaderParse,
+} from '../utils';
 import { DateFieldInput } from './ui';
 
 export function validateDate(
   validation: { min?: string; max?: string; isRequired?: boolean } | undefined,
-  value: unknown,
+  value: string | null,
   label: string
 ) {
-  if (
-    value !== null &&
-    (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
-  ) {
+  if (value !== null && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
     return `${label} is not a valid date`;
   }
 
@@ -45,10 +47,10 @@ export function date<IsRequired extends boolean | undefined>({
   validation?: { isRequired?: IsRequired; min?: string; max?: string };
   description?: string;
 } & RequiredValidation<IsRequired>): BasicFormField<
+  string | null,
   string | (IsRequired extends true ? never : null)
 > {
-  const field: BasicFormField<string | null> = {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input(props) {
       return (
         <DateFieldInput
@@ -59,7 +61,7 @@ export function date<IsRequired extends boolean | undefined>({
         />
       );
     },
-    get defaultValue() {
+    defaultValue() {
       if (defaultValue === undefined) {
         return null;
       }
@@ -72,9 +74,25 @@ export function date<IsRequired extends boolean | undefined>({
       const day = String(today.getDate()).padStart(2, '0');
       return `${year}-${month}-${day}`;
     },
+    parse(value) {
+      if (value === undefined) {
+        return null;
+      }
+      if (typeof value !== 'string') {
+        throw new FieldDataError('Must be a string');
+      }
+      return value;
+    },
+    serialize(value) {
+      return { value: value === null ? undefined : value };
+    },
     validate(value) {
-      return validateDate(validation, value, label) === undefined;
+      const message = validateDate(validation, value, label);
+      if (message !== undefined) {
+        throw new FieldDataError(message);
+      }
+      assertRequired(value, validation, label);
+      return value;
     },
-  };
-  return field as any;
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx
index bae56891..a44606f7 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx
@@ -1,28 +1,27 @@
 import Markdoc from '@markdoc/markdoc';
 
-import { Editor } from 'slate';
+import { Descendant, Editor } from 'slate';
 
 import { fromMarkdoc } from '../../../../markdoc/from-markdoc';
 import { toMarkdocDocument } from '../../../../markdoc/to-markdoc';
 import { DocumentFeatures } from '../../../document-features';
-import {
-  CollectedFile,
-  collectFiles,
-  deserializeFiles,
-} from '../../document-field';
+import { deserializeFiles } from '../../document-field';
 import { collectDirectoriesUsedInSchema } from '../../../../app/tree-key';
 import {
   BasicFormField,
   ComponentBlock,
   ComponentSchema,
   DocumentElement,
-  FormFieldWithFileRequiringContentsForReader,
+  ContentFormField,
   SlugFormField,
+  FormFieldStoredValue,
 } from '../../api';
 import { text } from '../text';
 import { DocumentFieldInput } from './ui';
 import { createDocumentEditorForNormalization } from '../../../create-editor';
 import { object } from '../object';
+import { FieldDataError } from '../error';
+import { basicFormFieldWithSimpleReaderParse } from '../utils';
 
 const textEncoder = new TextEncoder();
 const textDecoder = new TextDecoder();
@@ -71,7 +70,7 @@ type FormattingConfig = {
 
 export type BasicStringFormField =
   | BasicFormField<string>
-  | SlugFormField<string, undefined>;
+  | SlugFormField<string, string, string, null>;
 
 type DocumentFeaturesConfig = {
   formatting?: true | FormattingConfig;
@@ -91,21 +90,33 @@ type DocumentFeaturesConfig = {
   tables?: true;
 };
 
-const defaultAltField: SlugFormField<string, undefined> = text({
+const defaultAltField: SlugFormField<string, string, string, null> = text({
   label: 'Alt text',
   description: 'This text will be used by screen readers and search engines.',
 });
 
-const emptyTitleField: BasicFormField<string> = {
-  kind: 'form',
-  Input() {
-    return null;
-  },
-  defaultValue: '',
-  validate(value) {
-    return typeof value === 'string';
-  },
-};
+const emptyTitleField: BasicFormField<string> =
+  basicFormFieldWithSimpleReaderParse({
+    Input() {
+      return null;
+    },
+    defaultValue() {
+      return '';
+    },
+    parse(value) {
+      if (value === undefined) return '';
+      if (typeof value !== 'string') {
+        throw new FieldDataError('Must be string');
+      }
+      return value;
+    },
+    validate(value) {
+      return value;
+    },
+    serialize(value) {
+      return { value };
+    },
+  });
 
 export function normaliseDocumentFeatures(
   config: DocumentFeaturesConfig
@@ -242,21 +253,24 @@ export function document({
   label: string;
   componentBlocks?: Record<string, ComponentBlock>;
   description?: string;
-} & DocumentFeaturesConfig): FormFieldWithFileRequiringContentsForReader<
+} & DocumentFeaturesConfig): ContentFormField<
+  DocumentElement[],
   DocumentElement[],
   DocumentElement[]
 > {
   const documentFeatures = normaliseDocumentFeatures(documentFeaturesConfig);
   const parse =
     (mode: 'read' | 'edit') =>
-    (data: {
-      value: unknown;
-      primary: Uint8Array | undefined;
-      other: ReadonlyMap<string, Uint8Array>;
-      external?: ReadonlyMap<string, ReadonlyMap<string, Uint8Array>>;
-      slug?: string;
-    }): DocumentElement[] => {
-      const markdoc = textDecoder.decode(data.primary);
+    (
+      _value: FormFieldStoredValue,
+      data: {
+        content: Uint8Array | undefined;
+        other: ReadonlyMap<string, Uint8Array>;
+        external: ReadonlyMap<string, ReadonlyMap<string, Uint8Array>>;
+        slug: string | undefined;
+      }
+    ): DocumentElement[] => {
+      const markdoc = textDecoder.decode(data.content);
       const document = fromMarkdoc(Markdoc.parse(markdoc), componentBlocks);
       const editor = createDocumentEditorForNormalization(
         documentFeatures,
@@ -276,7 +290,10 @@ export function document({
     };
   return {
     kind: 'form',
-    defaultValue: [{ type: 'paragraph', children: [{ text: '' }] }],
+    formKind: 'content',
+    defaultValue() {
+      return [{ type: 'paragraph', children: [{ text: '' }] }];
+    },
     Input(props) {
       return (
         <DocumentFieldInput
@@ -288,78 +305,62 @@ export function document({
         />
       );
     },
-    validate() {
-      return true;
+
+    parse: parse('edit'),
+    contentExtension: '.mdoc',
+    validate(value) {
+      return value;
     },
-    serializeToFile: {
-      kind: 'multi',
-      primaryExtension: '.mdoc',
-      directories: [
-        ...collectDirectoriesUsedInSchema(
-          object(
-            Object.fromEntries(
-              Object.entries(componentBlocks).map(([name, block]) => [
-                name,
-                object(block.schema),
-              ])
-            )
+    directories: [
+      ...collectDirectoriesUsedInSchema(
+        object(
+          Object.fromEntries(
+            Object.entries(componentBlocks).map(([name, block]) => [
+              name,
+              object(block.schema),
+            ])
           )
-        ),
-        ...(typeof documentFeatures.images === 'object' &&
-        typeof documentFeatures.images.directory === 'string'
-          ? [documentFeatures.images.directory]
-          : []),
-      ],
-      parse: parse('edit'),
-      async serialize(value, slug) {
-        const collectedFiles: CollectedFile[] = [];
-        const transformed = collectFiles(
-          value as any,
+        )
+      ),
+      ...(typeof documentFeatures.images === 'object' &&
+      typeof documentFeatures.images.directory === 'string'
+        ? [documentFeatures.images.directory]
+        : []),
+    ],
+    serialize(value, opts) {
+      const { extraFiles, node } = toMarkdocDocument(
+        value as any as Descendant[],
+        {
           componentBlocks,
-          collectedFiles,
           documentFeatures,
-          slug
-        );
+          slug: opts.slug,
+        }
+      );
 
-        const other = new Map<string, Uint8Array>();
-        const external = new Map<string, Map<string, Uint8Array>>();
-        for (const file of collectedFiles) {
-          if (file.parent === undefined) {
-            other.set(file.filename, file.data);
-            continue;
-          }
-          if (!external.has(file.parent)) {
-            external.set(file.parent, new Map());
-          }
-          external.get(file.parent)!.set(file.filename, file.data);
+      const other = new Map<string, Uint8Array>();
+      const external = new Map<string, Map<string, Uint8Array>>();
+      for (const file of extraFiles) {
+        if (file.parent === undefined) {
+          other.set(file.path, file.contents);
+          continue;
         }
-        try {
-          return {
-            primary: textEncoder.encode(
-              Markdoc.format(
-                Markdoc.parse(
-                  Markdoc.format(
-                    toMarkdocDocument(transformed, {
-                      componentBlocks,
-                      documentFeatures,
-                    })
-                  )
-                )
-              )
-            ),
-            other,
-            external,
-            value: undefined,
-          };
-        } catch (err) {
-          debugger;
-          throw err;
+        if (!external.has(file.parent)) {
+          external.set(file.parent, new Map());
         }
-      },
-      reader: {
-        requiresContentInReader: true,
-        parseToReader: parse('read'),
-      },
+        external.get(file.parent)!.set(file.path, file.contents);
+      }
+
+      return {
+        content: textEncoder.encode(
+          Markdoc.format(Markdoc.parse(Markdoc.format(node)))
+        ),
+        other,
+        external,
+        value: undefined,
+      };
+    },
+    reader: {
+      parse: parse('read'),
     },
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/empty.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/empty.tsx
index a95e53fe..32b9ffcc 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/empty.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/empty.tsx
@@ -1,14 +1,22 @@
 import { BasicFormField } from '../api';
+import { basicFormFieldWithSimpleReaderParse } from './utils';
 
 export function empty(): BasicFormField<null> {
-  return {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input() {
       return null;
     },
-    defaultValue: null,
+    defaultValue() {
+      return null;
+    },
+    parse() {
+      return null;
+    },
+    serialize() {
+      return { value: undefined };
+    },
     validate(value) {
-      return value === null || value === undefined;
+      return value;
     },
-  };
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/emptyDocument.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/emptyDocument.tsx
index 48e3f462..6f3d4dc9 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/emptyDocument.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/emptyDocument.tsx
@@ -1,38 +1,34 @@
-import { FormFieldWithFileRequiringContentsForReader } from '../api';
+import { ContentFormField } from '../api';
 
-export function emptyDocument(): FormFieldWithFileRequiringContentsForReader<
-  null,
-  null
-> {
+export function emptyDocument(): ContentFormField<null, null, null> {
   return {
     kind: 'form',
+    formKind: 'content',
     Input() {
       return null;
     },
-    defaultValue: null,
+    defaultValue() {
+      return null;
+    },
+    parse() {
+      return null;
+    },
+    contentExtension: '.mdoc',
+    serialize() {
+      return {
+        value: undefined,
+        content: new Uint8Array(),
+        external: new Map(),
+        other: new Map(),
+      };
+    },
     validate(value) {
-      return value === null || value === undefined;
+      return value;
     },
-    serializeToFile: {
-      kind: 'multi',
+    reader: {
       parse() {
         return null;
       },
-      async serialize() {
-        return {
-          value: undefined,
-          primary: new Uint8Array(),
-          other: new Map(),
-          external: new Map(),
-        };
-      },
-      primaryExtension: '.mdoc',
-      reader: {
-        requiresContentInReader: true,
-        parseToReader() {
-          return null;
-        },
-      },
     },
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/error.ts b/packages/keystatic/DocumentEditor/component-blocks/fields/error.ts
new file mode 100644
index 00000000..0aaea899
--- /dev/null
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/error.ts
@@ -0,0 +1,6 @@
+export class FieldDataError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'FieldDataError';
+  }
+}
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/image/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/image/index.tsx
index ebbd484b..a3e079b9 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/image/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/image/index.tsx
@@ -1,6 +1,7 @@
 import { fixPath } from '../../../../app/path-utils';
-import { FormFieldWithFileNotRequiringContentsForReader } from '../../api';
-import { RequiredValidation } from '../utils';
+import { AssetFormField } from '../../api';
+import { FieldDataError } from '../error';
+import { RequiredValidation, assertRequired } from '../utils';
 import { ImageFieldInput } from './ui';
 
 export function image<IsRequired extends boolean | undefined>({
@@ -15,18 +16,15 @@ export function image<IsRequired extends boolean | undefined>({
   validation?: { isRequired?: IsRequired };
   description?: string;
   publicPath?: string;
-} & RequiredValidation<IsRequired>): FormFieldWithFileNotRequiringContentsForReader<
-  | {
-      kind: 'uploaded';
-      data: Uint8Array;
-      extension: string;
-      filename: string;
-    }
-  | { kind: 'none' },
+} & RequiredValidation<IsRequired>): AssetFormField<
+  { data: Uint8Array; extension: string; filename: string } | null,
+  | { data: Uint8Array; extension: string; filename: string }
+  | (IsRequired extends true ? never : null),
   string | (IsRequired extends true ? never : null)
 > {
   return {
     kind: 'form',
+    formKind: 'asset',
     Input(props) {
       return (
         <ImageFieldInput
@@ -37,75 +35,56 @@ export function image<IsRequired extends boolean | undefined>({
         />
       );
     },
-    defaultValue: { kind: 'none' },
+    defaultValue() {
+      return null;
+    },
+    filename(value, args) {
+      if (typeof value === 'string') {
+        return value.slice(getSrcPrefix(publicPath, args.slug).length);
+      }
+      return undefined;
+    },
+    parse(value, args) {
+      if (value === undefined) {
+        return null;
+      }
+      if (typeof value !== 'string') {
+        throw new FieldDataError('Must be a string');
+      }
+      if (args.asset === undefined) {
+        return null;
+      }
+      return {
+        data: args.asset,
+        filename: value.slice(getSrcPrefix(publicPath, args.slug).length),
+        extension: value.match(/\.([^.]+$)/)?.[1] ?? '',
+      };
+    },
     validate(value) {
-      if (
-        typeof value === 'object' &&
-        value !== null &&
-        'kind' in value &&
-        value.kind === 'none' &&
-        validation?.isRequired
-      ) {
-        return false;
+      assertRequired(value, validation, label);
+      return value;
+    },
+    serialize(value, args) {
+      if (value === null) {
+        return { value: undefined, asset: undefined };
       }
-      return true;
+      const filename = args.suggestedFilenamePrefix
+        ? args.suggestedFilenamePrefix + '.' + value.extension
+        : value.filename;
+      return {
+        value: `${getSrcPrefix(publicPath, args.slug)}${filename}`,
+        asset: { filename, content: value.data },
+      };
     },
-    serializeToFile: {
-      kind: 'asset',
-      directory: directory ? fixPath(directory) : undefined,
-      filename(value, suggestedFilenamePrefix, slug) {
-        if (typeof value === 'string') {
-          return value.slice(getSrcPrefix(publicPath, slug).length);
-        }
-        if (
-          typeof value === 'object' &&
-          value !== null &&
-          'extension' in value &&
-          typeof value.extension === 'string'
-        ) {
-          return suggestedFilenamePrefix + '.' + value.extension;
-        }
-      },
-      parse({ content, value, slug }) {
-        if (!content) {
-          return { kind: 'none' };
+    directory: directory ? fixPath(directory) : undefined,
+    reader: {
+      parse(value) {
+        if (typeof value !== 'string' && value !== undefined) {
+          throw new FieldDataError('Must be a string');
         }
-        return {
-          kind: 'uploaded',
-          data: content,
-          extension:
-            value && typeof value === 'object' && 'extension' in value
-              ? (value as any).extension
-              : typeof value === 'string'
-              ? value.match(/\.([^.]+$)/)?.[1]
-              : '',
-          filename:
-            typeof value === 'string'
-              ? value.slice(getSrcPrefix(publicPath, slug).length)
-              : '',
-        };
-      },
-      serialize(value, suggestedFilenamePrefix, slug) {
-        if (value.kind === 'none') {
-          return { value: null, content: undefined };
-        }
-        const filename = suggestedFilenamePrefix
-          ? suggestedFilenamePrefix + '.' + value.extension
-          : value.filename;
-        return {
-          value: `${getSrcPrefix(publicPath, slug)}${filename}`,
-          content: value.data,
-          filename,
-        };
-      },
-      reader: {
-        requiresContentInReader: false,
-        parseToReader({ value, suggestedFilenamePrefix }) {
-          if (!value) return null as any;
-          return typeof value === 'string'
-            ? value
-            : suggestedFilenamePrefix + '.' + (value as any).extension;
-        },
+        const val = value === undefined ? null : value;
+        assertRequired(val, validation, label);
+        return val;
       },
     },
   };
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/image/ui.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/image/ui.tsx
index 2ad06eb9..f8e6d52d 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/image/ui.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/image/ui.tsx
@@ -65,15 +65,11 @@ export function useObjectURL(data: Uint8Array | null) {
 }
 
 export function ImageFieldInput(
-  props: FormFieldInputProps<
-    | {
-        kind: 'uploaded';
-        data: Uint8Array;
-        extension: string;
-        filename: string;
-      }
-    | { kind: 'none' }
-  > & {
+  props: FormFieldInputProps<{
+    data: Uint8Array;
+    extension: string;
+    filename: string;
+  } | null> & {
     label: string;
     description: string | undefined;
     validation: { isRequired?: boolean } | undefined;
@@ -82,7 +78,7 @@ export function ImageFieldInput(
   const { value } = props;
   const [blurred, onBlur] = useReducer(() => true, false);
   const isInEditor = useIsInDocumentEditor();
-  const objectUrl = useObjectURL(value.kind === 'uploaded' ? value.data : null);
+  const objectUrl = useObjectURL(value === null ? null : value.data);
   return (
     <Flex direction="column" gap="medium">
       <FieldLabel>{props.label}</FieldLabel>
@@ -99,7 +95,6 @@ export function ImageFieldInput(
               const extension = image.filename.match(/\.([^.]+$)/)?.[1];
               if (extension) {
                 props.onChange({
-                  kind: 'uploaded',
                   data: image.content,
                   extension,
                   filename: image.filename,
@@ -110,11 +105,11 @@ export function ImageFieldInput(
         >
           Choose file
         </ActionButton>
-        {value.kind === 'uploaded' && (
+        {value !== null && (
           <ActionButton
             prominence="low"
             onPress={() => {
-              props.onChange({ kind: 'none' });
+              props.onChange(null);
               onBlur();
             }}
           >
@@ -141,7 +136,7 @@ export function ImageFieldInput(
           />
         </Box>
       )}
-      {isInEditor && value.kind === 'uploaded' && (
+      {isInEditor && value !== null && (
         <TextField
           label="Filename"
           onChange={filename => {
@@ -152,7 +147,7 @@ export function ImageFieldInput(
       )}
       {(props.forceValidation || blurred) &&
         props.validation?.isRequired &&
-        value.kind === 'none' && (
+        value === null && (
           <FieldMessage>{props.label} is required</FieldMessage>
         )}
     </Flex>
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/integer/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/integer/index.tsx
index db5cc62e..50c67ab0 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/integer/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/integer/index.tsx
@@ -1,5 +1,10 @@
 import { BasicFormField } from '../../api';
-import { RequiredValidation } from '../utils';
+import { FieldDataError } from '../error';
+import {
+  RequiredValidation,
+  assertRequired,
+  basicFormFieldWithSimpleReaderParse,
+} from '../utils';
 import { IntegerFieldInput } from './ui';
 
 export function validateInteger(
@@ -38,13 +43,10 @@ export function integer<IsRequired extends boolean | undefined>({
   validation?: { isRequired?: IsRequired; min: number; max: number };
   description?: string;
 } & RequiredValidation<IsRequired>): BasicFormField<
+  number | null,
   number | (IsRequired extends true ? never : null)
 > {
-  const validate = (value: unknown) => {
-    return validateInteger(validation, value, label) === undefined;
-  };
-  return {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input(props) {
       return (
         <IntegerFieldInput
@@ -55,7 +57,28 @@ export function integer<IsRequired extends boolean | undefined>({
         />
       );
     },
-    defaultValue: (defaultValue ?? null) as number,
-    validate,
-  };
+    defaultValue() {
+      return defaultValue ?? null;
+    },
+    parse(value) {
+      if (value === undefined) {
+        return null;
+      }
+      if (typeof value === 'number') {
+        return value;
+      }
+      throw new FieldDataError('Must be a number');
+    },
+    validate(value) {
+      const message = validateInteger(validation, value, label);
+      if (message !== undefined) {
+        throw new FieldDataError(message);
+      }
+      assertRequired(value, validation, label);
+      return value;
+    },
+    serialize(value) {
+      return { value: value === null ? undefined : value };
+    },
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/multiselect/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/multiselect/index.tsx
index ac4ce09f..5f98dd90 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/multiselect/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/multiselect/index.tsx
@@ -1,4 +1,6 @@
 import { BasicFormField } from '../../api';
+import { FieldDataError } from '../error';
+import { basicFormFieldWithSimpleReaderParse } from '../utils';
 import { MultiselectFieldInput } from './ui';
 
 export function multiselect<Option extends { label: string; value: string }>({
@@ -15,27 +17,51 @@ export function multiselect<Option extends { label: string; value: string }>({
   options: readonly Option[];
 } {
   const valuesToOption = new Map(options.map(x => [x.value, x]));
+  const field: BasicFormField<readonly Option['value'][]> =
+    basicFormFieldWithSimpleReaderParse({
+      Input(props) {
+        return (
+          <MultiselectFieldInput
+            label={label}
+            description={description}
+            options={options}
+            {...props}
+          />
+        );
+      },
+      defaultValue() {
+        return defaultValue;
+      },
+      parse(value) {
+        if (value === undefined) {
+          return [];
+        }
+        if (!Array.isArray(value)) {
+          throw new FieldDataError('Must be an array of options');
+        }
+        if (
+          !value.every(
+            (x): x is Option['value'] =>
+              typeof x === 'string' && valuesToOption.has(x)
+          )
+        ) {
+          throw new FieldDataError(
+            `Must be an array with one of ${options
+              .map(x => x.value)
+              .join(', ')}`
+          );
+        }
+        return value;
+      },
+      validate(value) {
+        return value;
+      },
+      serialize(value) {
+        return { value };
+      },
+    });
   return {
-    kind: 'form',
-    Input(props) {
-      return (
-        <MultiselectFieldInput
-          label={label}
-          description={description}
-          options={options}
-          {...props}
-        />
-      );
-    },
+    ...field,
     options,
-    defaultValue,
-    validate(value) {
-      return (
-        Array.isArray(value) &&
-        value.every(
-          value => typeof value === 'string' && valuesToOption.has(value)
-        )
-      );
-    },
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/pathReference/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/pathReference/index.tsx
index d9328c27..1098fda2 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/pathReference/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/pathReference/index.tsx
@@ -1,5 +1,10 @@
 import { BasicFormField } from '../../api';
-import { RequiredValidation } from '../utils';
+import { FieldDataError } from '../error';
+import {
+  RequiredValidation,
+  assertRequired,
+  basicFormFieldWithSimpleReaderParse,
+} from '../utils';
 import { PathReferenceInput } from './ui';
 
 export function pathReference<IsRequired extends boolean | undefined>({
@@ -13,10 +18,10 @@ export function pathReference<IsRequired extends boolean | undefined>({
   validation?: { isRequired?: IsRequired };
   description?: string;
 } & RequiredValidation<IsRequired>): BasicFormField<
+  string | null,
   string | (IsRequired extends true ? never : null)
 > {
-  return {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input(props) {
       return (
         <PathReferenceInput
@@ -28,9 +33,24 @@ export function pathReference<IsRequired extends boolean | undefined>({
         />
       );
     },
-    defaultValue: null as any,
-    validate: val =>
-      typeof val === 'string' ||
-      (validation?.isRequired ? false : val === null),
-  };
+    defaultValue() {
+      return null;
+    },
+    parse(value) {
+      if (value === undefined) {
+        return null;
+      }
+      if (typeof value !== 'string') {
+        throw new FieldDataError('Must be a string');
+      }
+      return value;
+    },
+    validate(value) {
+      assertRequired(value, validation, label);
+      return value;
+    },
+    serialize(value) {
+      return { value: value === null ? undefined : value };
+    },
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/relationship/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/relationship/index.tsx
index 8347d433..6fb932a8 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/relationship/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/relationship/index.tsx
@@ -1,5 +1,10 @@
 import { BasicFormField } from '../../api';
-import { RequiredValidation } from '../utils';
+import { FieldDataError } from '../error';
+import {
+  RequiredValidation,
+  assertRequired,
+  basicFormFieldWithSimpleReaderParse,
+} from '../utils';
 import { RelationshipInput } from './ui';
 
 export function relationship<IsRequired extends boolean | undefined>({
@@ -13,10 +18,10 @@ export function relationship<IsRequired extends boolean | undefined>({
   validation?: { isRequired?: IsRequired };
   description?: string;
 } & RequiredValidation<IsRequired>): BasicFormField<
+  string | null,
   string | (IsRequired extends true ? never : null)
 > {
-  return {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input(props) {
       return (
         <RelationshipInput
@@ -28,9 +33,24 @@ export function relationship<IsRequired extends boolean | undefined>({
         />
       );
     },
-    defaultValue: null as any,
-    validate: val =>
-      typeof val === 'string' ||
-      (validation?.isRequired ? false : val === null),
-  };
+    defaultValue() {
+      return null;
+    },
+    parse(value) {
+      if (value === undefined) {
+        return null;
+      }
+      if (typeof value !== 'string') {
+        throw new FieldDataError('Must be a string');
+      }
+      return value;
+    },
+    validate(value) {
+      assertRequired(value, validation, label);
+      return value;
+    },
+    serialize(value) {
+      return { value: value === null ? undefined : value };
+    },
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/select/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/select/index.tsx
index 8bfe6fb9..63eb4ff9 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/select/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/select/index.tsx
@@ -1,4 +1,6 @@
 import { BasicFormField } from '../../api';
+import { FieldDataError } from '../error';
+import { basicFormFieldWithSimpleReaderParse } from '../utils';
 import { SelectFieldInput } from './ui';
 
 export function select<Option extends { label: string; value: string }>({
@@ -11,29 +13,51 @@ export function select<Option extends { label: string; value: string }>({
   options: readonly Option[];
   defaultValue: Option['value'];
   description?: string;
-}): BasicFormField<Option['value']> & { options: readonly Option[] } {
+}): BasicFormField<Option['value']> & {
+  options: readonly Option[];
+} {
   const optionValuesSet = new Set(options.map(x => x.value));
   if (!optionValuesSet.has(defaultValue)) {
     throw new Error(
       `A defaultValue of ${defaultValue} was provided to a select field but it does not match the value of one of the options provided`
     );
   }
+  const field: BasicFormField<Option['value']> =
+    basicFormFieldWithSimpleReaderParse({
+      Input(props) {
+        return (
+          <SelectFieldInput
+            label={label}
+            options={options}
+            description={description}
+            {...props}
+          />
+        );
+      },
+      defaultValue() {
+        return defaultValue;
+      },
+      parse(value) {
+        if (value === undefined) {
+          return defaultValue;
+        }
+        if (typeof value !== 'string') {
+          throw new FieldDataError('Must be a string');
+        }
+        if (!optionValuesSet.has(value)) {
+          throw new FieldDataError('Must be a valid option');
+        }
+        return value;
+      },
+      validate(value) {
+        return value;
+      },
+      serialize(value) {
+        return { value };
+      },
+    });
   return {
-    kind: 'form',
-    Input(props) {
-      return (
-        <SelectFieldInput
-          label={label}
-          options={options}
-          description={description}
-          {...props}
-        />
-      );
-    },
+    ...field,
     options,
-    defaultValue,
-    validate(value) {
-      return typeof value === 'string' && optionValuesSet.has(value);
-    },
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/slug/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/slug/index.tsx
index fc4de52f..01663ee3 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/slug/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/slug/index.tsx
@@ -1,7 +1,41 @@
-import { SlugFormField } from '../../api';
+import { FormFieldStoredValue, SlugFormField } from '../../api';
 import slugify from '@sindresorhus/slugify';
-import { isValidSlug } from '../text';
+import { validateText } from '../text';
 import { SlugFieldInput } from './ui';
+import { FieldDataError } from '../error';
+import { Glob } from '../../../../src';
+
+function parseSlugFieldAsNormalField(value: FormFieldStoredValue) {
+  if (value === undefined) {
+    return { name: '', slug: '' };
+  }
+  if (typeof value !== 'object') {
+    throw new FieldDataError('Must be an object');
+  }
+  if (Object.keys(value).length !== 2) {
+    throw new FieldDataError('Unexpected keys');
+  }
+  if (!('name' in value) || !('slug' in value)) {
+    throw new FieldDataError('Missing name or slug');
+  }
+  if (typeof value.name !== 'string') {
+    throw new FieldDataError('name must be a string');
+  }
+  if (typeof value.slug !== 'string') {
+    throw new FieldDataError('slug must be a string');
+  }
+  return { name: value.name, slug: value.slug };
+}
+
+function parseAsSlugField(value: FormFieldStoredValue, slug: string) {
+  if (value === undefined) {
+    return { name: '', slug };
+  }
+  if (typeof value !== 'string') {
+    throw new FieldDataError('Must be a string');
+  }
+  return { name: value, slug };
+}
 
 export function slug(args: {
   name: {
@@ -26,7 +60,12 @@ export function slug(args: {
       };
     };
   };
-}): SlugFormField<{ name: string; slug: string }, string> {
+}): SlugFormField<
+  { name: string; slug: string },
+  { name: string; slug: string },
+  { name: string; slug: string },
+  string
+> {
   const naiveGenerateSlug: (name: string) => string =
     args.slug?.generate || slugify;
   const defaultValue = {
@@ -34,9 +73,42 @@ export function slug(args: {
     slug: naiveGenerateSlug(args.name.defaultValue ?? ''),
   };
 
+  function validate(
+    value: { name: string; slug: string },
+    {
+      slugField,
+    }: {
+      slugField: { slugs: Set<string>; glob: Glob } | undefined;
+    } = { slugField: undefined }
+  ) {
+    const nameMessage = validateText(
+      value.name,
+      args.name.validation?.length?.min ?? 0,
+      args.name.validation?.length?.max ?? Infinity,
+      args.name.label,
+      undefined
+    );
+    if (nameMessage !== undefined) {
+      throw new FieldDataError(nameMessage);
+    }
+    const slugMessage = validateText(
+      value.slug,
+      args.slug?.validation?.length?.min ?? 1,
+      args.slug?.validation?.length?.max ?? Infinity,
+      args.slug?.label ?? 'Slug',
+      slugField ? slugField : { slugs: emptySet, glob: '*' }
+    );
+    if (slugMessage !== undefined) {
+      throw new FieldDataError(slugMessage);
+    }
+
+    return value;
+  }
+
   const emptySet = new Set<string>();
   return {
     kind: 'form',
+    formKind: 'slug',
     Input(props) {
       return (
         <SlugFieldInput
@@ -47,38 +119,32 @@ export function slug(args: {
         />
       );
     },
-    defaultValue,
-    validate(value, slugInfo) {
-      return (
-        typeof value === 'object' &&
-        value !== null &&
-        'name' in value &&
-        'slug' in value &&
-        Object.keys(value).length === 2 &&
-        typeof value.name === 'string' &&
-        value.name.length >= (args.name.validation?.length?.min ?? 0) &&
-        value.name.length <= (args.name.validation?.length?.max ?? Infinity) &&
-        typeof value.slug === 'string' &&
-        value.slug.length >= (args.slug?.validation?.length?.min ?? 1) &&
-        value.slug.length <= (args.slug?.validation?.length?.max ?? Infinity) &&
-        isValidSlug(
-          value.slug,
-          slugInfo ? slugInfo : { slugs: emptySet, glob: '*' }
-        )
-      );
+    defaultValue() {
+      return defaultValue;
+    },
+    parse(value, args) {
+      if (args?.slug !== undefined) {
+        return parseAsSlugField(value, args.slug);
+      }
+      return parseSlugFieldAsNormalField(value);
+    },
+
+    validate,
+    serialize(value) {
+      return { value };
+    },
+    serializeWithSlug(value) {
+      return { value: value.name, slug: value.slug };
     },
-    slug: {
-      parse(data) {
-        if (typeof data.value !== 'string') {
-          throw new Error('Invalid data');
-        }
-        return {
-          name: data.value,
-          slug: data.slug,
-        };
+    reader: {
+      parse(value) {
+        const parsed = parseSlugFieldAsNormalField(value);
+        return validate(parsed);
       },
-      serialize(value) {
-        return { slug: value.slug, value: value.name };
+      parseWithSlug(value, args) {
+        return validate(parseAsSlugField(value, args.slug), {
+          slugField: { glob: args.glob, slugs: emptySet },
+        }).name;
       },
     },
   };
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/text/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/text/index.tsx
index b65cacd0..ecbaca1d 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/text/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/text/index.tsx
@@ -1,5 +1,7 @@
 import { Glob } from '../../../../config';
+import { FormFieldStoredValue } from '../../../../src';
 import { SlugFormField } from '../../api';
+import { FieldDataError } from '../error';
 import { TextFieldInput } from './ui';
 
 export function validateText(
@@ -20,13 +22,25 @@ export function validateText(
     return `${fieldLabel} must be no longer than ${max} characters`;
   }
   if (slugInfo) {
+    if (val === '') {
+      return `${fieldLabel} must not be empty`;
+    }
     if (val === '..') {
       return `${fieldLabel} must not be ..`;
     }
     if (val === '.') {
       return `${fieldLabel} must not be .`;
     }
-    if (badSlashForGlobPattern(slugInfo.glob).test(val)) {
+    if (slugInfo.glob === '**') {
+      const split = val.split('/');
+      if (split.some(s => s === '..')) {
+        return `${fieldLabel} must not contain ..`;
+      }
+      if (split.some(s => s === '.')) {
+        return `${fieldLabel} must not be .`;
+      }
+    }
+    if ((slugInfo.glob === '*' ? /[\\/]/ : /[\\]/).test(val)) {
       return `${fieldLabel} must not contain slashes`;
     }
     if (slugInfo.slugs.has(val)) {
@@ -35,21 +49,18 @@ export function validateText(
   }
 }
 
-const badSlashForGlobPattern = (glob: Glob) =>
-  glob === '*' ? /[\\/]/ : /[\\]/;
-
-export function isValidSlug(
-  val: string,
-  slugInfo: { slugs: Set<string>; glob: Glob }
-) {
-  return (
-    val !== '..' &&
-    val !== '.' &&
-    !badSlashForGlobPattern(slugInfo.glob).test(val) &&
-    !slugInfo.slugs.has(val)
-  );
+function parseAsNormalField(value: FormFieldStoredValue) {
+  if (value === undefined) {
+    return '';
+  }
+  if (typeof value !== 'string') {
+    throw new FieldDataError('Must be a string');
+  }
+  return value;
 }
 
+const emptySet = new Set<string>();
+
 export function text({
   label,
   defaultValue = '',
@@ -67,9 +78,20 @@ export function text({
     };
   };
   multiline?: boolean;
-}): SlugFormField<string, undefined> {
+}): SlugFormField<string, string, string, null> {
+  function validate(
+    value: string,
+    slugField: { slugs: Set<string>; glob: Glob } | undefined
+  ) {
+    const message = validateText(value, min, max, label, slugField);
+    if (message !== undefined) {
+      throw new FieldDataError(message);
+    }
+    return value;
+  }
   return {
     kind: 'form',
+    formKind: 'slug',
     Input(props) {
       return (
         <TextFieldInput
@@ -82,29 +104,38 @@ export function text({
         />
       );
     },
-    defaultValue,
-    validate(value, slugInfo) {
-      if (
-        !(
-          typeof value === 'string' &&
-          value.length >= Math.max(min, slugInfo ? 1 : 0) &&
-          value.length <= max
-        )
-      ) {
-        return false;
-      }
-      if (slugInfo) {
-        return isValidSlug(value, slugInfo);
+    defaultValue() {
+      return defaultValue;
+    },
+    parse(value, args) {
+      if (args?.slug !== undefined) {
+        return args.slug;
       }
-      return true;
+      return parseAsNormalField(value);
     },
-    slug: {
-      parse(data) {
-        return data.slug;
+    serialize(value) {
+      return { value: value === '' ? undefined : value };
+    },
+    serializeWithSlug(value) {
+      return { slug: value, value: undefined };
+    },
+
+    reader: {
+      parse(value) {
+        const parsed = parseAsNormalField(value);
+        return validate(parsed, undefined);
       },
-      serialize(value) {
-        return { slug: value, value: undefined };
+      parseWithSlug(_value, args) {
+        validate(parseAsNormalField(args.slug), {
+          glob: args.glob,
+          slugs: emptySet,
+        });
+        return null;
       },
     },
+
+    validate(value, args) {
+      return validate(value, args?.slugField);
+    },
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/url/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/url/index.tsx
index 5155104c..2d1fb07b 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/url/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/url/index.tsx
@@ -1,6 +1,11 @@
 import { isValidURL } from '../../../isValidURL';
 import { BasicFormField } from '../../api';
-import { RequiredValidation } from '../utils';
+import { FieldDataError } from '../error';
+import {
+  RequiredValidation,
+  assertRequired,
+  basicFormFieldWithSimpleReaderParse,
+} from '../utils';
 import { UrlFieldInput } from './ui';
 
 export function validateUrl(
@@ -28,10 +33,10 @@ export function url<IsRequired extends boolean | undefined>({
   validation?: { isRequired?: IsRequired };
   description?: string;
 } & RequiredValidation<IsRequired>): BasicFormField<
+  string | null,
   string | (IsRequired extends true ? never : null)
 > {
-  return {
-    kind: 'form',
+  return basicFormFieldWithSimpleReaderParse({
     Input(props) {
       return (
         <UrlFieldInput
@@ -42,9 +47,28 @@ export function url<IsRequired extends boolean | undefined>({
         />
       );
     },
-    defaultValue,
-    validate(val) {
-      return validateUrl(validation, val, label) === undefined;
+    defaultValue() {
+      return defaultValue ?? null;
     },
-  };
+    parse(value) {
+      if (value === undefined) {
+        return null;
+      }
+      if (typeof value !== 'string') {
+        throw new FieldDataError('Must be a string');
+      }
+      return value;
+    },
+    validate(value) {
+      const message = validateUrl(validation, value, label);
+      if (message !== undefined) {
+        throw new FieldDataError(message);
+      }
+      assertRequired(value, validation, label);
+      return value;
+    },
+    serialize(value) {
+      return { value: value === null ? undefined : value };
+    },
+  });
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/utils.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/utils.tsx
index 2564d306..aff28fd8 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/utils.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/utils.tsx
@@ -1,2 +1,49 @@
+import { ReactElement } from 'react';
+import {
+  BasicFormField,
+  FormFieldInputProps,
+  FormFieldStoredValue,
+} from '../api';
+import { FieldDataError } from './error';
+
 export type RequiredValidation<IsRequired extends boolean | undefined> =
   IsRequired extends true ? { validation: { isRequired: true } } : unknown;
+
+export function assertRequired<T, IsRequired extends boolean | undefined>(
+  value: T | null,
+  validation: undefined | { isRequired?: IsRequired },
+  label: string
+): asserts value is T | (IsRequired extends true ? never : null) {
+  if (value === null && validation?.isRequired) {
+    throw new FieldDataError(`${label} is required`);
+  }
+}
+
+export function basicFormFieldWithSimpleReaderParse<
+  ParsedValue extends {} | null,
+  ValidatedValue extends ParsedValue
+>(config: {
+  Input(props: FormFieldInputProps<ParsedValue>): ReactElement | null;
+  defaultValue(): ParsedValue;
+  parse(value: FormFieldStoredValue): ParsedValue;
+  /**
+   * If undefined is returned, the field will generally not be written,
+   * except in array fields where it will be stored as null
+   */
+  serialize(value: ParsedValue): { value: FormFieldStoredValue };
+  validate(value: ParsedValue): ValidatedValue;
+}): BasicFormField<ParsedValue, ValidatedValue, ValidatedValue> {
+  return {
+    kind: 'form',
+    Input: config.Input,
+    defaultValue: config.defaultValue,
+    parse: config.parse,
+    serialize: config.serialize,
+    validate: config.validate,
+    reader: {
+      parse(value) {
+        return config.validate(config.parse(value));
+      },
+    },
+  };
+}
diff --git a/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx b/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
index 68305413..ff3c7684 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
@@ -459,7 +459,11 @@ function ConditionalFieldPreview({
   value,
   forceValidation,
 }: DefaultFieldProps<'conditional'>) {
-  const schemaDiscriminant = schema.discriminant as FormField<string | boolean>;
+  const schemaDiscriminant = schema.discriminant as FormField<
+    string | boolean,
+    string | boolean,
+    string | boolean
+  >;
   return (
     <Flex gap="large" direction="column">
       {useMemo(
@@ -488,7 +492,7 @@ function ConditionalFieldPreview({
 }
 
 export type NonChildFieldComponentSchema =
-  | FormField<any>
+  | FormField<any, any, any>
   | ObjectField
   | ConditionalField<BasicFormField<any>, { [key: string]: ComponentSchema }>
   | ArrayField<ComponentSchema>;
diff --git a/packages/keystatic/DocumentEditor/component-blocks/get-value.ts b/packages/keystatic/DocumentEditor/component-blocks/get-value.ts
index f09a31f2..19fd0ca1 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/get-value.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/get-value.ts
@@ -4,7 +4,7 @@ import {
   ComponentSchema,
   GenericPreviewProps,
   InitialOrUpdateValueFromComponentPropField,
-  ValueForComponentSchema,
+  ParsedValueForComponentSchema,
 } from './api';
 import { getKeysForArrayValue, setKeysForArrayValue } from './initial-values';
 
@@ -14,7 +14,7 @@ const previewPropsToValueConverter: {
       Extract<ComponentSchema, { kind: Kind }>,
       unknown
     >
-  ) => ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>;
+  ) => ParsedValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>;
 } = {
   child() {
     return null;
@@ -48,13 +48,15 @@ const previewPropsToValueConverter: {
 
 export function previewPropsToValue<Schema extends ComponentSchema>(
   props: GenericPreviewProps<ComponentSchema, unknown>
-): ValueForComponentSchema<Schema> {
+): ParsedValueForComponentSchema<Schema> {
   return (previewPropsToValueConverter[props.schema.kind] as any)(props);
 }
 
 const valueToUpdaters: {
   [Kind in ComponentSchema['kind']]: (
-    value: ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>,
+    value: ParsedValueForComponentSchema<
+      Extract<ComponentSchema, { kind: Kind }>
+    >,
     schema: Extract<ComponentSchema, { kind: Kind }>
   ) => InitialOrUpdateValueFromComponentPropField<
     Extract<ComponentSchema, { kind: Kind }>
@@ -93,14 +95,14 @@ const valueToUpdaters: {
 };
 
 export function valueToUpdater<Schema extends ComponentSchema>(
-  value: ValueForComponentSchema<Schema>,
+  value: ParsedValueForComponentSchema<Schema>,
   schema: ComponentSchema
 ): InitialOrUpdateValueFromComponentPropField<Schema> {
   return (valueToUpdaters[schema.kind] as any)(value, schema);
 }
 
 export function setValueToPreviewProps<Schema extends ComponentSchema>(
-  value: ValueForComponentSchema<Schema>,
+  value: ParsedValueForComponentSchema<Schema>,
   props: GenericPreviewProps<ComponentSchema, unknown>
 ) {
   if (isKind(props, 'child')) {
diff --git a/packages/keystatic/DocumentEditor/component-blocks/initial-values.ts b/packages/keystatic/DocumentEditor/component-blocks/initial-values.ts
index 2042b396..40067501 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/initial-values.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/initial-values.ts
@@ -56,11 +56,11 @@ export function getInitialValue(type: string, componentBlock: ComponentBlock) {
 export function getInitialPropsValue(schema: ComponentSchema): any {
   switch (schema.kind) {
     case 'form':
-      return schema.defaultValue;
+      return schema.defaultValue();
     case 'child':
       return null;
     case 'conditional': {
-      const defaultValue = schema.discriminant.defaultValue;
+      const defaultValue = schema.discriminant.defaultValue();
       return {
         discriminant: defaultValue,
         value: getInitialPropsValue(schema.values[defaultValue.toString()]),
@@ -86,13 +86,13 @@ export function getInitialPropsValueFromInitializer(
 ): any {
   switch (schema.kind) {
     case 'form':
-      return initializer === undefined ? schema.defaultValue : initializer;
+      return initializer === undefined ? schema.defaultValue() : initializer;
     case 'child':
       return null;
     case 'conditional': {
       const defaultValue =
         initializer === undefined
-          ? schema.discriminant.defaultValue
+          ? schema.discriminant.defaultValue()
           : initializer.discriminant;
       return {
         discriminant: defaultValue,
diff --git a/packages/keystatic/DocumentEditor/component-blocks/preview-props.tsx b/packages/keystatic/DocumentEditor/component-blocks/preview-props.tsx
index ea6de763..090bad38 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/preview-props.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/preview-props.tsx
@@ -2,8 +2,7 @@ import {
   ArrayField,
   ComponentSchema,
   ConditionalField,
-  ValueForComponentSchema,
-  FormFieldValue,
+  ParsedValueForComponentSchema,
   ObjectField,
   GenericPreviewProps,
   BasicFormField,
@@ -20,10 +19,12 @@ function castToMemoizedInfoForSchema<
       schema: Extract<ComponentSchema, { kind: Kind }>,
       onChange: (
         cb: (
-          prevVal: ValueForComponentSchema<
+          prevVal: ParsedValueForComponentSchema<
             Extract<ComponentSchema, { kind: Kind }>
           >
-        ) => ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>
+        ) => ParsedValueForComponentSchema<
+          Extract<ComponentSchema, { kind: Kind }>
+        >
       ) => void
     ) => unknown;
   }
@@ -55,12 +56,12 @@ export function createGetPreviewProps<
   rootSchema: Schema,
   rootOnChange: (
     cb: (
-      val: ValueForComponentSchema<Schema>
-    ) => ValueForComponentSchema<Schema>
+      val: ParsedValueForComponentSchema<Schema>
+    ) => ParsedValueForComponentSchema<Schema>
   ) => void,
   getChildFieldElement: (path: readonly string[]) => ChildFieldElement
 ): (
-  value: ValueForComponentSchema<Schema>
+  value: ParsedValueForComponentSchema<Schema>
 ) => GenericPreviewProps<Schema, ChildFieldElement> {
   const memoizedInfoForSchema = castToMemoizedInfoForSchema({
     form(schema, onChange) {
@@ -121,16 +122,18 @@ export function createGetPreviewProps<
   const previewPropsFactories: {
     [Kind in ComponentSchema['kind']]: (
       schema: Extract<ComponentSchema, { kind: Kind }>,
-      value: ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>,
+      value: ParsedValueForComponentSchema<
+        Extract<ComponentSchema, { kind: Kind }>
+      >,
       memoized: ReturnType<(typeof memoizedInfoForSchema)[Kind]>,
       path: readonly string[],
       getInnerProp: <Field extends ComponentSchema>(
         schema: Field,
-        value: ValueForComponentSchema<Field>,
+        value: ParsedValueForComponentSchema<Field>,
         onChange: (
           cb: (
-            prevVal: ValueForComponentSchema<Field>
-          ) => ValueForComponentSchema<Field>
+            prevVal: ParsedValueForComponentSchema<Field>
+          ) => ParsedValueForComponentSchema<Field>
         ) => void,
         key: string
       ) => GenericPreviewProps<Field, ChildFieldElement>
@@ -141,7 +144,7 @@ export function createGetPreviewProps<
   } = {
     form(schema, value, onChange) {
       return {
-        value: value as FormFieldValue,
+        value: value as {} | null,
         onChange,
         schema: schema as any,
       };
@@ -260,11 +263,11 @@ export function createGetPreviewProps<
     path: readonly string[],
     getInnerProp: <Field extends ComponentSchema>(
       schema: Field,
-      value: ValueForComponentSchema<Field>,
+      value: ParsedValueForComponentSchema<Field>,
       onChange: (
         cb: (
-          prevVal: ValueForComponentSchema<Field>
-        ) => ValueForComponentSchema<Field>
+          prevVal: ParsedValueForComponentSchema<Field>
+        ) => ParsedValueForComponentSchema<Field>
       ) => void,
       key: string
     ) => GenericPreviewProps<Field, ChildFieldElement>
@@ -280,11 +283,11 @@ export function createGetPreviewProps<
 
   function getInitialMemoState<Schema extends ComponentSchema>(
     schema: Schema,
-    value: ValueForComponentSchema<Schema>,
+    value: ParsedValueForComponentSchema<Schema>,
     onChange: (
       cb: (
-        val: ValueForComponentSchema<Schema>
-      ) => ValueForComponentSchema<Schema>
+        val: ParsedValueForComponentSchema<Schema>
+      ) => ParsedValueForComponentSchema<Schema>
     ) => void,
     path: readonly string[]
   ): MemoState<Schema> {
@@ -294,8 +297,8 @@ export function createGetPreviewProps<
         schema: ComponentSchema,
         onChange: (
           cb: (
-            val: ValueForComponentSchema<Schema>
-          ) => ValueForComponentSchema<Schema>
+            val: ParsedValueForComponentSchema<Schema>
+          ) => ParsedValueForComponentSchema<Schema>
         ) => void
       ) => any
     )(schema, onChange);
@@ -325,11 +328,11 @@ export function createGetPreviewProps<
   }
   function getUpToDateProps<Schema extends ComponentSchema>(
     schema: Schema,
-    value: ValueForComponentSchema<Schema>,
+    value: ParsedValueForComponentSchema<Schema>,
     onChange: (
       cb: (
-        val: ValueForComponentSchema<Schema>
-      ) => ValueForComponentSchema<Schema>
+        val: ParsedValueForComponentSchema<Schema>
+      ) => ParsedValueForComponentSchema<Schema>
     ) => void,
     memoState: MemoState<Schema>,
     path: readonly string[]
@@ -389,7 +392,7 @@ export function createGetPreviewProps<
   let memoState: MemoState<Schema>;
 
   return (
-    value: ValueForComponentSchema<Schema>
+    value: ParsedValueForComponentSchema<Schema>
   ): GenericPreviewProps<Schema, ChildFieldElement> => {
     if (memoState === undefined) {
       memoState = getInitialMemoState(rootSchema, value, rootOnChange, []);
diff --git a/packages/keystatic/DocumentEditor/component-blocks/props-value.ts b/packages/keystatic/DocumentEditor/component-blocks/props-value.ts
index cab676d7..2b0ab712 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/props-value.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/props-value.ts
@@ -1,5 +1,5 @@
 import { assertNever, assert } from 'emery/assertions';
-import { ComponentSchema, ValueForComponentSchema } from './api';
+import { ComponentSchema, ParsedValueForComponentSchema } from './api';
 import { setKeysForArrayValue, getKeysForArrayValue } from './initial-values';
 import { ReadonlyPropPath } from './utils';
 
@@ -63,9 +63,13 @@ export function transformProps(
   visitors: {
     [Kind in ComponentSchema['kind']]?: (
       schema: Extract<ComponentSchema, { kind: Kind }>,
-      value: ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>,
+      value: ParsedValueForComponentSchema<
+        Extract<ComponentSchema, { kind: Kind }>
+      >,
       path: ReadonlyPropPath
-    ) => ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>;
+    ) => ParsedValueForComponentSchema<
+      Extract<ComponentSchema, { kind: Kind }>
+    >;
   },
   path: ReadonlyPropPath = []
 ): unknown {
@@ -130,10 +134,12 @@ export async function asyncTransformProps(
   visitors: {
     [Kind in ComponentSchema['kind']]?: (
       schema: Extract<ComponentSchema, { kind: Kind }>,
-      value: ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>,
+      value: ParsedValueForComponentSchema<
+        Extract<ComponentSchema, { kind: Kind }>
+      >,
       path: ReadonlyPropPath
     ) => Promise<
-      ValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>
+      ParsedValueForComponentSchema<Extract<ComponentSchema, { kind: Kind }>>
     >;
   },
   path: ReadonlyPropPath = []
diff --git a/packages/keystatic/DocumentEditor/component-blocks/utils.ts b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
index 29c79502..97549278 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/utils.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
@@ -6,6 +6,8 @@ import { DocumentFeaturesForNormalization } from '../document-features-normaliza
 import { Mark } from '../utils';
 import { ComponentSchema, ChildField } from './api';
 import { SlugFieldInfo } from './fields/text/ui';
+import { FieldDataError } from './fields/error';
+import { PropValidationError } from '../../parse-props';
 
 export type DocumentFeaturesForChildField =
   | {
@@ -171,50 +173,102 @@ export function getSchemaAtPropPath(
   });
 }
 
+function flattenErrors(error: unknown): unknown[] {
+  if (error instanceof AggregateError) {
+    return error.errors.flatMap(flattenErrors);
+  }
+  return [error];
+}
+
+export function formatFormDataError(error: unknown) {
+  const flatErrors = flattenErrors(error);
+
+  return flatErrors
+    .map(error => {
+      if (error instanceof PropValidationError) {
+        const path = error.path.join('.');
+        return `${path}: ${
+          error.cause instanceof FieldDataError
+            ? error.cause.message
+            : `Unexpected error: ${error.cause}`
+        }`;
+      }
+      return `Unexpected error: ${error}`;
+    })
+    .join('\n');
+}
+
+export function toFormattedFormDataError(error: unknown) {
+  const formatted = formatFormDataError(error);
+  return new Error(`Field validation failed:\n` + formatted);
+}
+
 export function clientSideValidateProp(
+  schema: ComponentSchema,
+  value: any,
+  slugField: SlugFieldInfo | undefined
+) {
+  try {
+    validateValueWithSchema(schema, value, slugField);
+    return true;
+  } catch (error) {
+    console.warn(toFormattedFormDataError(error));
+    return false;
+  }
+}
+
+export function validateValueWithSchema(
   schema: ComponentSchema,
   value: any,
   slugField: SlugFieldInfo | undefined,
   path: ReadonlyPropPath = []
-): boolean {
+): void {
   switch (schema.kind) {
     case 'child': {
-      return true;
+      return;
     }
     case 'form': {
-      if (slugField && path[path.length - 1] === slugField?.field) {
-        return schema.validate(value, {
-          slugs: slugField.slugs,
-          glob: slugField.glob,
-        });
+      try {
+        if (slugField && path[path.length - 1] === slugField?.field) {
+          schema.validate(value, {
+            slugField: { slugs: slugField.slugs, glob: slugField.glob },
+          });
+          return;
+        }
+        schema.validate(value, undefined);
+      } catch (err) {
+        throw new PropValidationError(err, path, schema);
       }
-      return schema.validate(value, undefined);
+      return;
     }
     case 'conditional': {
-      if (!schema.discriminant.validate(value.discriminant)) {
-        return false;
-      }
-      return clientSideValidateProp(
+      schema.discriminant.validate(value.discriminant);
+      validateValueWithSchema(
         schema.values[value.discriminant],
         value.value,
         undefined,
         path.concat('value')
       );
+      return;
     }
     case 'object': {
+      const errors: unknown[] = [];
       for (const [key, childProp] of Object.entries(schema.fields)) {
-        if (
-          !clientSideValidateProp(
+        try {
+          validateValueWithSchema(
             childProp,
             value[key],
             key === slugField?.field ? slugField : undefined,
             path.concat(key)
-          )
-        ) {
-          return false;
+          );
+        } catch (err) {
+          errors.push(err);
         }
       }
-      return true;
+      if (errors.length > 0) {
+        throw new AggregateError(errors);
+      }
+      return;
     }
     case 'array': {
       let slugInfo: undefined | { slugField: string; slugs: string[] };
@@ -231,9 +285,11 @@ export function clientSideValidateProp(
           ),
         };
       }
+      const errors: unknown[] = [];
+
       for (const [idx, innerVal] of (value as unknown[]).entries()) {
-        if (
-          !clientSideValidateProp(
+        try {
+          validateValueWithSchema(
             schema.element,
             innerVal,
             slugInfo === undefined
@@ -244,12 +300,15 @@ export function clientSideValidateProp(
                   glob: '*',
                 },
             path.concat(idx)
-          )
-        ) {
-          return false;
+          );
+        } catch (err) {
+          errors.push(err);
         }
       }
-      return true;
+      if (errors.length > 0) {
+        throw new AggregateError(errors);
+      }
+      return;
     }
   }
 }
diff --git a/packages/keystatic/app/path-utils.ts b/packages/keystatic/app/path-utils.ts
index 2fe66f18..a488e6e0 100644
--- a/packages/keystatic/app/path-utils.ts
+++ b/packages/keystatic/app/path-utils.ts
@@ -2,7 +2,7 @@ import { assert } from 'emery';
 import { Config, DataFormat, Format, Glob } from '../config';
 import {
   ComponentSchema,
-  FormFieldWithFile,
+  ContentFormField,
 } from '../DocumentEditor/component-blocks/api';
 
 export function fixPath(path: string) {
@@ -92,7 +92,7 @@ export function getSingletonPath(config: Config, singleton: string) {
 
 export function getDataFileExtension(formatInfo: FormatInfo) {
   return formatInfo.contentField
-    ? formatInfo.contentField.config.serializeToFile.primaryExtension
+    ? formatInfo.contentField.config.contentExtension
     : '.' + formatInfo.data;
 }
 
@@ -117,14 +117,12 @@ function getFormatInfo(
       `${format.contentField} is not a form field`
     );
     assert(
-      'serializeToFile' in field && field.serializeToFile?.kind === 'multi',
-      `${format.contentField} does not have a multi serializeToFile config`
+      field.formKind === 'content',
+      `${format.contentField} is not a content field`
     );
     contentField = {
       key: format.contentField,
-      config: field as typeof field & {
-        serializeToFile: { kind: 'multi' };
-      },
+      config: field as ContentFormField<any, any, any>,
     };
   }
   return {
@@ -139,9 +137,7 @@ export type FormatInfo = {
   contentField:
     | {
         key: string;
-        config: FormFieldWithFile<any, any> & {
-          serializeToFile: { kind: 'multi' };
-        };
+        config: ContentFormField<any, any, any>;
       }
     | undefined;
   dataLocation: 'index' | 'outer';
diff --git a/packages/keystatic/app/required-files.ts b/packages/keystatic/app/required-files.ts
index cbfba706..3754b959 100644
--- a/packages/keystatic/app/required-files.ts
+++ b/packages/keystatic/app/required-files.ts
@@ -1,220 +1,19 @@
-import { assert, assertNever } from 'emery';
+import { assert } from 'emery';
 import { load } from 'js-yaml';
 import {
-  ComponentSchema,
-  FormFieldWithFile,
+  AssetFormField,
+  ContentFormField,
+  JsonValue,
 } from '../DocumentEditor/component-blocks/api';
 import { ReadonlyPropPath } from '../DocumentEditor/component-blocks/utils';
 import { FormatInfo } from './path-utils';
-import { getSlugFromState } from './utils';
-import { traverseProps } from '../DocumentEditor/component-blocks/props-value';
 
 export type RequiredFile = {
   path: ReadonlyPropPath;
-  schema: FormFieldWithFile<any, unknown>;
+  schema: AssetFormField<any, any, any> | ContentFormField<any, any, any>;
   file: { filename: string; parent: string | undefined } | undefined;
 };
 
-export function getPropPathPortion(
-  path: ReadonlyPropPath,
-  schema: ComponentSchema,
-  value: unknown
-): string {
-  const end: (string | number)[] = [];
-  for (const portion of path) {
-    if (schema.kind === 'array') {
-      value = (value as any)[portion];
-      if (schema.slugField && schema.element.kind === 'object') {
-        const slug = getSlugFromState(
-          { schema: schema.element.fields, slugField: schema.slugField },
-          value as Record<string, unknown>
-        );
-        end.push(slug);
-      } else {
-        end.push(portion);
-      }
-      schema = schema.element;
-      continue;
-    }
-    end.push(portion);
-    if (schema.kind === 'object') {
-      value = (value as any)[portion];
-      schema = schema.fields[portion];
-      continue;
-    }
-    if (schema.kind === 'conditional') {
-      if (portion === 'discriminant') {
-        schema = schema.discriminant;
-      } else if (portion === 'value') {
-        schema = schema.values[(value as any).discriminant];
-      }
-      value = (value as any)[portion];
-      continue;
-    }
-    throw new Error(`unexpected ${schema.kind}`);
-  }
-  return end.join('/');
-}
-
-export function getRequiredFiles(
-  rootValue: unknown,
-  rootSchema: ComponentSchema,
-  slug: string | undefined
-) {
-  const requiredFiles: RequiredFile[] = [];
-  traverseProps(rootSchema, rootValue, (schema, val, propPath) => {
-    if (schema.kind === 'form') {
-      if ('serializeToFile' in schema && schema.serializeToFile) {
-        if (schema.serializeToFile.kind === 'asset') {
-          const filename = schema.serializeToFile.filename(
-            val,
-            getPropPathPortion(propPath, rootSchema, rootValue),
-            slug
-          );
-          const files: string[] = [];
-          if (filename) {
-            files.push(filename);
-          }
-          requiredFiles.push({
-            path: propPath,
-            schema,
-            file: filename
-              ? { filename, parent: schema.serializeToFile.directory }
-              : undefined,
-          });
-        }
-        if (schema.serializeToFile.kind === 'multi') {
-          const basePath = getPropPathPortion(propPath, rootSchema, rootValue);
-          requiredFiles.push({
-            path: propPath,
-            schema,
-            file: {
-              filename: basePath + schema.serializeToFile.primaryExtension,
-              parent: undefined,
-            },
-          });
-        }
-      }
-    }
-  });
-  return requiredFiles;
-}
-
-export function parseSerializedFormField(
-  value: unknown,
-  file: {
-    path: ReadonlyPropPath;
-    schema: FormFieldWithFile<any, unknown>;
-  },
-  loadedBinaryFiles: Map<string, Uint8Array>,
-  mode: 'read' | 'edit',
-  basePath: string,
-  slug: string | undefined,
-  rootValue: unknown,
-  rootSchema: ComponentSchema
-) {
-  const serializationConfig = file.schema.serializeToFile!;
-
-  if (serializationConfig.kind === 'asset') {
-    const suggestedFilenamePrefix = getPropPathPortion(
-      file.path,
-      rootSchema,
-      rootValue
-    );
-    const filepath = serializationConfig.filename(
-      value,
-      suggestedFilenamePrefix,
-      slug
-    );
-    if (
-      mode === 'read' &&
-      !serializationConfig.reader.requiresContentInReader
-    ) {
-      return serializationConfig.reader.parseToReader({
-        value,
-        suggestedFilenamePrefix,
-      });
-    }
-    const content = filepath
-      ? loadedBinaryFiles.get(
-          `${
-            serializationConfig.directory
-              ? `${serializationConfig.directory}${
-                  slug === undefined ? '' : `/${slug}`
-                }`
-              : basePath
-          }/${filepath}`
-        )
-      : undefined;
-
-    const parsed =
-      mode === 'read' && serializationConfig.reader.requiresContentInReader
-        ? serializationConfig.reader.parseToReader({
-            content,
-            value,
-            suggestedFilenamePrefix,
-          })
-        : serializationConfig.parse({
-            content,
-            value,
-            suggestedFilenamePrefix,
-            slug,
-          });
-    return parsed;
-  }
-  if (serializationConfig.kind === 'multi') {
-    const rootPath = `${basePath}/${getPropPathPortion(
-      file.path,
-      rootSchema,
-      rootValue
-    )}`;
-    const mainFilepath = rootPath + serializationConfig.primaryExtension;
-    if (mode === 'read') {
-      if (serializationConfig.reader.requiresContentInReader) {
-        const mainContents = loadedBinaryFiles.get(mainFilepath);
-        return serializationConfig.reader.parseToReader({
-          value,
-          primary: mainContents,
-        });
-      }
-      return serializationConfig.reader.parseToReader({ value });
-    }
-    const mainContents = loadedBinaryFiles.get(mainFilepath);
-
-    const otherFiles = new Map<string, Uint8Array>();
-    const otherDirectories = new Map<string, Map<string, Uint8Array>>();
-
-    for (const [filename] of loadedBinaryFiles) {
-      if (filename.startsWith(rootPath + '/')) {
-        const relativePath = filename.slice(rootPath.length + 1);
-        otherFiles.set(relativePath, loadedBinaryFiles.get(filename)!);
-      }
-    }
-    for (const dir of serializationConfig.directories ?? []) {
-      const dirFiles = new Map<string, Uint8Array>();
-      const start = `${dir}${slug === undefined ? '' : `/${slug}`}/`;
-      for (const [filename, val] of loadedBinaryFiles) {
-        if (filename.startsWith(start)) {
-          const relativePath = filename.slice(start.length);
-          dirFiles.set(relativePath, val);
-        }
-      }
-      if (dirFiles.size) {
-        otherDirectories.set(dir, dirFiles);
-      }
-    }
-
-    return serializationConfig.parse({
-      value,
-      primary: mainContents,
-      other: otherFiles,
-      external: otherDirectories,
-      slug,
-    });
-  }
-  assertNever(serializationConfig);
-}
-
 const textDecoder = new TextDecoder();
 const textEncoder = new TextEncoder();
 
@@ -231,7 +30,16 @@ function splitFrontmatter(data: Uint8Array) {
   return null;
 }
 
-export function loadDataFile(data: Uint8Array, formatInfo: FormatInfo) {
+export function loadDataFile(
+  data: Uint8Array,
+  formatInfo: FormatInfo
+): {
+  loaded: JsonValue;
+  extraFakeFile?: {
+    path: string;
+    contents: Uint8Array;
+  };
+} {
   const parse = formatInfo.data === 'json' ? JSON.parse : load;
   if (!formatInfo.contentField) {
     const dataFile = textDecoder.decode(data);
@@ -244,7 +52,7 @@ export function loadDataFile(data: Uint8Array, formatInfo: FormatInfo) {
   return {
     loaded: parse(res.frontmatter),
     extraFakeFile: {
-      path: `${formatInfo.contentField.key}${formatInfo.contentField.config.serializeToFile.primaryExtension}`,
+      path: `${formatInfo.contentField.key}${formatInfo.contentField.config.contentExtension}`,
       contents: res.content,
     },
   };
diff --git a/packages/keystatic/app/tree-key.tsx b/packages/keystatic/app/tree-key.tsx
index 0428bbe9..102c672b 100644
--- a/packages/keystatic/app/tree-key.tsx
+++ b/packages/keystatic/app/tree-key.tsx
@@ -24,20 +24,12 @@ function collectDirectoriesUsedInSchemaInner(
     return;
   }
   if (schema.kind === 'form') {
-    if ('serializeToFile' in schema) {
-      if (
-        schema.serializeToFile.kind === 'asset' &&
-        schema.serializeToFile.directory !== undefined
-      ) {
-        directories.add(fixPath(schema.serializeToFile.directory));
-      }
-      if (
-        schema.serializeToFile.kind === 'multi' &&
-        schema.serializeToFile.directories !== undefined
-      ) {
-        for (const directory of schema.serializeToFile.directories) {
-          directories.add(fixPath(directory));
-        }
+    if (schema.formKind === 'asset' && schema.directory !== undefined) {
+      directories.add(fixPath(schema.directory));
+    }
+    if (schema.formKind === 'content' && schema.directories !== undefined) {
+      for (const directory of schema.directories) {
+        directories.add(fixPath(directory));
       }
     }
     return;
diff --git a/packages/keystatic/app/useHasChanged.ts b/packages/keystatic/app/useHasChanged.ts
index 63f767d7..c96fe310 100644
--- a/packages/keystatic/app/useHasChanged.ts
+++ b/packages/keystatic/app/useHasChanged.ts
@@ -1,12 +1,11 @@
 import isEqual from 'fast-deep-equal';
-import { useCallback, useState, useMemo } from 'react';
+import { useMemo } from 'react';
 import {
   ComponentSchema,
   ObjectField,
 } from '../DocumentEditor/component-blocks/api';
-import { toFiles } from '../utils';
-import { useData } from './useData';
 import { getSlugFromState } from './utils';
+import { serializeProps } from '../serialize-props';
 
 export function useHasChanged(args: {
   initialState: unknown;
@@ -14,61 +13,39 @@ export function useHasChanged(args: {
   schema: ObjectField<Record<string, ComponentSchema>>;
   slugField: string | undefined;
 }) {
-  const initialFilesForUpdate = useData(
-    useCallback(
-      () =>
-        toFiles(
-          args.initialState,
-          args.schema,
-          args.slugField
-            ? {
-                field: args.slugField,
-                value: getSlugFromState(
-                  { schema: args.schema.fields, slugField: args.slugField },
-                  args.initialState as Record<string, unknown>
-                ),
-              }
-            : undefined
-        ),
-      [args.initialState, args.schema, args.slugField]
-    )
-  );
-  const filesForUpdate = useData(
-    useCallback(
-      () =>
-        toFiles(
-          args.state,
-          args.schema,
-          args.slugField
-            ? {
-                field: args.slugField,
-                value: getSlugFromState(
-                  { schema: args.schema.fields, slugField: args.slugField },
-                  args.state as Record<string, unknown>
-                ),
-              }
-            : undefined
-        ),
-      [args.state, args.schema, args.slugField]
-    )
+  const initialFilesForUpdate = useMemo(
+    () =>
+      serializeProps(
+        args.initialState,
+        args.schema,
+        args.slugField,
+        args.slugField
+          ? getSlugFromState(
+              { schema: args.schema.fields, slugField: args.slugField },
+              args.initialState as Record<string, unknown>
+            )
+          : undefined
+      ),
+    [args.initialState, args.schema, args.slugField]
   );
 
-  const [hasChanged, setHasChanged] = useState(false);
+  const filesForUpdate = useMemo(
+    () =>
+      serializeProps(
+        args.state,
+        args.schema,
+        args.slugField,
+        args.slugField
+          ? getSlugFromState(
+              { schema: args.schema.fields, slugField: args.slugField },
+              args.state as Record<string, unknown>
+            )
+          : undefined
+      ),
+    [args.state, args.schema, args.slugField]
+  );
 
-  const hasChangedState = useMemo(() => {
-    if (
-      initialFilesForUpdate.kind === 'loaded' &&
-      filesForUpdate.kind === 'loaded'
-    ) {
-      const a = initialFilesForUpdate.data;
-      const b = filesForUpdate.data;
-      return !isEqual(a, b);
-    }
-    return 'unknown' as const;
+  return useMemo(() => {
+    return !isEqual(initialFilesForUpdate, filesForUpdate);
   }, [initialFilesForUpdate, filesForUpdate]);
-
-  if (typeof hasChangedState === 'boolean' && hasChangedState !== hasChanged) {
-    setHasChanged(hasChangedState);
-  }
-  return hasChanged;
 }
diff --git a/packages/keystatic/app/useItemData.ts b/packages/keystatic/app/useItemData.ts
index 778fe2a4..00ac5031 100644
--- a/packages/keystatic/app/useItemData.ts
+++ b/packages/keystatic/app/useItemData.ts
@@ -2,11 +2,10 @@ import LRUCache from 'lru-cache';
 import { useCallback, useMemo } from 'react';
 import { Config } from '../config';
 import { SlugFieldInfo } from '../DocumentEditor/component-blocks/fields/text/ui';
-import { transformProps } from '../DocumentEditor/component-blocks/props-value';
 import { ComponentSchema, fields } from '../src';
-import { validateComponentBlockProps } from '../validate-component-block-props';
+import { parseProps } from '../parse-props';
 import { getAuth } from './auth';
-import { loadDataFile, parseSerializedFormField } from './required-files';
+import { loadDataFile } from './required-files';
 import { useTree } from './shell/data';
 import { getDirectoriesForTreeKey, getTreeKey } from './tree-key';
 import { TreeNode, getTreeNodeAtPath, TreeEntry, blobSha } from './trees';
@@ -19,6 +18,7 @@ import {
   KEYSTATIC_CLOUD_HEADERS,
   MaybePromise,
 } from './utils';
+import { toFormattedFormDataError } from '../DocumentEditor/component-blocks/utils';
 
 function parseEntry(args: UseItemDataArgs, files: Map<string, Uint8Array>) {
   const dataFilepath = getEntryDataFilepath(args.dirpath, args.format);
@@ -27,19 +27,6 @@ function parseEntry(args: UseItemDataArgs, files: Map<string, Uint8Array>) {
     throw new Error(`Could not find data file at ${dataFilepath}`);
   }
   const { loaded, extraFakeFile } = loadDataFile(data, args.format);
-  const schema = fields.object(args.schema);
-  const validated = validateComponentBlockProps(
-    schema,
-    loaded,
-    [],
-    args.slug
-      ? {
-          ...args.slug,
-          mode: 'parse',
-          slug: args.slug.slug,
-        }
-      : undefined
-  );
   const filesWithFakeFile = new Map(files);
   if (extraFakeFile) {
     filesWithFakeFile.set(
@@ -47,24 +34,90 @@ function parseEntry(args: UseItemDataArgs, files: Map<string, Uint8Array>) {
       extraFakeFile.contents
     );
   }
-  const rootSchema = schema;
-  const initialState = transformProps(schema, validated, {
-    form(schema, val, path) {
-      if ('serializeToFile' in schema) {
-        return parseSerializedFormField(
-          val,
-          { path, schema },
-          filesWithFakeFile,
-          'edit',
-          args.dirpath,
-          args.slug?.slug,
-          validated,
-          rootSchema
-        );
+  const rootSchema = fields.object(args.schema);
+  let initialState;
+  try {
+    initialState = parseProps(
+      rootSchema,
+      loaded,
+      [],
+      [],
+      (schema, value, path, pathWithArrayFieldSlugs) => {
+        if (path.length === 1 && path[0] === args.slug?.field) {
+          if (schema.formKind !== 'slug') {
+            throw new Error(`slugField is not a slug field`);
+          }
+          return schema.parse(value, { slug: args.slug.slug });
+        }
+        if (schema.formKind === 'asset') {
+          const suggestedFilenamePrefix = pathWithArrayFieldSlugs.join('/');
+          const filepath = schema.filename(value, {
+            suggestedFilenamePrefix,
+            slug: args.slug?.slug,
+          });
+          const asset = filepath
+            ? filesWithFakeFile.get(
+                `${
+                  schema.directory
+                    ? `${schema.directory}${
+                        args.slug?.slug === undefined
+                          ? ''
+                          : `/${args.slug.slug}`
+                      }`
+                    : args.dirpath
+                }/${filepath}`
+              )
+            : undefined;
+
+          return schema.parse(value, { asset, slug: args.slug?.slug });
+        }
+        if (schema.formKind === 'content') {
+          const rootPath = `${args.dirpath}/${pathWithArrayFieldSlugs.join(
+            '/'
+          )}`;
+          const mainFilepath = rootPath + schema.contentExtension;
+          const mainContents = filesWithFakeFile.get(mainFilepath);
+
+          const otherFiles = new Map<string, Uint8Array>();
+          const otherDirectories = new Map<string, Map<string, Uint8Array>>();
+
+          for (const [filename] of filesWithFakeFile) {
+            if (filename.startsWith(rootPath + '/')) {
+              const relativePath = filename.slice(rootPath.length + 1);
+              otherFiles.set(relativePath, filesWithFakeFile.get(filename)!);
+            }
+          }
+          for (const dir of schema.directories ?? []) {
+            const dirFiles = new Map<string, Uint8Array>();
+            const start = `${dir}${
+              args.slug?.slug === undefined ? '' : `/${args.slug?.slug}`
+            }/`;
+            for (const [filename, val] of filesWithFakeFile) {
+              if (filename.startsWith(start)) {
+                const relativePath = filename.slice(start.length);
+                dirFiles.set(relativePath, val);
+              }
+            }
+            if (dirFiles.size) {
+              otherDirectories.set(dir, dirFiles);
+            }
+          }
+
+          return schema.parse(value, {
+            content: mainContents,
+            other: otherFiles,
+            external: otherDirectories,
+            slug: args.slug?.slug,
+          });
+        }
+
+        return schema.parse(value, undefined);
       }
-      return val;
-    },
-  }) as Record<string, unknown>;
+    );
+  } catch (err) {
+    throw toFormattedFormDataError(err);
+  }
+
   const initialFiles = [...files.keys()];
 
   return { initialState, initialFiles };
diff --git a/packages/keystatic/app/utils.ts b/packages/keystatic/app/utils.ts
index da087432..27b001a7 100644
--- a/packages/keystatic/app/utils.ts
+++ b/packages/keystatic/app/utils.ts
@@ -2,7 +2,7 @@ import { fromUint8Array } from 'js-base64';
 import { isDefined } from 'emery';
 
 import { Config, GitHubConfig, LocalConfig } from '../config';
-import { CloudConfig, ComponentSchema, SlugFormField } from '../src';
+import { CloudConfig, ComponentSchema } from '../src';
 import {
   getCollectionFormat,
   getCollectionItemPath,
@@ -57,16 +57,6 @@ export function getRepoUrl(config: { mainOwner: string; mainRepo: string }) {
   return `https://github.com/${getRepoPath(config)}`;
 }
 
-export function isSlugFormField(
-  schema: ComponentSchema
-): schema is SlugFormField<unknown, unknown> {
-  return (
-    schema.kind === 'form' &&
-    'slug' in schema &&
-    typeof schema.slug === 'object'
-  );
-}
-
 export function getSlugFromState(
   collectionConfig: {
     slugField: string;
@@ -76,10 +66,10 @@ export function getSlugFromState(
 ) {
   const value = state[collectionConfig.slugField];
   const field = collectionConfig.schema[collectionConfig.slugField];
-  if (!isSlugFormField(field)) {
+  if (field.kind !== 'form' || field.formKind !== 'slug') {
     throw new Error(`slugField is not a slug field`);
   }
-  return field.slug.serialize(value).slug;
+  return field.serializeWithSlug(value).slug;
 }
 
 export function getEntriesInCollectionWithTreeKey(
diff --git a/packages/keystatic/config.tsx b/packages/keystatic/config.tsx
index b8b04013..7359c443 100644
--- a/packages/keystatic/config.tsx
+++ b/packages/keystatic/config.tsx
@@ -119,7 +119,9 @@ export function config<
 export function collection<
   Schema extends Record<string, ComponentSchema>,
   SlugField extends {
-    [K in keyof Schema]: Schema[K] extends SlugFormField<any, any> ? K : never;
+    [K in keyof Schema]: Schema[K] extends SlugFormField<any, any, any, any>
+      ? K
+      : never;
   }[keyof Schema]
 >(
   collection: Collection<Schema, SlugField & string>
diff --git a/packages/keystatic/markdoc/to-markdoc.tsx b/packages/keystatic/markdoc/to-markdoc.tsx
index b1508b06..ecbe81f9 100644
--- a/packages/keystatic/markdoc/to-markdoc.tsx
+++ b/packages/keystatic/markdoc/to-markdoc.tsx
@@ -3,7 +3,7 @@ import { ReadonlyPropPath } from '../DocumentEditor/component-blocks/utils';
 import { getValueAtPropPath } from '../DocumentEditor/component-blocks/props-value';
 import { areArraysEqual } from '../DocumentEditor/document-features-normalization';
 import { Mark } from '../DocumentEditor/utils';
-import { ComponentBlock, ComponentSchema } from '../src';
+import { ComponentBlock } from '../src';
 import {
   findSingleChildField,
   PathToChildFieldWithOption,
@@ -11,6 +11,9 @@ import {
 import { DocumentFeatures } from '../DocumentEditor/document-features';
 import { getInitialPropsValueFromInitializer } from '../DocumentEditor/component-blocks/initial-values';
 import { Descendant } from 'slate';
+import { fixPath } from '../app/path-utils';
+import { getSrcPrefixForImageBlock } from '../DocumentEditor/component-blocks/document-field';
+import { serializeProps } from '../serialize-props';
 
 function toInline(nodes: Descendant[]): Node {
   return new Ast.Node('inline', {}, nodes.flatMap(toMarkdocInline));
@@ -57,65 +60,30 @@ function toMarkdocInline(node: Descendant): Node | Node[] {
 
 export function toMarkdocDocument(
   nodes: Descendant[],
-  config: {
+  _config: {
     componentBlocks: Record<string, ComponentBlock>;
     documentFeatures: DocumentFeatures;
+    slug: string | undefined;
   }
 ) {
-  return new Ast.Node(
+  const extraFiles: {
+    contents: Uint8Array;
+    path: string;
+    parent: string | undefined;
+  }[] = [];
+  const config = {
+    ..._config,
+    extraFiles,
+  };
+  const node = new Ast.Node(
     'document',
     {},
     nodes.flatMap(x => toMarkdoc(x, config))
   );
-}
-
-function removeUnnecessaryFieldValues(
-  schema: ComponentSchema,
-  parent: ComponentSchema | undefined,
-  value: unknown
-): unknown {
-  if (schema.kind === 'form') {
-    // i am very unsure about this magic not writing defaults
-    if (value === schema.defaultValue && parent?.kind === 'object') {
-      return undefined;
-    }
-    return value;
-  }
-  if (schema.kind === 'child') {
-    if (parent?.kind === 'array') {
-      return null;
-    }
-    return undefined;
-  }
-  if (schema.kind === 'array') {
-    return (value as unknown[]).map(x =>
-      removeUnnecessaryFieldValues(schema.element, schema, x)
-    );
-  }
-  if (schema.kind === 'object') {
-    return Object.fromEntries(
-      Object.entries(schema.fields)
-        .map(([key, innerSchema]) => [
-          key,
-          removeUnnecessaryFieldValues(
-            innerSchema,
-            schema,
-            (value as any)[key]
-          ),
-        ])
-        .filter(([, val]) => val !== undefined)
-    );
-  }
-  if (schema.kind === 'conditional') {
-    return {
-      discriminant: (value as any).discriminant,
-      value: removeUnnecessaryFieldValues(
-        schema.values[(value as any).discriminant],
-        schema,
-        (value as any).value
-      ),
-    };
-  }
+  return {
+    node,
+    extraFiles,
+  };
 }
 
 function toChildrenAndProps(
@@ -191,6 +159,12 @@ function toMarkdoc(
   config: {
     componentBlocks: Record<string, ComponentBlock>;
     documentFeatures: DocumentFeatures;
+    slug: string | undefined;
+    extraFiles: {
+      contents: Uint8Array;
+      path: string;
+      parent: string | undefined;
+    }[];
   }
 ): Node {
   if (node.type === 'paragraph') {
@@ -209,10 +183,24 @@ function toMarkdoc(
     return markdocNode;
   }
   if (node.type === 'image') {
+    config.extraFiles.push({
+      contents: node.src.content,
+      path: node.src.filename,
+      parent:
+        typeof config.documentFeatures.images === 'object' &&
+        typeof config.documentFeatures.images.directory === 'string'
+          ? fixPath(config.documentFeatures.images.directory)
+          : undefined,
+    });
     return new Ast.Node('paragraph', {}, [
       new Ast.Node('inline', {}, [
         new Ast.Node('image', {
-          src: encodeURI(node.src as unknown as string),
+          src: encodeURI(
+            `${getSrcPrefixForImageBlock(
+              config.documentFeatures,
+              config.slug
+            )}${node.src.filename}`
+          ),
           alt: node.alt,
           title: node.title,
         }),
@@ -227,14 +215,14 @@ function toMarkdoc(
         ? config.documentFeatures.formatting.blockTypes.code.schema
         : undefined;
     if (schema && Object.keys(schema.fields).length > 0) {
-      Object.assign(
-        extraAttributes,
-        removeUnnecessaryFieldValues(
-          schema,
-          undefined,
-          getInitialPropsValueFromInitializer(schema, rest)
-        )
+      const serialized = serializeProps(
+        getInitialPropsValueFromInitializer(schema, rest),
+        schema,
+        undefined,
+        config.slug
       );
+      Object.assign(extraAttributes, serialized.value);
+      config.extraFiles.push(...serialized.extraFiles);
     }
 
     let content = (children[0] as { text: string }).text + '\n';
@@ -301,10 +289,11 @@ function toMarkdoc(
     if (Object.keys(schema.fields).length > 0) {
       Object.assign(
         extraAttributes,
-        removeUnnecessaryFieldValues(
+        serializeProps(
+          getInitialPropsValueFromInitializer(schema, rest),
           schema,
           undefined,
-          getInitialPropsValueFromInitializer(schema, rest)
+          config.slug
         )
       );
     }
@@ -385,15 +374,17 @@ function toMarkdoc(
       }
     }
 
-    const attributes = componentBlock
-      ? (removeUnnecessaryFieldValues(
-          { kind: 'object' as const, fields: componentBlock.schema },
-          undefined,
-          node.props
-        ) as Record<string, unknown>)
-      : node.props;
+    let attributes = node.props;
 
     if (componentBlock) {
+      const serialized = serializeProps(
+        node.props,
+        { kind: 'object' as const, fields: componentBlock.schema },
+        undefined,
+        config.slug
+      );
+      attributes = serialized.value as Record<string, unknown>;
+      config.extraFiles.push(...serialized.extraFiles);
       const singleChildField = findSingleChildField({
         kind: 'object',
         fields: componentBlock.schema,
diff --git a/packages/keystatic/parse-props.ts b/packages/keystatic/parse-props.ts
new file mode 100644
index 00000000..871a6646
--- /dev/null
+++ b/packages/keystatic/parse-props.ts
@@ -0,0 +1,206 @@
+import { assertNever } from 'emery';
+import { ComponentSchema } from './DocumentEditor/component-blocks/api';
+import { ReadonlyPropPath } from './DocumentEditor/component-blocks/utils';
+import { FormField, FormFieldStoredValue, JsonValue } from './src';
+import { FieldDataError } from './DocumentEditor/component-blocks/fields/error';
+
+export class PropValidationError extends Error {
+  path: ReadonlyPropPath;
+  schema: ComponentSchema;
+  cause: unknown;
+  constructor(cause: unknown, path: ReadonlyPropPath, schema: ComponentSchema) {
+    super(`field error at ${path.join('.')}`, { cause });
+    this.path = path;
+    this.schema = schema;
+    this.cause = cause;
+  }
+}
+
+function toFormFieldStoredValue(
+  val: JsonValue | undefined
+): FormFieldStoredValue {
+  if (val === null) {
+    return undefined;
+  }
+  return val;
+}
+
+const isArray: (val: unknown) => val is readonly unknown[] = Array.isArray;
+
+export function parseProps(
+  schema: ComponentSchema,
+  _value: JsonValue | undefined,
+  path: ReadonlyPropPath,
+  pathWithArrayFieldSlugs: readonly string[],
+  parseFormField: (
+    schema: FormField<any, any, any>,
+    value: FormFieldStoredValue,
+    path: ReadonlyPropPath,
+    pathWithArrayFieldSlugs: readonly string[]
+  ) => any
+): any {
+  let value = toFormFieldStoredValue(_value);
+  if (schema.kind === 'form') {
+    try {
+      return parseFormField(schema, value, path, pathWithArrayFieldSlugs);
+    } catch (err) {
+      throw new PropValidationError(err, path, schema);
+    }
+  }
+  if (schema.kind === 'child') {
+    return null;
+  }
+  if (schema.kind === 'conditional') {
+    if (value === undefined) {
+      return undefined;
+    }
+    try {
+      if (typeof value !== 'object' || value === null || isArray(value)) {
+        throw new FieldDataError('Must be an object');
+      }
+      for (const key of Object.keys(value)) {
+        if (key !== 'discriminant' && key !== 'value') {
+          throw new FieldDataError(
+            `Must only contain keys "discriminant" and "value", not "${key}"`
+          );
+        }
+      }
+    } catch (err) {
+      throw new PropValidationError(err, path, schema);
+    }
+
+    const parsedDiscriminant = parseProps(
+      schema.discriminant,
+      value.discriminant,
+      path.concat('discriminant'),
+      pathWithArrayFieldSlugs.concat('discriminant'),
+      parseFormField
+    );
+
+    return {
+      discriminant: parsedDiscriminant,
+      value: parseProps(
+        schema.values[parsedDiscriminant],
+        value.value,
+        path.concat('value'),
+        pathWithArrayFieldSlugs.concat('value'),
+        parseFormField
+      ),
+    };
+  }
+
+  if (schema.kind === 'object') {
+    if (value === undefined) {
+      value = {};
+    }
+    try {
+      if (typeof value !== 'object' || value === null || isArray(value)) {
+        throw new FieldDataError('Must be an object');
+      }
+      const allowedKeysSet = new Set(Object.keys(schema.fields));
+      for (const key of Object.keys(value)) {
+        if (!allowedKeysSet.has(key)) {
+          throw new FieldDataError(
+            `Key on object value "${key}" is not allowed`
+          );
+        }
+      }
+    } catch (err) {
+      throw new PropValidationError(err, path, schema);
+    }
+    const val: Record<string, any> = {};
+    const errors: unknown[] = [];
+    for (const key of Object.keys(schema.fields)) {
+      let individualVal = value[key];
+      try {
+        const propVal = parseProps(
+          schema.fields[key],
+          individualVal,
+          path.concat(key),
+          pathWithArrayFieldSlugs.concat(key),
+          parseFormField
+        );
+        val[key] = propVal;
+      } catch (err) {
+        errors.push(err);
+      }
+    }
+    if (errors.length) {
+      throw new AggregateError(errors);
+    }
+    return val;
+  }
+  if (schema.kind === 'array') {
+    if (value === undefined) {
+      return [];
+    }
+    try {
+      if (!isArray(value)) {
+        throw new FieldDataError('Must be an array');
+      }
+    } catch (err) {
+      throw new PropValidationError(err, path, schema);
+    }
+    const errors: unknown[] = [];
+    try {
+      return value.map((innerVal, i) => {
+        try {
+          let slug = i.toString();
+          if (
+            schema.slugField &&
+            typeof innerVal === 'object' &&
+            innerVal !== null &&
+            !isArray(innerVal)
+          ) {
+            if (schema.element.kind !== 'object') {
+              throw new Error(
+                'slugField on array fields requires the an object field element'
+              );
+            }
+            const slugField = schema.element.fields[schema.slugField];
+            if (!slugField) {
+              throw new Error(
+                `slugField "${schema.slugField}" does not exist on object field`
+              );
+            }
+            if (slugField.kind !== 'form') {
+              throw new Error(
+                `slugField "${schema.slugField}" is not a form field`
+              );
+            }
+            if (slugField.formKind !== 'slug') {
+              throw new Error(
+                `slugField "${schema.slugField}" is not a slug field`
+              );
+            }
+
+            let parsedSlugFieldValue;
+            try {
+              parsedSlugFieldValue = slugField.parse(
+                toFormFieldStoredValue(innerVal[schema.slugField]),
+                undefined
+              );
+            } catch (err) {
+              throw new AggregateError([err]);
+            }
+            slug = slugField.serializeWithSlug(parsedSlugFieldValue).slug;
+          }
+          return parseProps(
+            schema.element,
+            innerVal,
+            path.concat(i),
+            pathWithArrayFieldSlugs.concat(slug),
+            parseFormField
+          );
+        } catch (err) {
+          errors.push(err);
+        }
+      });
+    } finally {
+      if (errors.length) {
+        throw new AggregateError(errors);
+      }
+    }
+  }
+  assertNever(schema);
+}
diff --git a/packages/keystatic/serialize-props.ts b/packages/keystatic/serialize-props.ts
new file mode 100644
index 00000000..5c240776
--- /dev/null
+++ b/packages/keystatic/serialize-props.ts
@@ -0,0 +1,145 @@
+import { transformProps } from './DocumentEditor/component-blocks/props-value';
+import { ReadonlyPropPath } from './DocumentEditor/component-blocks/utils';
+import { getSlugFromState } from './app/utils';
+import { ComponentSchema } from './src';
+
+export function serializeProps(
+  rootValue: unknown,
+  rootSchema: ComponentSchema,
+  // note you might have a slug without a slug field when serializing props inside a component block or etc. in the editor
+  slugField: string | undefined,
+  slug: string | undefined
+) {
+  const extraFiles: {
+    path: string;
+    parent: string | undefined;
+    contents: Uint8Array;
+  }[] = [];
+  return {
+    value: transformProps(rootSchema, rootValue, {
+      form(schema, value, propPath) {
+        if (propPath.length === 1 && slugField === propPath[0]) {
+          if (schema.formKind !== 'slug') {
+            throw new Error('slugField is a not a slug field');
+          }
+          return schema.serializeWithSlug(value).value;
+        }
+        if (schema.formKind === 'asset') {
+          const suggestedFilenamePrefix = getPropPathPortion(
+            propPath,
+            rootSchema,
+            rootValue
+          );
+
+          const { asset, value: forYaml } = schema.serialize(value, {
+            suggestedFilenamePrefix,
+            slug,
+          });
+          if (asset) {
+            extraFiles.push({
+              path: asset.filename,
+              contents: asset.content,
+              parent: schema.directory,
+            });
+          }
+          return forYaml;
+        }
+        if (schema.formKind === 'content') {
+          const {
+            other,
+            external,
+            content,
+            value: forYaml,
+          } = schema.serialize(value, { slug });
+          if (content) {
+            extraFiles.push({
+              path:
+                getPropPathPortion(propPath, rootSchema, rootValue) +
+                schema.contentExtension,
+              contents: content,
+              parent: undefined,
+            });
+          }
+          for (const [key, contents] of other) {
+            extraFiles.push({
+              path:
+                getPropPathPortion(propPath, rootSchema, rootValue) + '/' + key,
+              contents,
+              parent: undefined,
+            });
+          }
+          const allowedDirectories = new Set(schema.directories);
+          for (const [directory, contents] of external) {
+            if (!allowedDirectories.has(directory)) {
+              throw new Error(
+                `Invalid directory ${directory} in content field serialization`
+              );
+            }
+            for (const [filename, fileContents] of contents) {
+              extraFiles.push({
+                path: filename,
+                contents: fileContents,
+                parent: directory,
+              });
+            }
+          }
+          return forYaml;
+        }
+        return schema.serialize(value).value;
+      },
+      object(_schema, value) {
+        return Object.fromEntries(
+          Object.entries(value).filter(([_, val]) => val !== undefined)
+        );
+      },
+      array(_schema, value) {
+        return value.map(val => (val === undefined ? null : val));
+      },
+      child() {
+        return undefined as any;
+      },
+    }),
+    extraFiles,
+  };
+}
+
+function getPropPathPortion(
+  path: ReadonlyPropPath,
+  schema: ComponentSchema,
+  value: unknown
+): string {
+  const end: (string | number)[] = [];
+  for (const portion of path) {
+    if (schema.kind === 'array') {
+      value = (value as any)[portion];
+      if (schema.slugField && schema.element.kind === 'object') {
+        const slug = getSlugFromState(
+          { schema: schema.element.fields, slugField: schema.slugField },
+          value as Record<string, unknown>
+        );
+        end.push(slug);
+      } else {
+        end.push(portion);
+      }
+      schema = schema.element;
+      continue;
+    }
+    end.push(portion);
+    if (schema.kind === 'object') {
+      value = (value as any)[portion];
+      schema = schema.fields[portion];
+      continue;
+    }
+    if (schema.kind === 'conditional') {
+      if (portion === 'discriminant') {
+        schema = schema.discriminant;
+      } else if (portion === 'value') {
+        schema = schema.values[(value as any).discriminant];
+      }
+      value = (value as any)[portion];
+      continue;
+    }
+    throw new Error(`unexpected ${schema.kind}`);
+  }
+  return end.join('/');
+}
diff --git a/packages/keystatic/src/reader.ts b/packages/keystatic/src/reader.ts
index 2e7609f9..8d25339f 100644
--- a/packages/keystatic/src/reader.ts
+++ b/packages/keystatic/src/reader.ts
@@ -8,7 +8,7 @@ import {
   ValueForReadingDeep,
 } from '../DocumentEditor/component-blocks/api';
 import fs from 'fs/promises';
-import path from 'path';
+import nodePath from 'path';
 import {
   FormatInfo,
   getCollectionFormat,
@@ -20,14 +20,14 @@ import {
   getSingletonPath,
   getSlugGlobForCollection,
 } from '../app/path-utils';
-import { validateComponentBlockProps } from '../validate-component-block-props';
-import {
-  getRequiredFiles,
-  loadDataFile,
-  parseSerializedFormField,
-} from '../app/required-files';
+import { parseProps } from '../parse-props';
+import { loadDataFile } from '../app/required-files';
 import { getValueAtPropPath } from '../DocumentEditor/component-blocks/props-value';
 import { Dirent } from 'fs';
+import {
+  ReadonlyPropPath,
+  formatFormDataError,
+} from '../DocumentEditor/component-blocks/utils';
 
 type EntryReaderOpts = { resolveLinkedFiles?: boolean };
 
@@ -64,7 +64,12 @@ type CollectionEntryWithResolvedLinkedFiles<
   SlugField extends string
 > = {
   [Key in keyof Schema]: SlugField extends Key
-    ? Schema[Key] extends SlugFormField<any, infer SlugSerializedValue>
+    ? Schema[Key] extends SlugFormField<
+        any,
+        any,
+        any,
+        infer SlugSerializedValue
+      >
       ? SlugSerializedValue
       : ValueForReadingDeep<Schema[Key]>
     : ValueForReadingDeep<Schema[Key]>;
@@ -75,7 +80,12 @@ type CollectionEntry<
   SlugField extends string
 > = {
   [Key in keyof Schema]: SlugField extends Key
-    ? Schema[Key] extends SlugFormField<any, infer SlugSerializedValue>
+    ? Schema[Key] extends SlugFormField<
+        any,
+        any,
+        any,
+        infer SlugSerializedValue
+      >
       ? SlugSerializedValue
       : ValueForReading<Schema[Key]>
     : ValueForReading<Schema[Key]>;
@@ -98,7 +108,12 @@ type CollectionReader<
   ) => Promise<
     | {
         [Key in keyof Schema]: SlugField extends Key
-          ? Schema[Key] extends SlugFormField<any, infer SlugSerializedValue>
+          ? Schema[Key] extends SlugFormField<
+              any,
+              any,
+              any,
+              infer SlugSerializedValue
+            >
             ? SlugSerializedValue
             : ValueForReadingWithMode<
                 Schema[Key],
@@ -118,7 +133,12 @@ type CollectionReader<
       slug: string;
       entry: {
         [Key in keyof Schema]: SlugField extends Key
-          ? Schema[Key] extends SlugFormField<any, infer SlugSerializedValue>
+          ? Schema[Key] extends SlugFormField<
+              any,
+              any,
+              any,
+              infer SlugSerializedValue
+            >
             ? SlugSerializedValue
             : ValueForReadingWithMode<
                 Schema[Key],
@@ -151,7 +171,7 @@ async function getAllEntries(
     await Promise.all(
       (
         await fs
-          .readdir(path.join(root, prefix), { withFileTypes: true })
+          .readdir(nodePath.join(root, prefix), { withFileTypes: true })
           .catch(err => {
             if ((err as any).code === 'ENOENT') {
               return [];
@@ -189,7 +209,7 @@ function collectionReader(
       glob === '*'
         ? (
             await fs
-              .readdir(path.join(repoPath, collectionPath), {
+              .readdir(nodePath.join(repoPath, collectionPath), {
                 withFileTypes: true,
               })
               .catch(err => {
@@ -199,7 +219,7 @@ function collectionReader(
                 throw err;
               })
           ).map(x => ({ entry: x, name: x.name }))
-        : await getAllEntries(path.join(repoPath, collectionPath), '');
+        : await getAllEntries(nodePath.join(repoPath, collectionPath), '');
 
     return (
       await Promise.all(
@@ -208,7 +228,7 @@ function collectionReader(
             if (!x.entry.isDirectory()) return [];
             try {
               await fs.stat(
-                path.join(
+                nodePath.join(
                   repoPath,
                   getEntryDataFilepath(
                     `${collectionPath}/${x.name}`,
@@ -242,7 +262,8 @@ function collectionReader(
         glob,
       },
       repoPath,
-      args[0]
+      args[0],
+      `"${slug}" in collection "${collection}"`
     );
 
   return {
@@ -266,7 +287,7 @@ function collectionReader(
 }
 
 async function readItem(
-  schema: ComponentSchema,
+  rootSchema: ComponentSchema,
   formatInfo: FormatInfo,
   itemDir: string,
   slugField:
@@ -277,12 +298,13 @@ async function readItem(
       }
     | undefined,
   repoPath: string,
-  opts: EntryReaderOpts | undefined
+  opts: EntryReaderOpts | undefined,
+  debugReference: string
 ) {
   let dataFile: Uint8Array;
   try {
     dataFile = await fs.readFile(
-      path.resolve(repoPath, getEntryDataFilepath(itemDir, formatInfo))
+      nodePath.resolve(repoPath, getEntryDataFilepath(itemDir, formatInfo))
     );
   } catch (err) {
     if ((err as any).code === 'ENOENT') {
@@ -291,82 +313,72 @@ async function readItem(
     throw err;
   }
   const { loaded, extraFakeFile } = loadDataFile(dataFile, formatInfo);
-  const validated = validateComponentBlockProps(
-    schema,
-    loaded,
-    [],
-    slugField === undefined
-      ? undefined
-      : {
-          field: slugField.field,
-          slug: slugField.slug,
-          mode: 'read',
-          slugs: new Set(),
-          glob: slugField.glob,
+
+  const contentFieldPathsToEagerlyResolve: ReadonlyPropPath[] | undefined =
+    opts?.resolveLinkedFiles ? [] : undefined;
+  let validated: any;
+  try {
+    validated = parseProps(
+      rootSchema,
+      loaded,
+      [],
+      [],
+      (schema, value, path, pathWithArrayFieldSlugs) => {
+        if (schema.formKind === 'asset') {
+          return schema.reader.parse(value);
         }
-  );
-  const requiredFiles = getRequiredFiles(validated, schema, slugField?.slug);
-  await Promise.all(
-    requiredFiles.map(async file => {
-      const parentValue = getValueAtPropPath(
-        validated,
-        file.path.slice(0, -1)
-      ) as any;
-      const keyOnParent = file.path[file.path.length - 1];
-      const originalValue = parentValue[keyOnParent];
-      if (file.schema.serializeToFile.reader.requiresContentInReader) {
-        const loadData = async () => {
-          const loadedFiles = new Map<string, Uint8Array>();
-          if (file.file) {
-            const filepath = `${
-              file.file.parent
-                ? `${file.file.parent}${slugField ? slugField.slug : ''}`
-                : itemDir
-            }/${file.file.filename}`;
-            if (file.file.filename === extraFakeFile?.path) {
-              loadedFiles.set(filepath, extraFakeFile.contents);
+        if (schema.formKind === 'content') {
+          contentFieldPathsToEagerlyResolve?.push(path);
+          return async () => {
+            let content: undefined | Uint8Array;
+            const filename =
+              pathWithArrayFieldSlugs.join('/') + schema.contentExtension;
+            if (filename === extraFakeFile?.path) {
+              content = extraFakeFile.contents;
             } else {
-              const contents = await fs
-                .readFile(path.resolve(repoPath, filepath))
+              content = await fs
+                .readFile(nodePath.resolve(repoPath, `${itemDir}/${filename}`))
                 .catch(x => {
                   if ((x as any).code === 'ENOENT') return undefined;
                   throw x;
                 });
-              if (contents) {
-                loadedFiles.set(filepath, contents);
-              }
             }
+
+            return schema.reader.parse(value, { content });
+          };
+        }
+        if (path.length === 1 && path[0] === slugField?.field) {
+          if (schema.formKind !== 'slug') {
+            throw new Error(
+              `Slug field ${slugField.field} is not a slug field`
+            );
           }
-          return parseSerializedFormField(
-            originalValue,
-            file,
-            loadedFiles,
-            'read',
-            itemDir,
-            slugField?.slug,
-            validated,
-            schema
-          );
-        };
-        if (opts?.resolveLinkedFiles) {
-          parentValue[keyOnParent] = await loadData();
-        } else {
-          parentValue[keyOnParent] = loadData;
+          return schema.reader.parseWithSlug(value, {
+            slug: slugField.slug,
+            glob: slugField.glob,
+          });
         }
-      } else {
-        parentValue[keyOnParent] = parseSerializedFormField(
-          originalValue,
-          file,
-          new Map(),
-          'read',
-          itemDir,
-          slugField?.slug,
-          validated,
-          schema
-        );
+        return schema.reader.parse(value);
       }
-    })
-  );
+    );
+
+    if (contentFieldPathsToEagerlyResolve?.length) {
+      await Promise.all(
+        contentFieldPathsToEagerlyResolve.map(async path => {
+          const parentValue = getValueAtPropPath(
+            validated,
+            path.slice(0, -1)
+          ) as any;
+          const keyOnParent = path[path.length - 1];
+          const originalValue = parentValue[keyOnParent];
+          parentValue[keyOnParent] = await originalValue();
+        })
+      );
+    }
+  } catch (err) {
+    const formatted = formatFormDataError(err);
+    throw new Error(`Invalid data for ${debugReference}:\n${formatted}`);
+  }
 
   return validated;
 }
@@ -381,7 +393,15 @@ function singletonReader(
   const schema = fields.object(config.singletons![singleton].schema);
   return {
     read: (...args) =>
-      readItem(schema, formatInfo, singletonPath, undefined, repoPath, args[0]),
+      readItem(
+        schema,
+        formatInfo,
+        singletonPath,
+        undefined,
+        repoPath,
+        args[0],
+        `singleton "${singleton}"`
+      ),
   };
 }
 
diff --git a/packages/keystatic/tsconfig.json b/packages/keystatic/tsconfig.json
new file mode 100644
index 00000000..283f079c
--- /dev/null
+++ b/packages/keystatic/tsconfig.json
@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "declaration": true,
+    "downlevelIteration": true,
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "noEmit": true,
+    "noUnusedLocals": false,
+    "resolveJsonModule": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "target": "esnext",
+    "types": ["node", "jest"]
+  },
+  "include": ["**/*"],
+  "exclude": ["**/dist/**/*", "**/node_modules/**/*"]
+}
diff --git a/packages/keystatic/utils.tsx b/packages/keystatic/utils.tsx
index d8d8e023..01dd92b1 100644
--- a/packages/keystatic/utils.tsx
+++ b/packages/keystatic/utils.tsx
@@ -1,5 +1,5 @@
 import { gql } from '@ts-gql/tag/no-transform';
-import { assert, assertNever } from 'emery';
+import { assert } from 'emery';
 import { useContext, useState } from 'react';
 
 import { ComponentSchema, fields } from './DocumentEditor/component-blocks/api';
@@ -24,12 +24,10 @@ import {
   updateTreeWithChanges,
 } from './app/trees';
 import { Config } from './src';
-import { isSlugFormField } from './app/utils';
 import { getDirectoriesForTreeKey, getTreeKey } from './app/tree-key';
-import { getPropPathPortion } from './app/required-files';
 import { AppSlugContext } from './app/onboarding/install-app';
 import { createUrqlClient } from './app/provider';
-import { asyncTransformProps } from './DocumentEditor/component-blocks/props-value';
+import { serializeProps } from './serialize-props';
 
 const textEncoder = new TextEncoder();
 
@@ -95,10 +93,11 @@ export function useUpsertItem(args: {
           return false;
         }
         setState({ kind: 'loading' });
-        let { value: stateWithExtraFilesRemoved, extraFiles } = await toFiles(
+        let { value: stateWithExtraFilesRemoved, extraFiles } = serializeProps(
           args.state,
           fields.object(args.schema),
-          args.slug
+          args.slug?.field,
+          args.slug?.value
         );
         const dataFormat = args.format.data;
         let dataContent = textEncoder.encode(
@@ -108,7 +107,7 @@ export function useUpsertItem(args: {
         );
 
         if (args.format.contentField) {
-          const filename = `${args.format.contentField.key}${args.format.contentField.config.serializeToFile.primaryExtension}`;
+          const filename = `${args.format.contentField.key}${args.format.contentField.config.contentExtension}`;
           let contents: undefined | Uint8Array;
           extraFiles = extraFiles.filter(x => {
             if (x.path !== filename) return true;
@@ -410,109 +409,6 @@ export function useDeleteItem(args: {
   ] as const;
 }
 
-export async function toFiles(
-  rootValue: unknown,
-  rootSchema: ComponentSchema,
-  slug: { field: string; value: string } | undefined
-) {
-  const extraFiles: {
-    path: string;
-    parent: string | undefined;
-    contents: Uint8Array;
-  }[] = [];
-  return {
-    value: await asyncTransformProps(rootSchema, rootValue, {
-      async form(schema, value, propPath) {
-        if (propPath.length === 1 && slug?.field === propPath[0]) {
-          if (!isSlugFormField(schema)) {
-            throw new Error('slugField is a not a slug field');
-          }
-          return schema.slug.serialize(value).value;
-        }
-        if ('serializeToFile' in schema && schema.serializeToFile) {
-          if (schema.serializeToFile.kind === 'asset') {
-            const suggestedFilenamePrefix = getPropPathPortion(
-              propPath,
-              rootSchema,
-              rootValue
-            );
-
-            const { content, value: forYaml } =
-              schema.serializeToFile.serialize(
-                value,
-                suggestedFilenamePrefix,
-                slug?.value
-              );
-            if (content) {
-              const path = schema.serializeToFile.filename(
-                forYaml,
-                suggestedFilenamePrefix,
-                slug?.value
-              );
-              if (path) {
-                extraFiles.push({
-                  path,
-                  contents: content,
-                  parent: schema.serializeToFile.directory,
-                });
-              }
-            }
-            return forYaml;
-          }
-          if (schema.serializeToFile.kind === 'multi') {
-            const {
-              other,
-              external,
-              primary,
-              value: forYaml,
-            } = await schema.serializeToFile.serialize(value, slug?.value);
-            if (primary) {
-              extraFiles.push({
-                path:
-                  getPropPathPortion(propPath, rootSchema, rootValue) +
-                  schema.serializeToFile.primaryExtension,
-                contents: primary,
-                parent: undefined,
-              });
-            }
-            for (const [key, contents] of other) {
-              extraFiles.push({
-                path:
-                  getPropPathPortion(propPath, rootSchema, rootValue) +
-                  '/' +
-                  key,
-                contents,
-                parent: undefined,
-              });
-            }
-            const allowedDirectories = new Set(
-              schema.serializeToFile.directories
-            );
-            for (const [directory, contents] of external) {
-              if (!allowedDirectories.has(directory)) {
-                throw new Error(
-                  `Invalid directory ${directory} in multi-file serialization`
-                );
-              }
-              for (const [filename, fileContents] of contents) {
-                extraFiles.push({
-                  path: filename,
-                  contents: fileContents,
-                  parent: directory,
-                });
-              }
-            }
-            return forYaml;
-          }
-          assertNever(schema.serializeToFile);
-        }
-        return value;
-      },
-    }),
-    extraFiles,
-  };
-}
-
 const FetchRef = gql`
   query FetchRef($owner: String!, $name: String!, $ref: String!) {
     repository(owner: $owner, name: $name) {
diff --git a/packages/keystatic/validate-component-block-props.ts b/packages/keystatic/validate-component-block-props.ts
deleted file mode 100644
index c7486e35..00000000
--- a/packages/keystatic/validate-component-block-props.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { assertNever } from 'emery';
-import { getSlugFromState, isSlugFormField } from './app/utils';
-import { ComponentSchema } from './DocumentEditor/component-blocks/api';
-import { SlugFieldInfo } from './DocumentEditor/component-blocks/fields/text/ui';
-import { getInitialPropsValue } from './DocumentEditor/component-blocks/initial-values';
-import { ReadonlyPropPath } from './DocumentEditor/component-blocks/utils';
-
-export class PropValidationError extends Error {
-  path: ReadonlyPropPath;
-  constructor(message: string, path: ReadonlyPropPath) {
-    super(message);
-    this.path = path;
-  }
-}
-
-export function validateComponentBlockProps(
-  schema: ComponentSchema,
-  value: unknown,
-  path: ReadonlyPropPath,
-  slugField:
-    | (SlugFieldInfo &
-        ({ mode: 'state' } | { mode: 'parse' | 'read'; slug: string }))
-    | undefined
-): any {
-  if (schema.kind === 'form') {
-    if (slugField?.field === path[path.length - 1]) {
-      if (!isSlugFormField(schema)) {
-        throw new Error('slugField is not a slug field');
-      }
-      const slugInfo = {
-        slugs: slugField.slugs,
-        glob: slugField.glob,
-      };
-      if (slugField.mode === 'state') {
-        if (!schema.validate(value, slugInfo)) {
-          throw new PropValidationError('Invalid form prop value', path);
-        }
-        return value;
-      }
-      const parsed = schema.slug.parse({
-        slug: slugField.slug,
-        value,
-      });
-      if (!schema.validate(parsed, slugInfo)) {
-        throw new PropValidationError('Invalid form prop value', path);
-      }
-      if (slugField.mode === 'read') {
-        return schema.slug.serialize(parsed).value;
-      }
-      return parsed;
-    }
-    if ('serializeToFile' in schema && schema.serializeToFile) {
-      return value;
-    }
-    if (schema.validate(value, undefined)) {
-      return value;
-    }
-    throw new PropValidationError('Invalid form prop value', path);
-  }
-  if (schema.kind === 'child') {
-    return null;
-  }
-  if (schema.kind === 'conditional') {
-    if (typeof value !== 'object' || value === null) {
-      throw new PropValidationError(
-        'Conditional value must be an object',
-        path
-      );
-    }
-    for (const key of Object.keys(value)) {
-      if (key !== 'discriminant' && key !== 'value') {
-        throw new PropValidationError(
-          `Conditional value only allows keys named "discriminant" and "value", not "${key}"`,
-          path
-        );
-      }
-    }
-    const discriminant = (value as any).discriminant;
-    const val = (value as any).value;
-    // for some reason mongo or mongoose or something is saving undefined as null
-    // so we're doing this so that we avoid setting undefined on objects
-    const obj: any = {};
-    const discriminantVal = validateComponentBlockProps(
-      schema.discriminant,
-      discriminant,
-      path.concat('discriminant'),
-      undefined
-    );
-    if (discriminantVal !== undefined) {
-      obj.discriminant = discriminantVal;
-    }
-    const conditionalFieldValue = validateComponentBlockProps(
-      schema.values[discriminant],
-      val,
-      path.concat('value'),
-      undefined
-    );
-    if (conditionalFieldValue !== undefined) {
-      obj.value = conditionalFieldValue;
-    }
-    return obj;
-  }
-
-  if (schema.kind === 'object') {
-    if (typeof value !== 'object' || value === null) {
-      throw new PropValidationError('Object value must be an object', path);
-    }
-    const allowedKeysSet = new Set(Object.keys(schema.fields));
-    for (const key of Object.keys(value)) {
-      if (!allowedKeysSet.has(key)) {
-        throw new PropValidationError(
-          `Key on object value "${key}" is not allowed`,
-          path
-        );
-      }
-    }
-    let val: Record<string, any> = {};
-    for (const key of Object.keys(schema.fields)) {
-      let individualVal = (value as any)[key];
-      if (individualVal === undefined) {
-        individualVal = getInitialPropsValue(schema.fields[key]);
-      }
-      const propVal = validateComponentBlockProps(
-        schema.fields[key],
-        individualVal,
-        path.concat(key),
-        slugField?.field === key ? slugField : undefined
-      );
-
-      // for some reason mongo or mongoose or something is saving undefined as null
-      // so we're doing this so that we avoid setting undefined on objects
-      if (propVal !== undefined) {
-        val[key] = propVal;
-      }
-    }
-    return val;
-  }
-  if (schema.kind === 'array') {
-    if (!Array.isArray(value)) {
-      throw new PropValidationError('Array field value must be an array', path);
-    }
-    let slugInfo: undefined | { slugField: string; slugs: string[] };
-    if (schema.slugField !== undefined && schema.element.kind === 'object') {
-      const innerSchema = schema.element.fields;
-      const { slugField } = schema;
-      slugInfo = {
-        slugField,
-        slugs: value.map(val =>
-          getSlugFromState({ schema: innerSchema, slugField }, val)
-        ),
-      };
-    }
-    return value.map((innerVal, i) => {
-      return validateComponentBlockProps(
-        schema.element,
-        innerVal,
-        path.concat(i),
-        slugInfo === undefined
-          ? undefined
-          : {
-              field: slugInfo.slugField,
-              slugs: new Set(slugInfo.slugs.filter((_, idx) => i !== idx)),
-              glob: '*',
-              mode: 'state',
-            }
-      );
-    });
-  }
-  assertNever(schema);
-}


Test patch:
diff --git a/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts b/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts
index f86241e3..70da33ff 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts
@@ -63,7 +63,7 @@ test('does not allow a circular object within a value for a non-default discrimi
 test("does allow a circular conditional as long as it's not the default", () => {
   type Field = ConditionalField<
     typeof discriminant,
-    { true: Field; false: FormField<null> }
+    { true: Field; false: FormField<null, any, any> }
   >;
   const conditional: Field = fields.conditional(discriminant, {
     get true() {
@@ -77,7 +77,7 @@ test("does allow a circular conditional as long as it's not the default", () =>
 test("does not allow a circular conditional if it's the default", () => {
   type Field = ConditionalField<
     typeof discriminant,
-    { false: Field; true: FormField<null> }
+    { false: Field; true: FormField<null, any, any> }
   >;
   const conditional: Field = fields.conditional(discriminant, {
     get false() {
diff --git a/packages/keystatic/markdoc/find-children.test.ts b/packages/keystatic/markdoc/find-children.test.ts
index 6b828d8b..1d0d594e 100644
--- a/packages/keystatic/markdoc/find-children.test.ts
+++ b/packages/keystatic/markdoc/find-children.test.ts
@@ -137,18 +137,27 @@ test('array', () => {
             },
             "heading": {
               "Input": [Function],
-              "defaultValue": "",
+              "defaultValue": [Function],
+              "formKind": "slug",
               "kind": "form",
-              "slug": {
+              "parse": [Function],
+              "reader": {
                 "parse": [Function],
-                "serialize": [Function],
+                "parseWithSlug": [Function],
               },
+              "serialize": [Function],
+              "serializeWithSlug": [Function],
               "validate": [Function],
             },
             "href": {
               "Input": [Function],
-              "defaultValue": "",
+              "defaultValue": [Function],
               "kind": "form",
+              "parse": [Function],
+              "reader": {
+                "parse": [Function],
+              },
+              "serialize": [Function],
               "validate": [Function],
             },
           },
diff --git a/packages/keystatic/markdoc/test-utils.ts b/packages/keystatic/markdoc/test-utils.ts
index b4fa3b48..ad627d10 100644
--- a/packages/keystatic/markdoc/test-utils.ts
+++ b/packages/keystatic/markdoc/test-utils.ts
@@ -12,14 +12,10 @@ export function toMarkdoc(
   componentBlocks: Record<string, ComponentBlock>
 ) {
   const { children } = makeEditor(node);
-  return Markdoc.format(
-    Markdoc.parse(
-      Markdoc.format(
-        toMarkdocDocument(children, {
-          componentBlocks,
-          documentFeatures: defaultDocumentFeatures,
-        })
-      )
-    )
-  );
+  const root = toMarkdocDocument(children, {
+    componentBlocks,
+    documentFeatures: defaultDocumentFeatures,
+    slug: undefined,
+  }).node;
+  return Markdoc.format(Markdoc.parse(Markdoc.format(root)));
 }
diff --git a/packages/keystatic/reader.test.tsx b/packages/keystatic/reader.test.tsx
index 88b9bc06..86367dc3 100644
--- a/packages/keystatic/reader.test.tsx
+++ b/packages/keystatic/reader.test.tsx
@@ -51,6 +51,18 @@ const localConfig = config({
         ),
       },
     }),
+    other: collection({
+      label: 'Other',
+      path: 'other/*',
+      slugField: 'slug',
+      schema: {
+        slug: fields.text({ label: 'Slug' }),
+        date: fields.date({ label: 'Date' }),
+        integer: fields.integer({ label: 'Integer' }),
+        text: fields.text({ label: 'Text' }),
+        anotherText: fields.text({ label: 'Another Text' }),
+      },
+    }),
   },
 });
 
@@ -274,3 +286,14 @@ test('read all deep', async () => {
     ]
   `);
 });
+
+test('errors', async () => {
+  const reader = createReader(path.join(__dirname, 'test-data'), localConfig);
+  await expect(reader.collections.other.read('invalid')).rejects
+    .toMatchInlineSnapshot(`
+    [Error: Invalid data for "invalid" in collection "other":
+    date: Date is not a valid date
+    integer: Must be a number
+    text: Must be a string]
+  `);
+});
diff --git a/packages/keystatic/test-data/other/invalid.yaml b/packages/keystatic/test-data/other/invalid.yaml
new file mode 100644
index 00000000..4ffb9411
--- /dev/null
+++ b/packages/keystatic/test-data/other/invalid.yaml
@@ -0,0 +1,4 @@
+date: bad
+integer: something
+text: 1
+anotherText: valid