Most Angular admin dashboards need a sortable, filterable, paginated table. Here's how to build one properly using Angular 21 signals and Bootstrap 5 for styling.

The Component

// data-table.component.ts
import { Component, Input, computed, signal } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'

export interface TableColumn {
  key: string
  label: string
  sortable?: boolean
}

@Component({
  selector: 'app-data-table',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './data-table.component.html'
})
export class DataTableComponent {
  @Input() columns: TableColumn[] = []
  @Input() set data(value: any[]) {
    this._data.set(value)
  }

  private _data = signal<any[]>([])

  // Search
  searchQuery = signal('')

  // Sort
  sortColumn = signal('')
  sortDirection = signal<'asc' | 'desc'>('asc')

  // Pagination
  currentPage = signal(1)
  pageSize = signal(10)

  // Filtered + sorted data
  processedData = computed(() => {
    let rows = [...this._data()]

    // Filter
    const query = this.searchQuery().toLowerCase()
    if (query) {
      rows = rows.filter(row =>
        Object.values(row).some(val =>
          String(val).toLowerCase().includes(query)
        )
      )
    }

    // Sort
    const col = this.sortColumn()
    if (col) {
      const dir = this.sortDirection()
      rows.sort((a, b) => {
        const av = a[col], bv = b[col]
        if (av < bv) return dir === 'asc' ? -1 : 1
        if (av > bv) return dir === 'asc' ? 1 : -1
        return 0
      })
    }

    return rows
  })

  // Paginated slice
  paginatedData = computed(() => {
    const start = (this.currentPage() - 1) * this.pageSize()
    return this.processedData().slice(start, start + this.pageSize())
  })

  totalPages = computed(() =>
    Math.ceil(this.processedData().length / this.pageSize())
  )

  pages = computed(() =>
    Array.from({ length: this.totalPages() }, (_, i) => i + 1)
  )

  // Sort handler
  sort(column: string) {
    if (this.sortColumn() === column) {
      this.sortDirection.update(d => d === 'asc' ? 'desc' : 'asc')
    } else {
      this.sortColumn.set(column)
      this.sortDirection.set('asc')
    }
    this.currentPage.set(1)
  }

  // Page handler
  goToPage(page: number) {
    if (page >= 1 && page <= this.totalPages()) {
      this.currentPage.set(page)
    }
  }

  // Search handler
  onSearch(query: string) {
    this.searchQuery.set(query)
    this.currentPage.set(1) // Reset to page 1 on search
  }
}

The Template

<!-- data-table.component.html -->
<div>
  <!-- Search + Info bar -->
  <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
    <div class="d-flex align-items-center gap-2">
      <span class="text-muted small">
        Showing {{ (currentPage()-1)*pageSize()+1 }}–{{ [currentPage()*pageSize(), processedData().length] | min }}
        of {{ processedData().length }} results
      </span>
    </div>
    <input
      type="search"
      class="form-control form-control-sm"
      style="max-width:240px;"
      placeholder="Search..."
      [value]="searchQuery()"
      (input)="onSearch($any($event.target).value)">
  </div>

  <!-- Table -->
  <div class="table-responsive">
    <table class="table table-hover align-middle">
      <thead class="table-dark">
        <tr>
          <th *ngFor="let col of columns"
            [style.cursor]="col.sortable ? 'pointer' : 'default'"
            (click)="col.sortable ? sort(col.key) : null">
            {{ col.label }}
            <span *ngIf="col.sortable && sortColumn() === col.key">
              {{ sortDirection() === 'asc' ? '↑' : '↓' }}
            </span>
            <span *ngIf="col.sortable && sortColumn() !== col.key" class="text-muted opacity-50">↕</span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr *ngFor="let row of paginatedData()">
          <td *ngFor="let col of columns">{{ row[col.key] }}</td>
        </tr>
        <tr *ngIf="paginatedData().length === 0">
          <td [colSpan]="columns.length" class="text-center text-muted py-4">
            No results found
          </td>
        </tr>
      </tbody>
    </table>
  </div>

  <!-- Pagination -->
  <div class="d-flex justify-content-between align-items-center mt-3 flex-wrap gap-2"
    *ngIf="totalPages() > 1">
    <div class="d-flex align-items-center gap-2">
      <span class="small text-muted">Rows per page:</span>
      <select class="form-select form-select-sm" style="width:auto;"
        [value]="pageSize()"
        (change)="pageSize.set(+$any($event.target).value); currentPage.set(1)">
        <option value="5">5</option>
        <option value="10">10</option>
        <option value="25">25</option>
        <option value="50">50</option>
      </select>
    </div>
    <nav>
      <ul class="pagination pagination-sm mb-0">
        <li class="page-item" [class.disabled]="currentPage() === 1">
          <a class="page-link" (click)="goToPage(currentPage()-1)">«</a>
        </li>
        <li class="page-item" *ngFor="let page of pages()"
          [class.active]="page === currentPage()">
          <a class="page-link" (click)="goToPage(page)">{{ page }}</a>
        </li>
        <li class="page-item" [class.disabled]="currentPage() === totalPages()">
          <a class="page-link" (click)="goToPage(currentPage()+1)">»</a>
        </li>
      </ul>
    </nav>
  </div>
