@abolfazl-khalaj/permission-react
v0.2.1
Published
TypeScript-first React authorization primitives for permission-based UI access control
Maintainers
Readme
@abolfazl-khalaj/permission-react
Enterprise permission & authorization primitives for React applications.
TypeScript-first access control for buttons, menus, pages, and features — without scattering user.permissions.includes(...) across your codebase.
npm install @abolfazl-khalaj/permission-react| | |
| --- | --- |
| Package | @abolfazl-khalaj/permission-react |
| Peers | React ^18 \|\| ^19 |
| License | MIT |
| Output | Tree-shakeable ESM + CJS (sideEffects: false) |
Contents
- Introduction
- Features
- Installation
- Quick Start
- PermissionProvider
- Subject Model
- usePermission
- Can Component
- Route Protection
- Enterprise Example
- Architecture
- Best Practices
- Security & Limitations
- Migration Guide
- Troubleshooting
- API Reference
- Roadmap
1. Introduction
What is this package?
@abolfazl-khalaj/permission-react is a React adapter around a framework-agnostic permission engine (living under src/core).
You provide a subject (the current user: id, roles, permissions) and get:
| Capability | API |
| --- | --- |
| Imperative checks | can, hasRole, … |
| Declarative UI gates | <Can /> |
| Route-level gates | <PermissionRoute /> |
| Async backend loading | permissionLoader, resolver |
| Structured debugging | explain, debug |
Who is it for?
- Frontend teams building dashboards, admin panels, and SaaS products
- Apps where authorization rules come from a backend (JWT, session, API)
- Codebases that need typed, testable, centralized permission logic
What problem does it solve?
Without a library, authorization usually becomes:
if (user.permissions.includes('user.delete')) {
// show delete button
}That pattern does not scale:
- duplicated checks across components
- no shared wildcard / role inheritance rules
- hard to show loading / denied / disabled UI consistently
- easy to flash unauthorized UI before permissions load
- difficult to explain why a check failed
This package centralizes those decisions behind one provider and one engine.
Authentication vs authorization
| Concept | Question | Typical source | | --- | --- | --- | | Authentication | Who is this user? | Login, JWT, session cookie | | Authorization | What may this user do? | Roles, permissions, policies |
This library is an authorization layer. It assumes you already know who the user is.
RBAC, permissions, and ABAC
| Model | Idea | Status in this package |
| --- | --- | --- |
| RBAC | Access via roles (admin, editor) | Supported (hasRole, role inheritance) |
| Permission-based | Access via fine-grained strings (invoice.approve) | Primary model (can, wildcards) |
| ABAC | Access via attributes/conditions (ownerId === user.id) | Types prepared; evaluation not implemented yet |
Most production UIs start with permissions + roles. Attribute-based rules are a later evolution — the public types (PermissionRule, PermissionCondition, PermissionPolicy, PermissionQuery) exist so that path does not require an API rewrite.
2. Features
Implemented
| Feature | Description |
| --- | --- |
| PermissionProvider | Central authorization context and engine lifecycle |
| Subject model | id, roles, permissions, attributes |
| Permission engine | Framework-agnostic evaluation in src/core |
| can / cannot / not | Sync permission checks |
| canAny / canAll / hasNone | Multi-permission operators |
| Role helpers | hasRole, hasAnyRole, hasAllRoles, hasInheritedRole |
| check / explain | Structured results and decision reasons |
| Async API | canAsync, status, loading, error, reload / cache clear |
| <Can /> | Declarative UI with fallback, loading, errorFallback |
| Modes | hidden | disable | readonly |
| <PermissionRoute /> | Page gates with loading UI and optional redirect |
| Wildcards | e.g. users.* |
| Role hierarchy | Via roleDefinitions |
| Custom matcher | permissionMatcher override |
| Loaders & resolvers | permissionLoader, remote resolver, cacheTime |
| Debug | Provider debug logging |
| TypeScript | Strict public types |
| React 18 / 19 | Peer support |
| Tree-shaking | ESM + CJS, sideEffects: false |
Roadmap
| Item | Notes |
| --- | --- |
| Full ABAC evaluation | Types exist today |
| Policy / condition runtime | Planned |
| @abolfazl-khalaj/permission-core | Extract src/core for publish |
| Next.js / Vue / Angular adapters | Planned |
| SSR-safe navigation adapters | Beyond window.location |
3. Installation
Choose your package manager:
npm install @abolfazl-khalaj/permission-reactpnpm add @abolfazl-khalaj/permission-reactyarn add @abolfazl-khalaj/permission-reactRequirement: React
^18 || ^19.
4. Quick Start
Three steps to wire authorization into an app.
Step 1 — Create a subject (your user)
const user = {
id: '1',
roles: ['admin'],
permissions: ['user.create', 'user.delete'],
};This object is the authorization subject: the identity the engine evaluates against.
Step 2 — Wrap your app
import { PermissionProvider } from '@abolfazl-khalaj/permission-react';
export function Root() {
return (
<PermissionProvider subject={user}>
<App />
</PermissionProvider>
);
}PermissionProvider normalizes the subject, builds the engine/store, and exposes it through React context.
Step 3 — Check a permission
import { usePermission } from '@abolfazl-khalaj/permission-react';
function CreateUserButton() {
const { can } = usePermission();
if (!can('user.create')) {
return null;
}
return <button type="button">Create user</button>;
}| Line | Behavior |
| --- | --- |
| usePermission() | Reads the nearest provider context (throws if missing) |
| can('user.create') | Asks the engine whether the subject holds that permission |
| Conditional render | You control UI from the boolean result |
Declarative alternative:
import { Can } from '@abolfazl-khalaj/permission-react';
<Can permission="user.create">
<button type="button">Create user</button>
</Can>Expected: If the subject lacks
user.createand mode ishidden(default), the button is not rendered.
5. PermissionProvider
Purpose
PermissionProvider is the root of the authorization tree.
- Accepts a subject (and optional async sources)
- Creates a framework-agnostic permission store/engine
- Publishes a stable API through context
- Keeps React components thin (no business rules in UI)
Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| subject | Subject | {} | Current user snapshot |
| permissions | string[] | — | Legacy shortcut; merged into subject permissions |
| defaultMode | 'hidden' \| 'disable' \| 'readonly' | 'hidden' | Default denied behavior for <Can /> |
| roleDefinitions | RoleDefinition[] | — | Role inheritance + role-granted permissions |
| permissionMatcher | (required, available) => boolean | wildcard matcher | Custom matching strategy |
| permissionLoader | (subject) => Promise<PermissionResponse> | — | Load permissions/roles from backend |
| resolver | (permission, subject) => Promise<boolean> | — | Per-permission remote check |
| cacheTime | number (ms) | 0 | Cache TTL for loader/resolver results |
| debug | boolean | false | Log permission decisions to the console |
| children | ReactNode | — | App tree |
Static example
<PermissionProvider
subject={{
id: '42',
roles: ['editor'],
permissions: ['posts.read', 'posts.write'],
}}
defaultMode="hidden"
>
<App />
</PermissionProvider>Legacy permissions prop
Still supported for simple apps:
<PermissionProvider permissions={['posts.read', 'posts.write']}>
<App />
</PermissionProvider>Prefer subject for production systems — it carries id, roles, and attributes needed for caching and future ABAC.
Async backend loading
<PermissionProvider
subject={{ id: currentUser.id }}
permissionLoader={async (subject) => {
const data = await api.getPermissions(subject.id);
return {
permissions: data.permissions,
roles: data.roles,
};
}}
cacheTime={5 * 60 * 1000}
>
<App />
</PermissionProvider>While loading,
usePermission().statusis'loading'(or'idle'before the first load starts). Use<Can loading={...} />to avoid unauthorized flashes.
Remote resolver
Use when some checks must hit the server even if local grants miss:
<PermissionProvider
subject={user}
permissions={user.permissions}
resolver={async (permission, subject) =>
api.checkPermission(subject.id, permission)
}
cacheTime={60_000}
>
<App />
</PermissionProvider>Resolution order for canAsync(permission):
- Local engine allow (if already resolved)
- Cached resolver result
- In-flight dedupe
- Call
resolver
6. Subject Model
Shape
type Subject = {
id?: string | number;
roles?: readonly string[];
permissions?: readonly string[];
attributes?: Readonly<Record<string, unknown>>;
};Why a subject exists
Authorization is always evaluated against someone.
Bundling identity fields into one object:
- matches how backends usually return users
- gives the cache a stable key (
id) - keeps roles and permissions together
- leaves room for attributes (department, tenant, plan) without changing the React API
Why this is future-proof
Today the engine primarily uses permissions and roles.attributes is already on the subject so ABAC conditions can attach later without renaming the model.
The core layer (src/core) does not import React. The same subject/engine design is intended to move into @abolfazl-khalaj/permission-core and be reused by Vue/Next adapters.
Mapping a backend user to Subject
Backend response:
{
"id": 10,
"role": "admin",
"permissions": ["users.*", "billing.view"],
"meta": { "department": "finance" }
}Transform:
import type { Subject } from '@abolfazl-khalaj/permission-react';
const subject: Subject = {
id: response.id,
roles: [response.role], // normalize singular → array
permissions: response.permissions,
attributes: response.meta,
};Role definitions (inheritance)
<PermissionProvider
subject={{ id: '1', roles: ['admin'], permissions: [] }}
roleDefinitions={[
{ name: 'manager', permissions: ['reports.view'] },
{
name: 'admin',
inherits: ['manager'],
permissions: ['users.*'],
},
]}
>
<App />
</PermissionProvider>| Check | Result | Why |
| --- | --- | --- |
| hasRole('admin') | true | Direct |
| hasRole('manager') | true | Inherited |
| hasInheritedRole('manager') | true | Via inheritance only |
| can('reports.view') | true | From manager role permissions |
| can('users.create') | true | Wildcard from admin |
7. usePermission
Imperative authorization API. Must be called under PermissionProvider.
const {
// subject snapshot
subject,
permissions,
roles,
// sync checks
can,
cannot,
not,
canAny,
canAll,
hasNone,
check,
explain,
// roles
hasRole,
hasAnyRole,
hasAllRoles,
hasInheritedRole,
// async / lifecycle
canAsync,
status,
loading,
error,
reloadPermissions,
clearPermissionCache,
} = usePermission();can(permission, strategy?)
Returns whether the subject is allowed the permission(s).
can('user.create');
can(['user.create', 'user.delete'], 'all'); // every permission
can(['user.create', 'user.delete'], 'any'); // at least one| Parameter | Type | Default | Meaning |
| --- | --- | --- | --- |
| permission | string \| string[] | — | Required permission(s) |
| strategy | 'all' \| 'any' | 'all' | How arrays are evaluated |
Returns: boolean
Use case: Show/hide a feature in imperative code.
Wildcards are supported by default:
// subject.permissions = ['users.*']
can('users.create'); // → true
can('users.delete'); // → true
can('billing.edit'); // → falsecannot / not
Inverse of can.
if (cannot('billing.edit')) {
showUpgradeBanner();
}
if (not('billing.edit')) {
showUpgradeBanner();
}not is an engine-oriented alias of cannot.
canAny / canAll / hasNone
canAny(['user.create', 'user.update']); // at least one
canAll(['user.create', 'user.delete']); // every
hasNone(['billing.edit', 'billing.delete']); // none grantedcheck(permission, strategy?)
Returns a structured sync result:
const result = check(['user.create', 'user.delete']);
// {
// allowed: false,
// missing: ['user.delete']
// }explain(permission)
Returns a decision object (useful for debugging):
explain('users.create');
// {
// allowed: true,
// permission: 'users.create',
// reason: 'permission_match',
// source: 'users.*'
// }
explain('billing.edit');
// {
// allowed: false,
// permission: 'billing.edit',
// reason: 'permission_missing'
// }Reasons include: permission_match, permission_missing, role_missing, condition_failed, denied.
Enable console logging with debug on the provider.
Role helpers
hasRole('admin');
hasAnyRole(['admin', 'editor']);
hasAllRoles(['editor', 'member']);
hasInheritedRole('manager'); // true only via inheritance, not direct assignmentAsync lifecycle
status; // 'idle' | 'loading' | 'resolved' | 'error'
loading; // boolean shortcut for status === 'loading'
error; // Error | null
await canAsync('invoice.delete');
await reloadPermissions();
clearPermissionCache();| API | Behavior |
| --- | --- |
| canAsync | Local allow first; otherwise resolver/loader path with cache |
| reloadPermissions | Invalidate cache for subject and reload via permissionLoader |
| clearPermissionCache | Drop cached loader/resolver entries for current subject |
8. Can Component
Declarative authorization keeps JSX readable and avoids repeating hooks in every leaf component.
Basic usage
<Can permission="user.delete">
<button type="button">Delete</button>
</Can>Expected: If denied and mode is
hidden(default), children are not rendered.
Why a component?
- Colocates authorization with UI
- Standardizes denied / loading / error presentation
- Works well in design systems (shared fallbacks)
- Supports render props for advanced UI states
fallback
<Can permission="user.delete" fallback={<NoAccess />}>
<DeleteButton />
</Can>Rendered when the check fails and mode is hidden.
loading / errorFallback
<Can
permission="user.delete"
loading={<Skeleton />}
errorFallback={<PermissionError />}
fallback={<NoAccess />}
>
<DeleteButton />
</Can>| State | Rendered |
| --- | --- |
| provider loading / idle | loading (or null if omitted — avoids unauthorized flash) |
| provider error | errorFallback (falls back to fallback) |
| allowed | children |
| denied + hidden | fallback |
| denied + disable / readonly | hardened children |
mode
{/* Hidden (default): remove from UI */}
<Can permission="invoice.edit" mode="hidden" fallback={<Locked />}>
<EditInvoice />
</Can>
{/* Disable: visible but non-interactive */}
<Can permission="invoice.edit" mode="disable">
<button type="button">Edit</button>
</Can>
{/* Readonly: visible, read-only semantics (aria + blocked submit/click) */}
<Can permission="invoice.edit" mode="readonly">
<InvoiceForm />
</Can>| Mode | User experience |
| --- | --- |
| hidden | Element is not shown |
| disable | Element is shown but interaction is blocked (disabled / aria-disabled) |
| readonly | Element is shown as read-only (aria-readonly, blocked submit/click) |
defaultMode on the provider sets the default when mode is omitted.
Render functions
<Can
permission="user.create"
fallback={({ allowed, mode }) => <NoAccess mode={mode} />}
>
{({ allowed }) => (allowed ? <CreateButton /> : null)}
</Can>Strategy for multiple permissions
<Can permission={['user.create', 'user.invite']} strategy="any">
<InvitePanel />
</Can>9. Route Protection
PermissionRoute gates page-level content. It is router-agnostic (works with React Router, Next.js pages, or plain composition).
<PermissionRoute
permission="dashboard.view"
loading={<PageLoader />}
fallback={<Forbidden />}
redirect="/403"
>
<DashboardPage />
</PermissionRoute>Props
| Prop | Description |
| --- | --- |
| permission | Required permission(s) |
| strategy | 'all' (default) or 'any' for arrays |
| loading | Shown while provider/async check is pending |
| fallback | Shown when unauthorized |
| redirect / redirectTo | Optional browser redirect target when denied |
Behavior
- While permissions are loading → render
loading - If sync
can(...)allows → render children - Otherwise try
canAsync(...)(resolver / delayed grants) - If still denied → render
fallback, and ifredirect/redirectTois set, navigate viawindow.location.assign
Admin section example:
<PermissionRoute permission="admin.access" redirect="/unauthorized">
<AdminLayout />
</PermissionRoute>10. Real Enterprise Example
Imagine a SaaS dashboard with permissions:
| Permission | Typical use |
| --- | --- |
| dashboard.view | Open the main dashboard |
| user.create | Create users / users nav |
| user.delete | Destructive user actions |
| invoice.approve | Finance workflow |
import {
PermissionProvider,
PermissionRoute,
Can,
usePermission,
} from '@abolfazl-khalaj/permission-react';
const subject = {
id: session.user.id,
roles: session.user.roles,
permissions: session.user.permissions,
};
export function AppShell() {
return (
<PermissionProvider
subject={subject}
roleDefinitions={ROLE_CATALOG}
defaultMode="hidden"
debug={import.meta.env.DEV}
>
<Shell />
</PermissionProvider>
);
}
function Shell() {
const { can, hasRole } = usePermission();
return (
<div className="layout">
<aside>
{/* Sidebar visibility */}
<Can permission="dashboard.view">
<NavLink to="/dashboard">Dashboard</NavLink>
</Can>
<Can permission="user.create">
<NavLink to="/users">Users</NavLink>
</Can>
{hasRole('finance') ? (
<NavLink to="/invoices">Invoices</NavLink>
) : null}
</aside>
<main>
{/* Route protection */}
<PermissionRoute permission="dashboard.view" fallback={<NoAccess />}>
<DashboardPage />
</PermissionRoute>
</main>
</div>
);
}
function UsersToolbar() {
return (
<div>
{/* Button protection */}
<Can permission="user.create">
<button type="button">Create user</button>
</Can>
<Can permission="user.delete" mode="disable" fallback={null}>
<button type="button">Delete user</button>
</Can>
</div>
);
}
function InvoiceActions({ invoice }: { invoice: { id: string } }) {
const { can } = usePermission();
return (
<Can permission="invoice.approve" fallback={<span>Awaiting permission</span>}>
<button type="button" onClick={() => approve(invoice.id)}>
Approve
</button>
{/* Feature visibility */}
{can('invoice.approve') ? <AuditHint /> : null}
</Can>
);
}This pattern keeps:
| Concern | Approach |
| --- | --- |
| Sidebar | <Can /> / hasRole |
| Buttons | Declarative <Can /> |
| Routes | <PermissionRoute /> |
| Business checks | Out of random utilities |
11. Architecture
High-level data flow:
┌─────────────────────────────────────────────┐
│ Application UI │
│ (pages, buttons, menus) │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ PermissionProvider │
│ React adapter · context · lifecycle │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Permission Store / Engine │
│ (src/core · no React) │
│ │
│ ┌───────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Matchers │ │ Roles │ │ Decisions│ │
│ │ exact + * │ │ inherit │ │ explain │ │
│ └───────────┘ └──────────┘ └─────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Async: loader · resolver · cache │ │
│ └─────────────────────────────────────┘ │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Permission Decision │
│ can() · <Can /> · <PermissionRoute /> │
└─────────────────────────────────────────────┘Why the engine is separated
- UI libraries should not own authorization rules
- The same core can power Vue / Next / Angular adapters later
- Unit tests can target pure functions without rendering React
- Bundle stays focused: React layer is a thin wrapper
Multi-framework direction
@abolfazl-khalaj/permission-core ← planned extraction of src/core
▲
│
@abolfazl-khalaj/permission-react ← this package (ships today)
@abolfazl-khalaj/permission-vue ← planned
@abolfazl-khalaj/permission-next ← plannedToday, core code ships inside the React package but lives in an isolated src/core tree with zero React imports.
12. Best Practices
Do
| Practice | Why |
| --- | --- |
| Load permissions from backend / token into a Subject | Single source of truth |
| Use namespaced strings (invoice.approve, users.*) | Readable, scalable catalogs |
| Put PermissionProvider near the top of the authenticated tree | One context for the app |
| Prefer <Can /> for UI, can() for logic | Clear separation |
| Use loading UI with permissionLoader | Prevents unauthorized flashes |
| Use explain / debug while developing rules | Faster diagnosis |
| Version roleDefinitions with your backend | Avoid drift |
Don’t
| Anti-pattern | Why |
| --- | --- |
| Mix auth redirects with authz in random components | Hard to reason about |
| Hardcode the same permission in 20 files | Drift and typos |
| Re-implement matching outside the engine | Divergent rules |
| Assume permissions are ready before status === 'resolved' | Race conditions |
| Depend on internal src/core paths | Not a public contract |
Suggested constants module:
export const Permissions = {
UserCreate: 'user.create',
UserDelete: 'user.delete',
InvoiceApprove: 'invoice.approve',
} as const;13. Security & Limitations
This package is for client-side / UI authorization only.
It does not replace backend authorization, API authorization, or server-side policy enforcement.
Treat every permission check in the browser as a UX and presentation control:
| Layer | Responsibility | | --- | --- | | This library | Hide, disable, or gate UI based on known grants | | Your backend | Enforce the same rules on every sensitive operation |
Security principles baked into the engine
| Principle | Behavior |
| --- | --- |
| Fail closed | Unknown / missing permission → deny |
| Empty requirements | "" never authorizes |
| Loading | While status is idle / loading, <Can /> does not render privileged children |
| Subject input | Caller mutations of role/permission arrays do not affect a normalized snapshot |
What this library will not do
- Encrypt or validate tokens
- Prevent forged client permission arrays
- Guarantee that a user cannot call your API without a grant
- Replace CSRF, authn, rate limiting, or tenancy checks
Always re-check authorization on the server.
14. Migration Guide
Before
function DeleteButton({ user }: { user: { permissions: string[] } }) {
if (!user.permissions.includes('user.delete')) {
return null;
}
return <button type="button">Delete</button>;
}Problems: prop drilling, duplicated strings, no loading/disabled modes, no wildcards/roles.
After
// root
<PermissionProvider subject={mapUserToSubject(user)}>
<App />
</PermissionProvider>
// leaf
<Can permission="user.delete">
<button type="button">Delete</button>
</Can>Or imperative:
const { can } = usePermission();
return can('user.delete') ? <DeleteButton /> : null;Migration steps:
- Map your auth user →
Subject - Wrap the authenticated tree with
PermissionProvider - Replace
includeschecks withcan/<Can /> - Optionally introduce
permissionLoaderif permissions arrive asynchronously
15. Troubleshooting
usePermission must be used within a PermissionProvider
Cause: Hook/component rendered outside the provider.
Fix: Wrap the authenticated subtree (or the whole app after login) with PermissionProvider.
Permission always denied
Checklist:
- Is
statusstill'loading'? Wait or showloadingUI. - Does the subject actually include the permission (or a matching wildcard)?
- Are permissions coming from
permissionLoaderbut the loader failed? Checkerror. - Did you pass
permissions={...}and an emptysubject.permissionsunexpectedly? - For roles: did you configure
roleDefinitionsif you rely on inheritance?
console.log(usePermission().subject);
console.log(usePermission().explain('user.delete'));Wrong subject shape
roles and permissions must be arrays. If the backend sends role: "admin", convert it:
roles: Array.isArray(user.roles) ? user.roles : [user.role]TypeScript: permission strings
Permission type is string. For stricter apps, wrap your own union:
type AppPermission = 'user.create' | 'user.delete' | 'invoice.approve';
can('user.create' as AppPermission);Redirect does nothing in tests / SSR
PermissionRoute uses window.location.assign when redirect / redirectTo is set. In non-browser environments, provide fallback UI instead of relying on navigation.
16. API Reference
Runtime exports
| API | Kind | Purpose | Example |
| --- | --- | --- | --- |
| PermissionProvider | Component | Provide subject + engine/store | <PermissionProvider subject={user}> |
| usePermission | Hook | Imperative authorization API | const { can } = usePermission() |
| Can | Component | Declarative UI gate | <Can permission="user.delete"> |
| PermissionRoute | Component | Page/section gate | <PermissionRoute permission="admin.access"> |
Common types
| Type | Purpose |
| --- | --- |
| Subject | User snapshot for evaluation |
| Permission / PermissionList / PermissionInput | Permission identifiers |
| PermissionMode | hidden | disable | readonly |
| PermissionStatus | idle | loading | resolved | error |
| RoleDefinition | Role inheritance + role permissions |
| PermissionDecision | Result of explain() |
| PermissionLoader / PermissionResolver / PermissionResponse | Async integration |
| PermissionRuntimeApi | Return type of usePermission() |
| CanProps / PermissionProviderProps / PermissionRouteProps | Component props |
| PermissionRule / PermissionCondition / PermissionPolicy / PermissionQuery | Future ABAC surface (types only) |
Internal modules under
src/coreare not public API.
17. Roadmap
| Phase | Focus | Status |
| --- | --- | --- |
| 1 | React foundation: Provider, hooks, Can, routes, subject, engine | Done |
| 2 | Advanced rules: wildcards, role hierarchy, explain/debug, operators | Done |
| 3 | Async system: loader, resolver, cache, loading/error UI | Done |
| 4 | ABAC evaluation + policy engine | Planned |
| 5 | Extract @abolfazl-khalaj/permission-core | Planned |
| 6 | Framework adapters (Next / Vue / Angular) | Planned |
License
MIT © abolfazl-khalaj
