Skip to content
Framework: Vite + React + TypeScriptDifficulty: intermediate

Admin panel

A CRUD members table with search, sortable columns, pagination, and validated create/edit forms.

An admin layout with a sidebar and a members resource: searchable, sortable, paginated table; create and edit dialogs with field-level validation and ARIA error wiring; activate/deactivate toggles and delete with confirmation. The data layer in src/api.ts mirrors the HTTP calls you would make (list, create, update, delete) so moving to a real API is a drop-in change.

  • react
  • admin
  • crud
  • table
  • forms
  • tailwind

This preview is the template's actual file tree and source code — exactly what is copied into a new project. Codexa does not publish screenshots or mock-ups of templates; run the project to see it rendered.

Files12 files

src/App.tsx144 lines
import { useMemo, useState } from "react";
import { useMembers, type Member } from "./api";
import { MemberForm } from "./MemberForm";

type SortKey = "name" | "email" | "role" | "createdAt";
const PAGE_SIZE = 4;

export default function App() {
  const api = useMembers();
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: "name", dir: 1 });
  const [page, setPage] = useState(1);
  const [editing, setEditing] = useState<Member | "new" | null>(null);

  const rows = useMemo(() => {
    const q = query.toLowerCase();
    const filtered = api.members.filter((m) => m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q));
    return filtered.sort((a, b) => a[sort.key].localeCompare(b[sort.key]) * sort.dir);
  }, [api.members, query, sort]);
  const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
  const current = Math.min(page, pages);
  const visible = rows.slice((current - 1) * PAGE_SIZE, current * PAGE_SIZE);

  const toggleSort = (key: SortKey) => setSort((s) => (s.key === key ? { key, dir: s.dir === 1 ? -1 : 1 } : { key, dir: 1 }));

  return (
    <div className="min-h-screen bg-slate-100 text-slate-900 md:grid md:grid-cols-[220px_1fr]">
      <aside className="border-b border-slate-200 bg-white p-4 md:border-b-0 md:border-r">
        <p className="text-sm font-semibold tracking-wide">ADMIN</p>
        <nav className="mt-4 space-y-1 text-sm" aria-label="Sections">
          <span aria-current="page" className="block rounded-md bg-slate-100 px-3 py-2 font-medium">
            Members
          </span>
          <span className="block px-3 py-2 text-slate-400">Settings (add a page)</span>
        </nav>
      </aside>
      <main className="p-4 sm:p-6">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <h1 className="text-xl font-semibold">Members</h1>
          <button type="button" onClick={() => setEditing("new")} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
            Add member
          </button>
        </div>
        <div className="mt-4 rounded-xl border border-slate-200 bg-white">
          <div className="border-b border-slate-200 p-3">
            <input
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setPage(1);
              }}
              placeholder="Search by name or email"
              aria-label="Search members"
              className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm sm:w-72"
            />
          </div>
          <div className="overflow-x-auto">
            <table className="w-full min-w-[640px] text-sm">
              <thead className="text-left text-slate-500">
                <tr>
                  {(["name", "email", "role", "createdAt"] as SortKey[]).map((k) => (
                    <th key={k} className="p-3">
                      <button type="button" onClick={() => toggleSort(k)} className="font-medium hover:text-slate-900" aria-sort={sort.key === k ? (sort.dir === 1 ? "ascending" : "descending") : undefined}>
                        {k === "createdAt" ? "Created" : k[0].toUpperCase() + k.slice(1)}
                        {sort.key === k ? (sort.dir === 1 ? " ↑" : " ↓") : ""}
                      </button>
                    </th>
                  ))}
                  <th className="p-3">Status</th>
                  <th className="p-3">
                    <span className="sr-only">Actions</span>
                  </th>
                </tr>
              </thead>
              <tbody>
                {visible.map((m) => (
                  <tr key={m.id} className="border-t border-slate-100">
                    <td className="p-3 font-medium">{m.name}</td>
                    <td className="p-3 text-slate-600">{m.email}</td>
                    <td className="p-3 capitalize">{m.role}</td>
                    <td className="p-3 text-slate-600">{m.createdAt}</td>
                    <td className="p-3">
                      <button type="button" onClick={() => api.update(m.id, { active: !m.active })} className={"rounded-full px-2 py-0.5 text-xs " + (m.active ? "bg-green-100 text-green-800" : "bg-slate-200 text-slate-700")} aria-label={(m.active ? "Deactivate " : "Activate ") + m.name}>
                        {m.active ? "Active" : "Inactive"}
                      </button>
                    </td>
                    <td className="p-3 text-right">
                      <button type="button" onClick={() => setEditing(m)} className="text-blue-600 hover:underline">
                        Edit
                      </button>
                      <button
                        type="button"
                        onClick={() => {
                          if (window.confirm("Delete " + m.name + "?")) api.remove(m.id);
                        }}
                        className="ml-3 text-red-600 hover:underline"
                      >
                        Delete
                      </button>
                    </td>
                  </tr>
                ))}
                {visible.length === 0 ? (
                  <tr>
                    <td colSpan={6} className="p-8 text-center text-slate-500">
                      No members match.
                    </td>
                  </tr>
                ) : null}
              </tbody>
            </table>
          </div>
          <nav className="flex items-center justify-between border-t border-slate-200 p-3 text-sm" aria-label="Pagination">
            <span className="text-slate-500">
              {rows.length} member{rows.length === 1 ? "" : "s"}
            </span>
            <div className="flex items-center gap-2">
              <button type="button" disabled={current <= 1} onClick={() => setPage(current - 1)} className="rounded border border-slate-300 px-2 py-1 disabled:opacity-40">
                Previous
              </button>
              <span>
                Page {current} of {pages}
              </span>
              <button type="button" disabled={current >= pages} onClick={() => setPage(current + 1)} className="rounded border border-slate-300 px-2 py-1 disabled:opacity-40">
                Next
              </button>
            </div>
          </nav>
        </div>
        {editing ? (
          <MemberForm
            initial={editing === "new" ? null : editing}
            onCancel={() => setEditing(null)}
            onSave={(values) => {
              if (editing === "new") api.create(values);
              else api.update(editing.id, values);
              setEditing(null);
            }}
          />
        ) : null}
      </main>
    </div>
  );
}

Dependencies

Documented from the template's package.json. The runtime has a prebuilt dependency set for Vite + React + TypeScript, so installs are fast.

PackageVersion
react^19.1.0
react-dom^19.1.0
vite^6.3.0
@vitejs/plugin-react^4.4.0
tailwindcss^4.1.0
@tailwindcss/vite^4.1.0
typescript^5.8.0

How it runs

Framework
Vite + React + TypeScript
Dev command
npm run dev
Build command
npm run build
Output directory
dist
Port
5173
Opens at
/
Difficulty
intermediate

Projects run in an isolated sandbox with a live preview. Read the projects documentation for details on settings, snapshots and deployment.

Need the raw data? GET /api/v1/templates/vite-react-admin returns this template with its files.

Build on Admin panel

Create a free account, start from this template, and ask the AI agent to turn it into your app.