diff --git a/app/routes/posts.admin.new.tsx b/app/routes/posts.admin.new.tsx
index 7d923e1..934fcc6 100644
--- a/app/routes/posts.admin.new.tsx
+++ b/app/routes/posts.admin.new.tsx
@@ -1,90 +1,107 @@
 import type { ActionArgs } from "@remix-run/node";
-import { json, redirect } from "@remix-run/node";
-import { Form, useActionData, useNavigation } from "@remix-run/react";
-import invariant from "tiny-invariant";
+import { redirect } from "@remix-run/node";
 import { createPost } from "~/models/post.server";
 
+import {
+  ValidatedForm,
+  useField,
+  useIsSubmitting,
+  validationError,
+} from "remix-validated-form";
+import { withZod } from "@remix-validated-form/with-zod";
+import { z } from "zod";
+
 const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg`;
 
+export const validator = withZod(
+  z.object({
+    title: z.string().min(1, { message: "Title is required" }),
+    slug: z.string().min(1, { message: "Slug name is required" }),
+    markdown: z.string().min(1, { message: "Markdown content is required" }),
+  })
+);
+
 export const action = async ({ request }: ActionArgs) => {
-  const formData = await request.formData();
+  const result = await validator.validate(await request.formData());
 
-  const title = formData.get("title");
-  const slug = formData.get("slug");
-  const markdown = formData.get("markdown");
+  if (result.error) {
+    return validationError(result.error);
+  }
 
   await new Promise((res) => setTimeout(res, 1000));
 
-  const errors = {
-    title: title ? null : "Title is required",
-    slug: slug ? null : "Slug is required",
-    markdown: markdown ? null : "Markdown is required",
-  };
+  await createPost(result.data);
 
-  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
-  if (hasErrors) {
-    return json(errors);
-  }
+  return redirect("/posts/admin");
+};
+
+export default function NewPost() {
+  return (
+    <ValidatedForm method="post" validator={validator}>
+      <Input name="title" label="Post Title" />
+      <Input name="slug" label="Post Slug" />
 
-  invariant(typeof title === "string", "title must be a string");
-  invariant(typeof slug === "string", "slug must be a string");
-  invariant(typeof markdown === "string", "markdown must be a string");
+      <Textarea name="markdown" label="Markdown" />
 
-  await createPost({ title, slug, markdown });
+      <p className="text-right">
+        <SubmitButton />
+      </p>
+    </ValidatedForm>
+  );
+}
 
-  return redirect("/posts/admin");
+type InputProps = {
+  name: string;
+  label: string;
 };
 
-export default function NewPost() {
-  const errors = useActionData<typeof action>();
+export const Input = ({ name, label }: InputProps) => {
+  const { error, getInputProps } = useField(name);
+  return (
+    <p>
+      <label htmlFor={name}>
+        {`${label}: `}
+        {error && <em className="text-red-600">{error}</em>}
+        <input {...getInputProps({ id: name, className: inputClassName })} />
+      </label>
+    </p>
+  );
+};
 
-  const navigation = useNavigation();
-  const isCreating = Boolean(navigation.state === "submitting");
+type TextareaProps = {
+  name: string;
+  label: string;
+};
 
+export const Textarea = ({ name, label }: TextareaProps) => {
+  const { error, getInputProps } = useField(name);
   return (
-    <Form method="post">
-      <p>
-        <label>
-          Post Title:{" "}
-          {errors?.title ? (
-            <em className="text-red-600">{errors.title}</em>
-          ) : null}
-          <input type="text" name="title" className={inputClassName} />
-        </label>
-      </p>
-      <p>
-        <label>
-          Post Slug:{" "}
-          {errors?.slug ? (
-            <em className="text-red-600">{errors.slug}</em>
-          ) : null}
-          <input type="text" name="slug" className={inputClassName} />
-        </label>
-      </p>
-      <p>
-        <label htmlFor="markdown">
-          Markdown:
-          {errors?.markdown ? (
-            <em className="text-red-600">{errors.markdown}</em>
-          ) : null}
-        </label>
-        <br />
+    <p>
+      <label htmlFor={name}>
+        {`${label}: `}
+        {error && <em className="text-red-600">{error}</em>}
         <textarea
-          id="markdown"
-          rows={20}
-          name="markdown"
-          className={`${inputClassName} font-mono`}
+          {...getInputProps({
+            id: name,
+            rows: 20,
+            className: `${inputClassName} font-mono`,
+          })}
         />
-      </p>
-      <p className="text-right">
-        <button
-          type="submit"
-          className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
-          disabled={isCreating}
-        >
-          {isCreating ? "Creating..." : "Create Post"}
-        </button>
-      </p>
-    </Form>
+      </label>
+    </p>
   );
-}
+};
+
+export const SubmitButton = () => {
+  const isSubmitting = useIsSubmitting();
+
+  return (
+    <button
+      type="submit"
+      className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
+      disabled={isSubmitting}
+    >
+      {isSubmitting ? "Creating..." : "Create Post"}
+    </button>
+  );
+};
diff --git a/package-lock.json b/package-lock.json
index 2d381b2..147001d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,12 +11,15 @@
         "@remix-run/node": "^1.17.0",
         "@remix-run/react": "^1.17.0",
         "@remix-run/serve": "^1.17.0",
+        "@remix-validated-form/with-zod": "^2.0.6",
         "bcryptjs": "^2.4.3",
         "isbot": "^3.6.8",
         "marked": "^5.1.0",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
-        "tiny-invariant": "^1.3.1"
+        "remix-validated-form": "^5.0.2",
+        "tiny-invariant": "^1.3.1",
+        "zod": "^3.21.4"
       },
       "devDependencies": {
         "@faker-js/faker": "^7.6.0",
@@ -3176,6 +3179,15 @@
         "web-streams-polyfill": "^3.1.1"
       }
     },
+    "node_modules/@remix-validated-form/with-zod": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@remix-validated-form/with-zod/-/with-zod-2.0.6.tgz",
+      "integrity": "sha512-i8H0PPFSSKIMGPVO/8cUMO1QoGa2bBQZb6RH3DoXGVE1heu52d1vwrFVsYYQB8Vc8lp5BGQk1kbxZuN9RzH1OA==",
+      "peerDependencies": {
+        "remix-validated-form": "^4.x || ^5.x",
+        "zod": "^3.11.x"
+      }
+    },
     "node_modules/@rollup/pluginutils": {
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
@@ -8922,6 +8934,15 @@
         "node": ">= 4"
       }
     },
+    "node_modules/immer": {
+      "version": "9.0.21",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+      "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
     "node_modules/import-fresh": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -10088,6 +10109,11 @@
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
       "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
     },
+    "node_modules/lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
+    },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -13579,6 +13605,29 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/remeda": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/remeda/-/remeda-1.19.0.tgz",
+      "integrity": "sha512-iwZohiXDhC1K+adRI6OB+tYxOfXyX7DaPXQDZrR5s1k7umrkG3Yd2+QDfSrYFlC7oc0IqeUns6RqSjNkERXeLw=="
+    },
+    "node_modules/remix-validated-form": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/remix-validated-form/-/remix-validated-form-5.0.2.tgz",
+      "integrity": "sha512-jM3uuvCP6AO9G117MAEGgTb3x/aOy5qVrOM85XdDhWnpJFt7WC6u1gBQ/tSd1UBCTxDSFwU6TZPCJex73D9rjQ==",
+      "dependencies": {
+        "immer": "^9.0.12",
+        "lodash.get": "^4.4.2",
+        "nanoid": "3.3.6",
+        "remeda": "^1.2.0",
+        "tiny-invariant": "^1.2.0",
+        "zustand": "^4.3.0"
+      },
+      "peerDependencies": {
+        "@remix-run/react": ">= 1.15.0",
+        "@remix-run/server-runtime": "1.x",
+        "react": "^17.0.2 || ^18.0.0"
+      }
+    },
     "node_modules/request-progress": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz",
@@ -15435,6 +15484,14 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/use-sync-external-store": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+      "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
     "node_modules/util": {
       "version": "0.12.5",
       "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -16219,6 +16276,37 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/zod": {
+      "version": "3.21.4",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
+      "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zustand": {
+      "version": "4.3.8",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.8.tgz",
+      "integrity": "sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==",
+      "dependencies": {
+        "use-sync-external-store": "1.2.0"
+      },
+      "engines": {
+        "node": ">=12.7.0"
+      },
+      "peerDependencies": {
+        "immer": ">=9.0",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/zwitch": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
@@ -18352,6 +18440,11 @@
         "web-streams-polyfill": "^3.1.1"
       }
     },
+    "@remix-validated-form/with-zod": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@remix-validated-form/with-zod/-/with-zod-2.0.6.tgz",
+      "integrity": "sha512-i8H0PPFSSKIMGPVO/8cUMO1QoGa2bBQZb6RH3DoXGVE1heu52d1vwrFVsYYQB8Vc8lp5BGQk1kbxZuN9RzH1OA=="
+    },
     "@rollup/pluginutils": {
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
@@ -22658,6 +22751,11 @@
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
       "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="
     },
+    "immer": {
+      "version": "9.0.21",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+      "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="
+    },
     "import-fresh": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -23497,6 +23595,11 @@
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
       "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
     },
+    "lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
+    },
     "lodash.merge": {
       "version": "4.6.2",
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -25876,6 +25979,24 @@
         "unified": "^10.0.0"
       }
     },
+    "remeda": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/remeda/-/remeda-1.19.0.tgz",
+      "integrity": "sha512-iwZohiXDhC1K+adRI6OB+tYxOfXyX7DaPXQDZrR5s1k7umrkG3Yd2+QDfSrYFlC7oc0IqeUns6RqSjNkERXeLw=="
+    },
+    "remix-validated-form": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/remix-validated-form/-/remix-validated-form-5.0.2.tgz",
+      "integrity": "sha512-jM3uuvCP6AO9G117MAEGgTb3x/aOy5qVrOM85XdDhWnpJFt7WC6u1gBQ/tSd1UBCTxDSFwU6TZPCJex73D9rjQ==",
+      "requires": {
+        "immer": "^9.0.12",
+        "lodash.get": "^4.4.2",
+        "nanoid": "3.3.6",
+        "remeda": "^1.2.0",
+        "tiny-invariant": "^1.2.0",
+        "zustand": "^4.3.0"
+      }
+    },
     "request-progress": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz",
@@ -27285,6 +27406,11 @@
         "punycode": "^2.1.0"
       }
     },
+    "use-sync-external-store": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+      "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="
+    },
     "util": {
       "version": "0.12.5",
       "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -27813,6 +27939,19 @@
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
     },
+    "zod": {
+      "version": "3.21.4",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
+      "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw=="
+    },
+    "zustand": {
+      "version": "4.3.8",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.8.tgz",
+      "integrity": "sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==",
+      "requires": {
+        "use-sync-external-store": "1.2.0"
+      }
+    },
     "zwitch": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 8a9a566..f8a6335 100644
--- a/package.json
+++ b/package.json
@@ -29,12 +29,15 @@
     "@remix-run/node": "^1.17.0",
     "@remix-run/react": "^1.17.0",
     "@remix-run/serve": "^1.17.0",
+    "@remix-validated-form/with-zod": "^2.0.6",
     "bcryptjs": "^2.4.3",
     "isbot": "^3.6.8",
     "marked": "^5.1.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "tiny-invariant": "^1.3.1"
+    "remix-validated-form": "^5.0.2",
+    "tiny-invariant": "^1.3.1",
+    "zod": "^3.21.4"
   },
   "devDependencies": {
     "@faker-js/faker": "^7.6.0",