Skip to main content

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.

Comments