AdminKitDocs
Forms

Next.js Bootstrap Form Controls

Next.js Bootstrap form controls: inputs and textareas as Bootstrap markup in server components, with state and submits handled in client components.

Most forms are built from text inputs and textareas. In AdminKit for Next.js you write them as Bootstrap's markup in JSX, with className and htmlFor, not as components. Bootstrap's Sass styles them in the free and PRO versions of AdminKit for Next.js, and every class of the Bootstrap form docs works.

In the App Router, pages are server components: they render on the server and have no state. Plain form markup is fine there, so you don't need "use client" for it. A form that holds state or handles events, such as onChange and onSubmit, needs the browser. Put it in a client component, a file that starts with "use client".

AdminKit PRO adds three field components: the select, the input mask and the date picker.

Example

<div className="mb-3">
  <label htmlFor="formControlEmail" className="form-label">Email address</label>
  <input type="email" className="form-control" id="formControlEmail" placeholder="name@example.com" />
</div>
<div className="mb-3">
  <label htmlFor="formControlMessage" className="form-label">Message</label>
  <textarea className="form-control" id="formControlMessage" rows={3} />
</div>

Classes

ClassElementDescription
form-label<label>The label above a field
form-control<input>, <textarea>The text field
form-control-lg, form-control-sm.form-controlLarger or smaller field
form-control-plaintext<input readOnly>A read-only value without the field border
form-control-color<input type="color">The color picker
form-text<div>Help text below a field
form-select<select>The native select, see Select

Submitting a form

AdminKit for Next.js builds to static files (output: "export"). No Next.js server runs while people use the site, so Server Actions and <form action={serverAction}> are not available. Handle the submit in a client component instead, and send the data to your API with fetch:

src/components/contact-form.tsx
"use client";

import type { FormEvent } from "react";

export function ContactForm() {
  const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    await fetch("https://api.example.com/contact", {
      method: "POST",
      body: new FormData(event.currentTarget),
    });
  };

  return (
    <form onSubmit={onSubmit}>
      <div className="mb-3">
        <label htmlFor="contact-email" className="form-label">Email address</label>
        <input type="email" className="form-control" id="contact-email" name="email" required />
      </div>
      <div className="mb-3">
        <label htmlFor="contact-message" className="form-label">Message</label>
        <textarea className="form-control" id="contact-message" name="message" rows={3} />
      </div>
      <button type="submit" className="btn btn-primary">Send</button>
    </form>
  );
}

The page stays a server component and renders the form. pageMetadata() reads the title and description of /contact from src/data/pages.json, so add the route there:

src/app/(dashboard)/contact/page.tsx
import { ContactForm } from "@/components/contact-form";
import { pageMetadata } from "@/lib/site";

export const metadata = pageMetadata("/contact");

export default function ContactPage() {
  return (
    <>
      <h1 className="h3 mb-3">Contact</h1>
      <ContactForm />
    </>
  );
}

Controlled and uncontrolled inputs

The fields above are uncontrolled. They keep their own value, defaultValue sets the starting one, and the submit handler reads them with FormData. When the page has to react to each keystroke, use a controlled field. It takes value from state and updates that state in onChange:

src/components/bio-field.tsx
"use client";

import { useState } from "react";

export function BioField() {
  const [bio, setBio] = useState("");

  return (
    <div className="mb-3">
      <label htmlFor="bio" className="form-label">Bio</label>
      <textarea
        className="form-control"
        id="bio"
        rows={3}
        maxLength={160}
        value={bio}
        onChange={(event) => setBio(event.target.value)}
        aria-describedby="bio-count"
      />
      <div className="form-text" id="bio-count">{160 - bio.length} characters left</div>
    </div>
  );
}

Next.js builds the HTML of the page ahead of time, and the browser then takes over that HTML. This step is called hydration, and the browser's first render has to match the HTML. So start the state with a fixed value, not one that only the browser knows, such as localStorage. A value without onChange makes the field read-only, and React warns about it.

Sizes

For a field that has to line up with a large button or fit a compact filter bar, add form-control-lg or form-control-sm.

<input className="form-control form-control-lg mb-3" type="text" placeholder="Large input" aria-label="Large input" />
<input className="form-control mb-3" type="text" placeholder="Medium input" aria-label="Medium input" />
<input className="form-control form-control-sm" type="text" placeholder="Small input" aria-label="Small input" />

Disabled and read only

Sometimes a form shows a value that people may not change. A disabled field is grayed out and left out of the form data. A readOnly field cannot be edited, but it is still submitted.

<input className="form-control mb-3" type="text" placeholder="Disabled input" aria-label="Disabled input" disabled />
<input className="form-control" type="text" placeholder="Readonly input" aria-label="Readonly input" readOnly />

Accessibility

  • Label every field: a <label> whose htmlFor matches the field's id, or aria-label without a visible label. A placeholder does not replace a label.
  • Link help text with aria-describedby, so screen readers read it with the field.
  • Set the type (email, tel, url, number) and autoComplete: mobile keyboards and password managers use them.
  • A disabled field is skipped by the keyboard. Use readOnly when the value must stay reachable.

Last updated on

On this page