dimah-formv0.2.0

Quickstart

Define a form, mount the handler, and render a fill session in Next.js App Router.

A fill session in Next.js App Router — drafts, showWhen, and snapshot validation.

Install

bash pnpm add @dimah-form/server @dimah-form/react

Define a form

lib/forms/feedback.ts
import { defineForm } from "@dimah-form/server";

export const feedbackForm = defineForm({
  title: "Product Feedback",
  slug: "feedback",
  fields: [
    { id: "fullName", type: "text", label: "Full name", required: true },
    {
      id: "rating",
      type: "select",
      label: "Rating",
      required: true,
      options: [
        { value: "great", label: "Great" },
        { value: "ok", label: "Okay" },
        { value: "poor", label: "Poor" },
      ],
    },
    {
      id: "improvements",
      type: "text",
      label: "What could we improve?",
      required: true,
      showWhen: { field: "rating", equals: "poor" },
    },
  ],
});
lib/forms/index.ts
import { feedbackForm } from "./feedback";

export const forms = {
  feedback: feedbackForm,
};

Create the instance

lib/form.ts
import { dimahForm, memoryAdapter } from "@dimah-form/server";
import { forms } from "@/lib/forms";

export const form = dimahForm({
  database: memoryAdapter(),
  forms,
  basePath: "/api/form",
});

export type Form = typeof form;

memoryAdapter() is process-local. For SQL, see Database.

Mount the handler

app/api/form/[...all]/route.ts
import { toNextJsHandler } from "@dimah-form/server/next";
import { form } from "@/lib/form";

export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(form);

Create the client

lib/form-client.ts
"use client";

import { createFormClient } from "@dimah-form/react";
import type { Form } from "@/lib/form";

export const formClient = createFormClient<Form>({
  basePath: "/api/form",
});

export const { useFormResponse } = formClient;

Build the fill UI

components/questionnaire.tsx
"use client";

import { emptyToNull, fieldLabel, fieldOptions } from "@dimah-form/react";
import type { FormSnapshot } from "@dimah-form/react";
import { useFormResponse } from "@/lib/form-client";
import type { Form } from "@/lib/form";

export function Questionnaire({ form }: { form: FormSnapshot }) {
  const q = useFormResponse<Form["$Infer"]["answers"]["feedback"]>({
    snapshot: form,
    autosave: { debounceMs: 1000 },
  });

  if (q.inactive) return <p>This form is inactive.</p>;
  if (q.status === "submitted") return <p>Submitted.</p>;

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        void q.submit();
      }}
      className="mx-auto max-w-md space-y-4"
    >
      <h1 className="text-xl font-bold">{form.title}</h1>

      {q.visibleFields.map((field) => {
        const binding = q.field(field.id);
        const value = typeof binding.value === "string" ? binding.value : "";

        return (
          <div key={field.id} className="space-y-1">
            <label className="block text-sm font-medium">
              {fieldLabel(field)}
              {binding.required && <span className="ml-1 text-red-500">*</span>}
            </label>
            {field.type === "select" ? (
              <select
                value={value}
                disabled={binding.disabled}
                onChange={(e) => binding.onChange(emptyToNull(e.target.value))}
                className="w-full rounded border p-2"
              >
                <option value="">Select...</option>
                {fieldOptions(field).map((opt) => (
                  <option key={opt.value} value={opt.value}>
                    {opt.label}
                  </option>
                ))}
              </select>
            ) : (
              <input
                value={value}
                disabled={binding.disabled}
                onChange={(e) => binding.onChange(emptyToNull(e.target.value))}
                className="w-full rounded border p-2"
              />
            )}
            {binding.error && (
              <p className="text-xs text-red-500">{binding.error}</p>
            )}
          </div>
        );
      })}

      {q.error && <p className="text-sm text-red-500">{q.error}</p>}

      <div className="flex gap-2">
        <button
          type="button"
          disabled={q.pending !== undefined || q.locked}
          onClick={() => void q.saveDraft()}
          className="rounded border px-4 py-2"
        >
          {q.pending === "save" ? "Saving..." : "Save draft"}
        </button>
        <button
          type="submit"
          disabled={q.pending !== undefined || q.locked}
          className="rounded bg-black px-4 py-2 text-white"
        >
          {q.pending === "submit" ? "Submitting..." : "Submit"}
        </button>
      </div>
    </form>
  );
}

Render on a page

app/page.tsx
import { Questionnaire } from "@/components/questionnaire";
import { form } from "@/lib/form";
import { notFound } from "next/navigation";

export default async function Page() {
  const snapshot = await form.api.getForm({
    query: { formId: "feedback" },
  });

  if (!snapshot) notFound();

  return <Questionnaire form={snapshot} />;
}

Next Steps

On this page