</div>

Using the Component

// parent.component.ts
import { DataTableComponent } from './data-table.component'

@Component({
  standalone: true,
  imports: [DataTableComponent],
  template: `
    <app-data-table
      [columns]="columns"
      [data]="templates">
    </app-data-table>
  `
})
export class ParentComponent {
  columns = [
    { key: 'name', label: 'Template', sortable: true },
    { key: 'framework', label: 'Framework', sortable: true },
    { key: 'price', label: 'Price', sortable: true },
    { key: 'downloads', label: 'Downloads', sortable: true },
    { key: 'status', label: 'Status', sortable: false },
  ]

  templates = [
    { name: 'Marvel Dashboard', framework: 'Angular', price: 29, downloads: 1240, status: 'Active' },
    { name: 'PORTO Template', framework: 'Bootstrap', price: 19, downloads: 3580, status: 'Active' },
    { name: 'Proctu Medical', framework: 'React', price: 39, downloads: 890, status: 'Draft' },
  ]
}

The table handles search, sort and pagination automatically. Pass data and column definitions — everything else is handled internally.

Custom Cell Rendering

For custom cells like badges, links or action buttons, extend with a slot or template projection approach. The simplest is using ng-content with named outlets or @ContentChild for a cell template. For most admin dashboards, the inline approach of handling specific columns in the parent template is simpler:

<app-data-table [columns]="standardColumns" [data]="filteredData">
</app-data-table>

And keep action columns in the parent template as a separate column outside the reusable component. Clean separation between the data table logic and page-specific actions.

Frequently Asked Questions

For simple tables with under 1000 rows, build it yourself — Angular pipes for filtering/sorting, Bootstrap for styling. For complex requirements like virtual scrolling, Excel export, server-side pagination, use TanStack Table or AG Grid which both have Angular adapters.
Create a signal for sort column and direction. In a computed signal, sort the data array: return [...data].sort((a,b) => direction === 'asc' ? a[column] > b[column] ? 1 : -1 : a[column] < b[column] ? 1 : -1). Trigger sort changes on header click.
Track currentPage and pageSize signals. Slice the filtered array: filteredData().slice((currentPage()-1)*pageSize(), currentPage()*pageSize()). Calculate totalPages from filteredData().length / pageSize(). Update currentPage on nav button clicks.
On each page change, call your API with page and pageSize parameters. Store the returned rows and totalCount. Calculate totalPages from totalCount/pageSize. Show a loading state while fetching. This is the approach for large datasets.
Map the current rows to CSV strings: const csv = [headers.join(','), ...rows.map(r => Object.values(r).join(','))].join(' '). Create a Blob and trigger download: const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv])); a.download = 'data.csv'; a.click().

Related

Need a Full Angular + Bootstrap Admin Dashboard?

Marvel Angular Dashboard — Angular 21 + Bootstrap 5 with 50+ components and dark mode.

Browse Templates →

Use code FIRST30 for 30% off.