Server-side Example
Server-side users
| # | Actions | Status | ||
|---|---|---|---|---|
| No data available. | ||||
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.tsis 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
valuesfrom 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
app/
api/
users/
route.ts
[id]/
route.ts
users/
page.tsx
lib/
users-store.ts
vibeflui/
registry.tsx
schemas/
users/
server-users.schema.tsSchema file
schemas/users/server-users.schema.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
"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
import { createRegistry } from "@vibeflui/core";
export const vibefluiRegistry = createRegistry();In-memory store
lib/users-store.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
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
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:
{
"status": true,
"code": 200,
"message": "Saved successfully"
}Business-rule response mapped to warning feedback:
{
"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:
{
"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.