Skip to main content

Best Boilerplates for Internal Tools 2026

·StarterPick Team
Share:

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

What Internal Tools Actually Need

"Internal tools" covers a wide range: customer support panels, operations dashboards, financial reporting tools, employee directories, inventory management systems. Despite their variety, most internal tools share the same core requirements.

Data tables with actions. The core UI pattern is a filterable, sortable, paginated table of records with row-level actions (edit, delete, promote, notify). Everything else is variation on this theme.

Role-based access control. Internal tools are used by people with different responsibilities and trust levels. The support agent shouldn't be able to delete user accounts. The accountant needs billing data but not production database access. RBAC is non-negotiable.

Audit logging. For compliance, security, and debugging, you need to know who did what and when. Every mutation in an internal tool should write to an audit log.

Integration with existing systems. Internal tools rarely live in isolation — they need to read from your production database, call external APIs (Stripe, SendGrid, Intercom), and trigger workflows (Inngest, Temporal).

No customer-facing polish required. Internal tools can be functional over beautiful. This changes the development calculus significantly — you don't need to spend time on pixel-perfect UX when your users are employees who'd rather have the feature than the animation.

Choosing between a boilerplate, a framework like Refine, or a low-code tool (Tooljet, Appsmith) depends on who builds and maintains the tool, how complex the logic is, and whether TypeScript type safety across your data layer matters.


Option 1: Refine (Best Open-Source Admin Framework)

Refine is the most feature-complete open-source React admin framework available in 2026. It sits between a low-code builder (less flexible) and raw Next.js (more verbose) — you write TypeScript and JSX, but Refine handles CRUD scaffolding, data provider connections, and RBAC conventions.

What Refine provides:

  • Data providers for Prisma, Supabase, Hasura, REST, GraphQL
  • CRUD operations with one-liner syntax
  • RBAC access control layer
  • Audit log hooks
  • Built-in pagination, sorting, filtering
  • UI adapters for shadcn/ui, Ant Design, Material UI, Mantine

Best for: Technical teams who want the power of custom code but don't want to build every admin table from scratch. Refine saves 60–70% of the boilerplate code for data management UIs.

Tradeoff: Refine's abstraction layer has opinions. When your requirements deviate from its data provider pattern, you're working against the framework rather than with it.

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, EditButton, DeleteButton } from '@refinedev/antd';
// Or with shadcn/ui:
import { useTable } from '@refinedev/react-table';
import { ColumnDef } from '@tanstack/react-table';

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>
    ),
  },
  {
    id: 'actions',
    cell: ({ row }) => (
      <div className="flex gap-2">
        <EditButton hideText size="sm" recordItemId={row.original.id} />
      </div>
    ),
  },
];
// Refine data provider — connect to your API or DB directly:
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 (Next.js + TanStack Table)

For internal tools with complex business logic, custom validation, or tight integration with existing Next.js apps, building the data tables directly with TanStack Table v8 and shadcn/ui gives complete control.

This approach adds more code per table, but each table is exactly what you need — no framework abstraction to work around.

'use client';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable,
  getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from '@tanstack/react-table';
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 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,
  });
  
  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>
  );
}

RBAC: Role-Based Access Control

Role-based access control is where most internal tool implementations get either too simple (one boolean isAdmin field) or too complex (a full permissions graph for a tool used by 20 people). The right approach scales with actual requirements.

For most internal tools, a simple role hierarchy is sufficient: viewer → editor → admin → super_admin. Viewers can read. Editors can create and update. Admins can delete and access billing. Super admins can manage roles.

// Simple RBAC pattern for internal tools:
const USER_ROLES = ['viewer', 'editor', 'admin', 'super_admin'] as const;
type UserRole = typeof USER_ROLES[number];

// middleware.ts — protect admin routes:
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();
}

For more complex requirements — where permissions depend on resource ownership, team membership, or contextual attributes — consider Permit.io (managed) or Casbin (self-hosted).


Audit Logging

Every mutation in an internal tool should be logged. Support investigations, compliance audits, and debugging all depend on knowing who changed what and when.

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,
  });
}

Low-Code Alternatives Compared

For teams where the tool users are non-developers, or where the internal tool needs to be maintained by operations staff rather than engineers, low-code builders make more sense than custom code.

ToolModelStarsSelf-HostPriceBest For
RetoolManagedN/A$10/user/moPolished enterprise UX
TooljetOpen source29K✅ DockerFree / $20/userRetool alternative, self-hosted
AppsmithOpen source33K✅ DockerFree / $15/userCRUD apps, non-devs
BaserowOpen source9K✅ DockerFree / $5/userAirtable-like, spreadsheet UX
RefineOpen source28KFreeTechnical teams, custom code

Choose Tooljet or Appsmith when: The people maintaining the tool are operations staff, not developers. The logic is primarily CRUD (filter, display, update records). Speed of new feature additions matters more than code quality.

Choose Refine or custom Next.js when: The tool has complex business logic (conditional workflows, integration with internal APIs, custom validation). Type safety across your data layer matters. The tool is customer-adjacent (support agents use it while on calls — latency and reliability matter).


Integrating Internal Tools with External Services

