VVibeFlui

Server-side Example

Generated UI previewSERVER MODE

Server-side users

#ActionsEmailStatus
No data available.
Page 1 of 1 · Total: 0

This runs against an in-memory mock of the REST endpoints below (same response shape and a simulated ~450ms round trip) since this docs site is static-first and has no backend — search, sort, pagination, create, edit, and delete all go through it.

This tutorial shows endpoint-backed list, create, update, and delete operations in a Next.js App Router project.

VibeFlui owns UI rendering and operation orchestration. The route handlers are host app code.

What's a stand-in vs what your backend should own

Everything under app/api/users in this tutorial (the "List endpoint" and "Detail endpoint" sections below) and lib/users-store.ts is a simplified stand-in written only so this example runs without a real backend. It is not a template for a production API:

  • lib/users-store.ts is a plain in-memory array. A real backend replaces it with an actual database/ORM.
  • The route handlers skip authentication, authorization, and input validation entirely. A real backend must check who is calling, whether they are allowed to perform the operation, and that values from the request body are well-formed before touching storage — none of that is VibeFlui's or the frontend's job.
  • The business-rule example in "Response contract" below (409 "Email is already used") is not implemented in the sample route handlers at all; it only illustrates the response shape a real validation rule would return.

What is the real contract to implement, not just tutorial code, is everything the schema declares as fixed: the URL and method per operation (schema.endpoint), the query param names for search/sort/pagination (table.search.param, table.pagination.pageParam/pageSizeParam), the dataPath/totalPath locations in the list response, and the { status, code, message, data } response envelope described in "Response contract". Your backend can be written in any language or framework — these route handlers are Next.js only because the tutorial's frontend happens to be Next.js — as long as it honors that contract.

File structure

TXT
app/
  api/
    users/
      route.ts
      [id]/
        route.ts
  users/
    page.tsx
lib/
  users-store.ts
  vibeflui/
    registry.tsx
schemas/
  users/
    server-users.schema.ts

Schema file

schemas/users/server-users.schema.ts

TS
export const serverUsersSchema = {
  resource: "users",
  title: "Server-side users",
  mode: "server",
  identity: {
    key: "id",
    param: "id"
  },
  endpoint: {
    list: "/api/users",
    create: { url: "/api/users", method: "POST" },
    update: { url: "/api/users/:id", method: "PATCH" },
    delete: { url: "/api/users/:id", method: "DELETE" }
  },
  provider: {
    type: "rest"
  },
  table: {
    dataPath: "data",
    totalPath: "total",
    columns: [
      { key: "name", label: "Name", searchable: true, sortable: true },
      { key: "email", label: "Email", searchable: true },
      { key: "status", label: "Status" }
    ],
    search: {
      enabled: true,
      param: "search"
    },
    pagination: {
      enabled: true,
      pageSize: 10,
      pageParam: "page",
      pageSizeParam: "pageSize"
    },
    sort: {
      enabled: true,
      defaultKey: "name",
      defaultDirection: "asc"
    }
  },
  form: {
    mode: "modal",
    fields: [
      { name: "name", label: "Name", required: true },
      { name: "email", label: "Email", type: "email", required: true },
      {
        name: "status",
        type: "select",
        options: [
          { label: "Active", value: "active" },
          { label: "Pending", value: "pending" }
        ]
      }
    ]
  },
  actions: {
    create: { enabled: true, label: "Create user" },
    edit: { enabled: true },
    delete: { enabled: true, confirm: true, variant: "danger" }
  }
} as const;

Page file

app/users/page.tsx

TSX
"use client";

import { FluiKit, FluiKitProvider } from "@vibeflui/core";
import { serverUsersSchema } from "@/schemas/users/server-users.schema";
import { vibefluiRegistry } from "@/lib/vibeflui/registry";

export default function UsersPage() {
  return (
    <FluiKitProvider registry={vibefluiRegistry}>
      <FluiKit schema={serverUsersSchema} />
    </FluiKitProvider>
  );
}

FluiKit loads list data from the provider because data and response are absent and table.enabled is true.

Registry file

lib/vibeflui/registry.tsx

TSX
import { createRegistry } from "@vibeflui/core";

export const vibefluiRegistry = createRegistry();

In-memory store

lib/users-store.ts

TS
export type UserRow = {
  id: string;
  name: string;
  email: string;
  status: string;
};

export let userRows: UserRow[] = [
  { id: "usr_1", name: "Ada Lovelace", email: "ada@example.test", status: "active" },
  { id: "usr_2", name: "Grace Hopper", email: "grace@example.test", status: "pending" }
];

export function setUserRows(nextRows: UserRow[]) {
  userRows = nextRows;
}

This store is only for the tutorial. Production apps should use a real persistence layer.

List endpoint

app/api/users/route.ts

TS
import { NextResponse } from "next/server";
import { setUserRows, userRows } from "@/lib/users-store";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const search = searchParams.get("search")?.toLowerCase() ?? "";
  const page = Number(searchParams.get("page") ?? 1);
  const pageSize = Number(searchParams.get("pageSize") ?? 10);

  const filtered = userRows.filter((row) =>
    [row.name, row.email, row.status].some((value) => value.toLowerCase().includes(search))
  );
  const start = (page - 1) * pageSize;

  return NextResponse.json({
    status: true,
    code: 200,
    message: "Users loaded",
    data: filtered.slice(start, start + pageSize),
    total: filtered.length
  });
}

export async function POST(request: Request) {
  const values = await request.json();
  const row = { id: crypto.randomUUID(), ...values };
  setUserRows([...userRows, row]);

  return NextResponse.json({
    status: true,
    code: 200,
    message: "User created",
    data: row
  });
}

Detail endpoint

app/api/users/[id]/route.ts

TS
import { NextResponse } from "next/server";
import { setUserRows, userRows } from "@/lib/users-store";

export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
  const { id } = await context.params;
  const values = await request.json();
  const nextRows = userRows.map((row) => (row.id === id ? { ...row, ...values } : row));
  setUserRows(nextRows);

  return NextResponse.json({
    status: true,
    code: 200,
    message: `User ${id} updated`,
    data: nextRows.find((row) => row.id === id)
  });
}

export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) {
  const { id } = await context.params;
  setUserRows(userRows.filter((row) => row.id !== id));

  return NextResponse.json({
    status: true,
    code: 200,
    message: `User ${id} deleted`
  });
}

Response contract

Success:

JSON
{
  "status": true,
  "code": 200,
  "message": "Saved successfully"
}

Business-rule response mapped to warning feedback:

JSON
{
  "status": false,
  "code": 409,
  "message": "Email is already used"
}

The feedback component receives warning because status: false with a 400..499 code is treated as an expected business or validation response.

Failed:

JSON
{
  "status": false,
  "code": 500,
  "message": "Unable to save user"
}

HTTP errors thrown by the endpoint provider are shown through the table error state or action feedback.