Admin panels repeat themselves. Every table gets a list page, a create form, an edit form, a delete button, loading and error states. With Signals and the native resource() API, Angular now has what it takes to write that layer once and reuse it for every table.
This article shows how, using Pidrang, an open-source headless admin framework for Angular that I maintain. It is version 0.1.1 and pre-1.0, so the API may still change. The backend here is Supabase, but the pattern does not depend on it.
What we build
- A typed
Resourcethat describes aproductstable. - One list component that serves every table, driven by a route parameter.
- Per-row edit and delete actions.
- A create form that only sends the fields the user changed.
Prerequisites: an Angular 22 app with standalone APIs and the router, a Supabase project with a table that has an id primary key, and its URL and publishable key (formerly anon).
1. Install
npm install @pidrang/core @pidrang/supabase @pidrang/material @supabase/supabase-js
ng add @angular/material
@pidrang/material is one of two UI layers. @pidrang/tailwind offers the same components styled with Tailwind utility classes.
2. Describe the table once
import { Resource } from '@pidrang/core';
export const resources: Resource[] = [
{
name: 'products',
fields: [
{ name: 'id', type: 'text', readOnly: true },
{ name: 'name', type: 'text' },
{ name: 'price', type: 'number' },
{ name: 'in_stock', label: 'In stock', type: 'boolean' },
{
name: 'created_at',
label: 'Created at',
type: 'timestamp',
readOnly: true,
hasDefault: true,
},
],
},
];
name matches the table. Each field has a type (text, number, boolean, date, timestamp, time, numeric), and the UI renders columns and inputs from it. readOnly keeps generated columns out of forms. date, timestamp, time and numeric stay plain strings end to end, so a high-precision number or a zoned time is never rounded through Number or Date.
3. Register the providers
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { providePidrang } from '@pidrang/core';
import { provideSupabaseAuthProvider, provideSupabaseDataProvider } from '@pidrang/supabase';
import { createClient } from '@supabase/supabase-js';
import { appRoutes } from './app.routes';
import { resources } from './resources';
const client = createClient('https://YOUR-PROJECT.supabase.co', 'YOUR-PUBLISHABLE-KEY');
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(appRoutes, withComponentInputBinding()),
providePidrang({
dataProvider: provideSupabaseDataProvider({ client }),
authProvider: provideSupabaseAuthProvider({ client }),
resources,
}),
],
};
provideSupabaseDataProvider returns a plain object implementing a five-method contract: getList, getOne, create, update, delete. providePidrang is the only place that touches Angular DI. withComponentInputBinding() carries the :resource segment of the next step to the page’s input; without it the page renders blank. Use the publishable key, never the secret key: the app runs in the browser.
4. One list page for every table
This is where resource() earns its place. injectResourceList wraps DataProvider.getList in Angular’s resource(), so value(), isLoading(), error() and reload() are signals. The resource name can itself be a function, so when the route parameter changes, the list reloads.
import { Component, computed, input } from '@angular/core';
import { injectResourceList, injectResources } from '@pidrang/core';
import { ResourceTable } from '@pidrang/material';
type Row = { id: string } & Record<string, unknown>;
@Component({
selector: 'app-resource-list-page',
imports: [ResourceTable],
template: `
<h1>{{ resource() }}</h1>
@if (list.isLoading()) {
<p>Loading...</p>
}
@if (list.error(); as error) {
<p>{{ error.message }}</p>
}
<pidrang-material-resource-table [fields]="fields()" [data]="list.value()?.data ?? []" />
`,
})
export class ResourceListPage {
readonly resource = input.required<string>();
private readonly resources = injectResources();
protected readonly list = injectResourceList<Row>(() => this.resource());
protected readonly fields = computed(() => this.resources.find((resource) => resource.name === this.resource())?.fields?.filter((field) => !field.hidden) ?? []);
}
Route it with a :resource segment:
import { Route } from '@angular/router';
import { ResourceListPage } from './resource-list-page';
export const appRoutes: Route[] = [{ path: ':resource', component: ResourceListPage }];
Open /products: one column per field. Add a second Resource and /orders works with the same component.
Paging, sorting and filtering are a second, reactive argument. Return undefined from it to skip loading.
injectResourceList<Row>(
() => this.resource(),
() => ({
pagination: { current: this.page(), pageSize: 20 },
sorters: [{ field: 'created_at', order: 'desc' }],
filters: [
{ field: 'in_stock', operator: 'eq', value: true },
{ field: 'name', operator: 'contains', value: 'mouse' },
],
}),
);
Filters are a typed union, one member per operator, so a value that does not fit its operator does not compile.
5. Row actions
ResourceTable projects an #actions template as a trailing column. Add RouterLink to the page’s imports, and injectDataProvider from @pidrang/core and signal from @angular/core to its imports:
<pidrang-material-resource-table [fields]="fields()" [data]="list.value()?.data ?? []">
<ng-template #actions let-row>
<a [routerLink]="['/', resource(), row.id, 'edit']">Edit</a>
<button type="button" (click)="remove(row.id)">Delete</button>
</ng-template>
</pidrang-material-resource-table>
private readonly dataProvider = injectDataProvider();
protected readonly deleteError = signal<string | null>(null);
protected async remove(id: string): Promise<void> {
if (!confirm('Delete this record?')) {
return;
}
try {
await this.dataProvider.delete(this.resource(), { id });
this.deleteError.set(null);
this.list.reload();
} catch (error) {
this.deleteError.set(error instanceof Error ? error.message : String(error));
}
}
Errors thrown by the Supabase provider are PidrangErrors with a category (unauthenticated, forbidden, not-found, unknown), so the page can ask the user to sign in instead of showing a raw PostgREST message.
6. A form that sends only what changed
ResourceForm is built on Angular Signal Forms. It never mutates the value you pass in, and its save output only contains the fields the user touched, so the database default or NULL applies to everything else.
<pidrang-material-resource-form mode="create" [fields]="fields()" [value]="initialValue()" (save)="submit($event)" />
On edit, save does not emit at all when nothing changed, since there is nothing to PATCH.
Authorization stays in the database
Pidrang performs no authorization. With Supabase, the Row Level Security policies on your tables decide who reads and writes. Enable RLS on every exposed table before deploying: the publishable key ships to every browser.
Skip the typing: generate it
The same resources.ts and generic list, create, show and edit pages can be generated from your schema:
npx pidrang init
It reads your Supabase schema and asks which tables and actions you want. It offers to install the packages, registers Pidrang in app.config.ts, adds the routes to app.routes.ts, writes the environment files when the app has none, and generates a sign-in page. It never overwrites a file: if one already exists with other content, it stops before writing anything and lists the paths.
Wrap-up
resource()plus a reactive resource name gives one list component for every table.- A typed field list drives both the table and the form.
- The data layer is a five-method contract, so Supabase is a choice, not a requirement.
Try it with the getting started guide, or look at the repository. Feedback and issues are welcome.
Comments
Comments come from the discussion on GitHub.