Unified Create and Edit Form With NextJS

Published: (December 7, 2025 at 06:32 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Introduction

In this guide we’ll create a unified form that can handle both adding new data and updating existing data. Using a single form reduces code duplication, makes maintenance easier, ensures consistency in data handling and validation, improves the user experience, and speeds up development.

We’ll walk through how to create a form for adding and editing branches.

Branch Instance Type

export type BranchInstance = {
  id: string;
  branch_name: string;
  location: string;
  img: string;
  google_coordinate: string;
};

Validation Schema (Zod)

import { z } from "zod";

// validator
export const BranchSchema = z.object({
  branch_name: z.string().min(1, "Branch name is required"),
  location: z.string().min(1, "Location is required"),
  google_coordinate: z.string().min(1, "Google Map embed link is required"),
  img: z.string().min(1, "One Image of the place is required"),
});

// Infer the TypeScript type from the Zod schema
type BranchSchemaType = z.infer<typeof BranchSchema>;

// Errors type
export type zodBranchErrorsType = {
  [K in keyof BranchSchemaType]?: string[];
};

Save Response Types

export type BranchSaveResponse = {
  status: number;
  data: BranchInstance;
  message: string;
  errors?: {
    fieldErrors?: zodBranchErrorsType;
    formErrors?: string[];
  };
};

// Generic reusable version
export type SaveResponse<T> = {
  status: number;
  data: T;
  message: string;
  errors?: {
    fieldErrors?: Record<string, string[]>;
    formErrors?: string[];
  };
};

export type BranchSaveResponse = SaveResponse<BranchInstance>;

Converting FormData to a Plain Object

export function formDataToObject(fd: FormData) {
  // This object will hold the final key‑value pairs
  const out: Record<string, any> = {};

  // This set keeps track of all keys that are arrays (ending with "[]")
  const arrayKeys = new Set<string>();

  // Loop through all key‑value pairs in the FormData
  for (const [rawKey, value] of fd.entries()) {
    // Check if the field name ends with "[]", which means it's an array field
    const isArray = rawKey.endsWith("[]");

    // Remove the "[]" from the key for cleaner object property names
    const key = isArray ? rawKey.slice(0, -2) : rawKey;

    // Remember that this key is meant to represent an array
    if (isArray) arrayKeys.add(key);

    // If this key already exists in the object, we need to merge the new value
    if (key in out) {
      const prev = out[key];
      // If it's already an array, add the new value to it;
      // otherwise, convert the previous single value into an array
      out[key] = Array.isArray(prev) ? [...prev, value] : [prev, value];
    } else {
      out[key] = value;
    }
  }

  // Ensure that all keys marked as arrays are stored as arrays,
  // even if only one value was present in the FormData
  for (const key of arrayKeys) {
    const v = out[key];
    out[key] = Array.isArray(v) ? v : v === undefined ? [] : [v];
  }

  // Return the final, cleaned‑up object
  return out;
}

Reusable Input Component

export function InputField({
  label,
  name,
  defaultValue,
  errors,
  placeholder,
  type = "text", // default is text
}: {
  label: string;
  name: string;
  defaultValue?: string | number | null;
  errors?: string[];
  placeholder?: string;
  type?: "text" | "number" | "email" | "password";
}) {
  return (
    <div className="mb-4">
      <label htmlFor={name} className="block text-sm font-medium text-gray-700">
        {label}
      </label>
      <input
        id={name}
        name={name}
        type={type}
        className={`mt-1 block w-full rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
          errors?.length ? "border-red-500" : "border-gray-300"
        }`}
        defaultValue={defaultValue as string | number}
        placeholder={placeholder}
      />
      {errors?.map((error, index) => (
        <p key={index} className="mt-1 text-sm text-red-600">
          {error}
        </p>
      ))}
    </div>
  );
}

Image Uploader Component

import { useState, useRef, useCallback } from "react";
import toast from "react-hot-toast";

const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
  <svg
    className={className}
    /* SVG content omitted for brevity */
  />
);

const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
  <svg
    className={className}
    /* SVG content omitted for brevity */
  />
);

function ImageUploader({
  label,
  name,
  errors,
  initial,
}: {
  label: string;
  name: string;
  errors: string[];
  initial?: string; // if provided, the image is in update state
}) {
  const [image, setImage] = useState<File | null>(null);
  const [isDragging, setIsDragging] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [imageId, setImageId] = useState<string | null>(null);

  // Used only if `initial` is provided
  const [markedDeletedId, setMarkedDeletedId] = useState<string[]>([]);

  const processFile = useCallback(async (file: File) => {
    if (file && file.type.startsWith("image/")) {
      /** upload to Appwrite */
      toast.loading("Uploading main image", { id: "upload" });
      // ...upload logic here...
    }
  }, []);

  // Component UI (simplified)
  return (
    <div className="mb-4">
      <label className="block text-sm font-medium text-gray-700">{label}</label>
      {/* UI for drag‑and‑drop, preview, delete, etc. */}
      {/* Errors */}
      {errors.map((e, i) => (
        <p key={i} className="mt-1 text-sm text-red-600">
          {e}
        </p>
      ))}
      {/* Hidden inputs to send image ID and deleted IDs to the server */}
      <input type="hidden" name={`${name}_id`} value={imageId ?? ""} />
      {markedDeletedId.map((id) => (
        <input key={id} type="hidden" name={`${name}_deleted_ids[]`} value={id} />
      ))}
    </div>
  );
}

Note: This method of saving files (images, PDFs, DOCX, etc.) is safer than uploading directly to the server. Only the file identifier is sent to the backend, reducing exposure and avoiding file‑size limits.

Back to Blog

Related posts

Read more »