Best Boilerplates for Building Internal Tools 2026
·StarterPick Team
internal-toolsadmin-dashboardretoolshadcn-uirbac2026
TL;DR
The best internal tool boilerplate in 2026 depends on your technical team's skills. For technical teams: Next.js + shadcn/ui + Prisma + RBAC is the gold standard — full control, TypeScript, extensible. For less technical teams: Retool/Appsmith/Tooljet (low-code internal tool builders). For self-hosted: Tooljet or Appsmith (open source). The common pattern: data tables with actions, role-based access control, audit logs, and integration with existing services.
Key Takeaways
- Custom code approach: Next.js + TanStack Table + shadcn/ui + Prisma + Clerk (RBAC)
- Low-code builders: Retool (managed), Tooljet (open source), Appsmith (open source)
- Admin panel generators: React Admin, Refine (most feature-complete)
- Refine: Best open-source React admin framework with CRUD + RBAC built-in (2026 standard)
- Data tables: TanStack Table v8 for complex tables; AG Grid for Excel-like spreadsheet UX
- RBAC: Casbin or Permit.io for complex permission models; simple role column works for most
Option 1: Custom Code (Next.js + Refine)
npx create-refine-app@latest my-admin
# Choose: Next.js + App Router + Prisma + shadcn/ui
// Refine admin panel pattern — CRUD with almost no code:
import { List, useTable, EditButton, DeleteButton, ShowButton } from '@refinedev/antd';
// Or with shadcn/ui:
import { useTable } from '@refinedev/react-table';
import { ColumnDef } from '@tanstack/react-table';
// Define a resource — Refine handles CRUD:
export const userColumns: ColumnDef<User>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'plan',
header: 'Plan',
cell: ({ getValue }) => (
<Badge variant={getValue() === 'pro' ? 'default' : 'secondary'}>
{getValue() as string}
</Badge>
),
},
{
accessorKey: 'createdAt',
header: 'Joined',
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
},
{
id: 'actions',
cell: ({ row }) => (
<div className="flex gap-2">
<EditButton hideText size="sm" recordItemId={row.original.id} />
<ShowButton hideText size="sm" recordItemId={row.original.id} />
</div>
),
},
];
// Refine data provider — connect to your API or DB directly:
// src/providers/dataProvider.ts
import { dataProvider } from '@refinedev/prisma';
import { db } from '@/lib/db';
export const prismaDataProvider = dataProvider(db);
// app/layout.tsx:
import { Refine } from '@refinedev/core';
export default function RootLayout({ children }) {
return (
<Refine
dataProvider={prismaDataProvider}
resources={[
{ name: 'users', list: '/users', edit: '/users/:id/edit', show: '/users/:id' },
{ name: 'subscriptions', list: '/subscriptions' },
{ name: 'invoices', list: '/invoices' },
]}
>
{children}
</Refine>
);
}
Option 2: Custom from Scratch
// The core internal tool pattern — users table with actions:
'use client';
import { useQuery } from '@tanstack/react-query';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable,
getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from '@tanstack/react-table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { MoreHorizontal } from 'lucide-react';
import { useState } from 'react';
const columns: ColumnDef<User>[] = [
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'plan',
header: 'Plan',
cell: ({ row }) => (
<Badge variant={row.getValue('plan') === 'pro' ? 'default' : 'outline'}>
{row.getValue('plan')}
</Badge>
),
},
{
id: 'actions',
cell: ({ row }) => {
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => grantProAccess(user.id)}>
Grant Pro Access
</DropdownMenuItem>
<DropdownMenuItem onClick={() => sendEmail(user.email)}>
Send Email
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive"
onClick={() => deleteUser(user.id)}>
Delete User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
export function UsersTable() {
const [globalFilter, setGlobalFilter] = useState('');
const { data: users = [] } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
initialState: { pagination: { pageSize: 25 } },
});
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-sm"
/>
<div className="rounded-md border">
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-4 py-3 text-left text-sm font-medium">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b hover:bg-muted/25">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} users
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>
</div>
);
}
RBAC: Role-Based Access Control
// Simple RBAC pattern for internal tools:
// db/schema.ts
const USER_ROLES = ['viewer', 'editor', 'admin', 'super_admin'] as const;
type UserRole = typeof USER_ROLES[number];
// middleware.ts — protect admin routes:
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
const ROLE_PERMISSIONS: Record<string, UserRole[]> = {
'/admin/users': ['admin', 'super_admin'],
'/admin/billing': ['super_admin'],
'/admin/settings': ['admin', 'super_admin'],
'/admin/dashboard': ['viewer', 'editor', 'admin', 'super_admin'],
};
export async function middleware(request: NextRequest) {
const session = await auth();
const pathname = request.nextUrl.pathname;
if (!session) return NextResponse.redirect(new URL('/login', request.url));
const requiredRoles = Object.entries(ROLE_PERMISSIONS)
.find(([path]) => pathname.startsWith(path))?.[1];
if (requiredRoles && !requiredRoles.includes(session.user.role as UserRole)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
return NextResponse.next();
}
Audit Logging
// Essential for internal tools — who did what:
export async function auditLog(params: {
userId: string;
action: string;
resource: string;
resourceId: string;
metadata?: Record<string, unknown>;
}) {
await db.auditLog.create({
data: {
userId: params.userId,
action: params.action, // 'USER_DELETED', 'SUBSCRIPTION_UPGRADED', etc.
resource: params.resource,
resourceId: params.resourceId,
metadata: params.metadata ?? {},
ip: headers().get('x-forwarded-for') ?? 'unknown',
userAgent: headers().get('user-agent') ?? 'unknown',
},
});
}
// Usage in server action:
async function deleteUser(userId: string) {
'use server';
const session = await auth();
if (session?.user.role !== 'admin') throw new Error('Unauthorized');
await db.user.delete({ where: { id: userId } });
await auditLog({
userId: session.user.id,
action: 'USER_DELETED',
resource: 'user',
resourceId: userId,
});
}
Open Source Low-Code Alternatives
Self-hosted internal tool builders:
Tooljet (tooljet.com):
→ Open source Retool alternative
→ 25K+ GitHub stars
→ Connect to any DB/API via GUI
→ Build dashboards without code
→ Self-host on Docker/K8s
Appsmith (appsmith.com):
→ Similar to Tooljet, strong drag-and-drop
→ 33K+ GitHub stars
→ Better for CRUD-heavy internal apps
→ JS transformers for logic
Baserow (baserow.io):
→ Airtable-like interface
→ Self-hosted with Docker
→ Good for non-technical teams
When to use low-code vs custom:
✅ Low-code: Non-developers maintain the tool
✅ Low-code: Simple CRUD with DB tables
✅ Custom code: Complex business logic
✅ Custom code: Need tight TypeScript types
✅ Custom code: Customer-facing admin panel
Find internal tool boilerplates at StarterPick.