Internal tools rarely live in isolation. The real value comes from connecting your admin panel to the services your business already uses: Stripe for billing operations, Intercom or Zendesk for customer support context, SendGrid for email delivery status, Segment for analytics. Every connection you build reduces the number of tabs your team has to switch between.

The pattern is consistent: read data from external APIs in server-side components (no rate limit risk, no CORS issues), and trigger actions via server actions. Show relevant context from multiple sources on the same screen.

For example, a user management panel that shows: user's account status (from your DB) + their Stripe subscription and recent invoices (from Stripe API) + their open support tickets (from Intercom API) + their last 5 session events (from Mixpanel API) — all on one page. Building this with separate API calls in a Server Component is straightforward; the external calls happen in parallel via Promise.all.

Be selective about which integrations you build. Every external API call is a potential failure point. Cache aggressively (30–60 second TTL is usually fine for internal tools) and show graceful loading states when external services are slow.

Deployment and Access Control

Internal tools need deployment and access patterns that are different from customer-facing apps. The most common approaches:

Behind SSO/VPN: Deploy the internal tool as a separate Next.js app, restrict access at the network level (Cloudflare Access, Tailscale, VPN). No auth code needed in the app itself — the network perimeter handles it. Simple and secure, but requires network infrastructure.

Same app, route-level auth: Add internal tool routes to your existing Next.js app under /admin or /internal, protected by middleware that checks for the admin role. Simpler deployment, but the internal tool is exposed to the internet and relies on your auth system for security.

Separate Vercel deployment: Deploy the admin tool to a separate Vercel project with environment-variable-controlled access or Vercel's built-in password protection. Quick to set up, no auth code required.

For most early-stage SaaS products, the route-level auth approach within your main app is the right balance — less infrastructure, and you already have RBAC in place. Add network perimeter protection when security requirements become more formal.


Common Internal Tool Patterns

Impersonation / view-as: Support tools often need admins to log in as a customer to debug their experience. This requires a dedicated server action that creates a temporary session with the target user's ID but the admin's permissions flagged separately.

Bulk actions: Most internal tables eventually need bulk operations (bulk refund, bulk export, bulk email). TanStack Table's row selection model handles this well — collect selected row IDs, pass to a server action.

Webhooks / action triggers: Internal tools often need to trigger external actions (resend an invoice, trigger a refund, restart a job). These should be server actions with audit logs, not client-side API calls.


From Internal Tool to Customer-Facing Feature

Some internal tools grow into customer-facing product features. The admin view of user data becomes a self-service analytics dashboard. The billing management panel becomes a customer billing portal. The monitoring tool becomes a status page.

When this happens, the technical requirements shift: internal tools can tolerate slower load times, rougher UX, and occasional downtime. Customer-facing features cannot. Plan for this transition by keeping internal tool logic in clearly separated modules.

The pattern that works: build the internal tool with the same data layer as your customer-facing product, but with different presentation layers. The fetchOrders(userId) function works for both the admin panel and the customer's order history page — only the rendering differs. This makes the transition from internal to customer-facing straightforward when the time comes.

Be careful about access control when surfacing internal tools to customers. An admin can see any user's data; a customer can only see their own. The permission check is the only thing separating them. Use Prisma row-level security or application-layer scoping to enforce this boundary at the data layer, not just the UI layer.

Performance for High-Volume Internal Tools

Internal tools used by support teams under load can become performance bottlenecks. A support agent handling customer calls cannot wait 5 seconds for a user lookup — every second of latency is a second of awkward silence with a customer on the phone.

The most impactful optimizations for internal tool performance:

Index the tables you search by. If your support panel searches users by email or phone number, those columns need indexes. EXPLAIN ANALYZE your common queries in production and add indexes for any sequential scans over large tables.

Cache external service calls. Fetching Stripe subscription data for a user should use a short cache (30–60 seconds). A support agent looking at the same customer record twice shouldn't hit the Stripe API twice.

Paginate aggressively. Internal tool tables often try to show everything at once ("just show all 50,000 users"). Default to 25 or 50 rows with efficient keyset pagination, not offset pagination. Offset pagination with large offsets (LIMIT 25 OFFSET 49000) is slow; keyset pagination (WHERE id > $lastId LIMIT 25) is fast regardless of the position.

Real-time only when necessary. Don't add Supabase Realtime subscriptions to every internal table "just in case." Real-time connections have overhead. Add them to tables where freshness is required (active incident monitoring, live order status) and use polling or manual refresh everywhere else.


For the admin panel specifically (as a customer-facing feature within your SaaS), see best boilerplates with admin panels. For the RBAC patterns used in multi-tenant products, best boilerplates for admin dashboards covers the full access control design. If you're building a no-code platform (rather than an internal tool), best no-code/low-code boilerplates covers builder frameworks.


Methodology

Tool star counts sourced from GitHub as of Q1 2026. Pricing from official product pages. Refine benchmarks based on hands-on evaluation against a standard CRUD admin panel use case.

The distinction between internal tools and customer-facing features is a spectrum, not a binary. The best internal tools are built with the same care as customer-facing products — because your team's productivity directly affects the product quality your customers experience. Teams that invest in robust internal tooling early consistently outpace those that defer it — support ticket resolution speed, data quality, and incident response all improve when operators have reliable, fast tools.

Find internal tool boilerplates at StarterPick.

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.