A modular, framework-agnostic SDK for building applications with JSON:API backends.
- Overview
- Design Principles
- Package Structure
- Core Packages
- Block Packages
- Framework Adapters
- Data Flow
- JSON:API Conventions
- Block Independence
- Migration Strategy
- Provider Compatibility
The 23blocks SDK is a modern TypeScript SDK designed to provide a clean, type-safe interface for applications consuming JSON:API backends. It follows a block-based architecture where each domain (auth, CRM, content, etc.) is an independent, composable unit.
- Framework Agnostic - Core SDK works with any JavaScript runtime
- Type Safety - Full TypeScript support with typed domain objects
- Block Independence - Each block can be used standalone or composed
- Provider Swappable - Switch backends without changing application code
- Modern Standards - ES2022+, Tree-shakeable, ESM-first
┌─────────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ Uses: User, Post, Contact (typed objects) │
└─────────────────────────────────────────────────────────────┘
▲
│ Typed Objects Only
│
┌─────────────────────────────────────────────────────────────┐
│ 23blocks SDK │
│ │
│ Transforms JSON:API ←→ Typed Objects │
└─────────────────────────────────────────────────────────────┘
▲
│ JSON:API Wire Format
│
┌─────────────────────────────────────────────────────────────┐
│ Backend APIs │
│ │
│ 23blocks Rails APIs (JSON:API v1.0) │
└─────────────────────────────────────────────────────────────┘
Rule: Application code NEVER sees JSON:API structures. Only typed domain objects.
The core SDK uses Promises for all async operations:
// Core block - returns Promise
const user = await gateway.auth.signIn({ email, password });
// Angular adapter - converts to Observable
gatewayService.signIn({ email, password }).subscribe(user => ...);
// React hook - manages Promise lifecycle
const { data: user, loading } = useSignIn({ email, password });❌ Core packages NEVER import:
- @angular/*
- react / react-dom
- rxjs (in core)
- @ngrx/*
- redux
✅ Core packages ONLY use:
- Native Promises
- Standard TypeScript
- @23blocks/* packages
// The SDK doesn't know HOW requests are made
interface Transport {
request<T>(config: RequestConfig): Promise<T>;
}
// Implementations can vary
const httpTransport = createHttpTransport({ baseUrl, headers });
const mockTransport = createMockTransport(fixtures);
const graphqlTransport = createGraphQLTransport({ endpoint });@23blocks/
├── contracts # Shared types, interfaces, errors
├── jsonapi-codec # JSON:API encoding/decoding
├── transport-http # HTTP transport implementation
│
├── block-gateway # Auth, users, roles, companies
├── block-content # Posts, comments, tags, categories
├── block-crm # Contacts, accounts, opportunities
├── block-forms # Forms, submissions, surveys
├── block-products # Products, catalogs, inventory
├── block-search # Entity search, indexing
├── block-files # File uploads, attachments
├── block-rewards # Loyalty, badges, coupons
├── block-assets # Asset management
├── block-wallet # Digital wallet, transactions
├── block-jarvis # AI/RAG services
│
├── angular # Angular adapters (Observable-based)
├── react # React adapters (hooks-based)
│
└── sdk # Unified SDK entry point
The smallest, most stable package. Contains shared types used across all packages.
// Error handling
export interface BlockError {
code: string;
message: string;
status: number;
source?: string;
meta?: Record<string, unknown>;
}
// Pagination
export interface PageMeta {
currentPage: number;
totalPages: number;
totalCount: number;
perPage: number;
}
export interface PageResult<T> {
data: T[];
meta: PageMeta;
}
// Base identity (optional)
export interface IdentityCore {
id: string;
uniqueId: string;
createdAt: Date;
updatedAt: Date;
}Rules:
- This package should RARELY change
- No JSON:API types here
- No implementation, only interfaces
The heart of the system. Handles all JSON:API serialization/deserialization.
// Wire types (internal)
interface JsonApiDocument<T = unknown> {
data: JsonApiResource<T> | JsonApiResource<T>[];
included?: JsonApiResource[];
meta?: JsonApiMeta;
errors?: JsonApiError[];
}
// Decoding
function decodeOne<T>(doc: JsonApiDocument, mapper: ResourceMapper<T>): T;
function decodeMany<T>(doc: JsonApiDocument, mapper: ResourceMapper<T>): T[];
function decodePageResult<T>(doc: JsonApiDocument, mapper: ResourceMapper<T>): PageResult<T>;
// Error mapping
function blockErrorFromJsonApi(errors: JsonApiError[]): BlockError;
// Resource mappers (per block)
interface ResourceMapper<T> {
type: string;
map(resource: JsonApiResource, included: Map<string, JsonApiResource>): T;
}Rules:
- ALL JSON:API knowledge lives here
- No HTTP, no blocks, no Angular/React
- Provides base utilities, blocks provide mappers
Minimal HTTP transport layer.
interface TransportConfig {
baseUrl: string;
headers?: HeadersProvider;
timeout?: number;
interceptors?: Interceptor[];
}
interface HeadersProvider {
(): Promise<Record<string, string>> | Record<string, string>;
}
function createHttpTransport(config: TransportConfig): Transport;
// Transport interface
interface Transport {
get<T>(path: string, options?: RequestOptions): Promise<T>;
post<T>(path: string, body: unknown, options?: RequestOptions): Promise<T>;
patch<T>(path: string, body: unknown, options?: RequestOptions): Promise<T>;
delete<T>(path: string, options?: RequestOptions): Promise<T>;
}Rules:
- Uses native
fetchor configurable client - Handles baseUrl, headers, errors
- Maps non-2xx to BlockError
- NO knowledge of blocks or models
Each block follows the same structure:
packages/block-gateway/
├── src/
│ ├── index.ts # Public exports
│ ├── gateway.block.ts # Block factory
│ ├── types/ # Domain types
│ │ ├── user.ts
│ │ ├── company.ts
│ │ └── auth.ts
│ ├── mappers/ # JSON:API → Domain mappers
│ │ ├── user.mapper.ts
│ │ └── company.mapper.ts
│ └── services/ # Domain operations
│ ├── auth.service.ts
│ └── users.service.ts
├── package.json
└── tsconfig.json
// packages/block-gateway/src/gateway.block.ts
import { Transport } from '@23blocks/transport-http';
import { createAuthService } from './services/auth.service';
import { createUsersService } from './services/users.service';
export interface GatewayBlockConfig {
apiKey: string;
tenantId?: string;
}
export function createGatewayBlock(transport: Transport, config: GatewayBlockConfig) {
return {
auth: createAuthService(transport, config),
users: createUsersService(transport, config),
roles: createRolesService(transport, config),
companies: createCompaniesService(transport, config),
};
}
export type GatewayBlock = ReturnType<typeof createGatewayBlock>;// packages/block-gateway/src/services/auth.service.ts
import { Transport } from '@23blocks/transport-http';
import { decodeOne } from '@23blocks/jsonapi-codec';
import { User, SignInRequest, SignInResponse } from '../types';
import { userMapper } from '../mappers/user.mapper';
export function createAuthService(transport: Transport, config: GatewayBlockConfig) {
return {
async signIn(request: SignInRequest): Promise<SignInResponse> {
const response = await transport.post('/auth/sign_in', request);
return {
user: decodeOne(response, userMapper),
token: response.meta?.token,
};
},
async signUp(request: SignUpRequest): Promise<User> {
const response = await transport.post('/auth', request);
return decodeOne(response, userMapper);
},
async signOut(): Promise<void> {
await transport.delete('/auth/sign_out');
},
async getCurrentUser(): Promise<User> {
const response = await transport.get('/auth/validate_token');
return decodeOne(response, userMapper);
},
};
}// packages/block-gateway/src/mappers/user.mapper.ts
import { ResourceMapper, JsonApiResource } from '@23blocks/jsonapi-codec';
import { User } from '../types';
export const userMapper: ResourceMapper<User> = {
type: 'User',
map(resource: JsonApiResource, included: Map<string, JsonApiResource>): User {
const attrs = resource.attributes;
return {
id: resource.id,
email: attrs.email,
name: attrs.name,
username: attrs.username,
uniqueId: attrs.unique_id,
status: attrs.status,
roleId: attrs.role_id,
lastSignInAt: attrs.last_sign_in_at ? new Date(attrs.last_sign_in_at) : null,
confirmedAt: attrs.confirmed_at ? new Date(attrs.confirmed_at) : null,
createdAt: new Date(attrs.created_at),
updatedAt: new Date(attrs.updated_at),
// Relationships (resolved from included)
role: resolveRelationship(resource, 'role', included, roleMapper),
avatar: resolveRelationship(resource, 'user_avatar', included, avatarMapper),
profile: resolveRelationship(resource, 'user_profile', included, profileMapper),
};
},
};Provides Angular-specific integration:
// Angular module with DI
@NgModule({
providers: [
{
provide: GATEWAY_BLOCK,
useFactory: (http: HttpClient) => {
const transport = createAngularTransport(http);
return createGatewayBlock(transport, config);
},
deps: [HttpClient],
},
],
})
export class GatewayModule {}
// Service wrapper (Observable-based)
@Injectable({ providedIn: 'root' })
export class GatewayService {
constructor(@Inject(GATEWAY_BLOCK) private block: GatewayBlock) {}
signIn(request: SignInRequest): Observable<SignInResponse> {
return from(this.block.auth.signIn(request));
}
// ... other methods
}Provides React-specific integration:
// Context provider
export function GatewayProvider({ children, config }: GatewayProviderProps) {
const block = useMemo(() => {
const transport = createHttpTransport(config.transport);
return createGatewayBlock(transport, config.block);
}, [config]);
return (
<GatewayContext.Provider value={block}>
{children}
</GatewayContext.Provider>
);
}
// Hooks
export function useSignIn() {
const block = useGatewayBlock();
return useMutation({
mutationFn: (request: SignInRequest) => block.auth.signIn(request),
});
}
export function useCurrentUser() {
const block = useGatewayBlock();
return useQuery({
queryKey: ['currentUser'],
queryFn: () => block.auth.getCurrentUser(),
});
}┌──────────────────────────────────────────────────────────────────┐
│ Application │
│ │
│ const { data: user } = useSignIn({ email, password }); │
│ ▲ │
│ │ User (typed object) │
└──────────────────────────┼───────────────────────────────────────┘
│
┌──────────────────────────┼───────────────────────────────────────┐
│ Framework Adapter │
│ │
│ Observable<User> / useQuery<User> │
│ ▲ │
│ │ Promise<User> │
└──────────────────────────┼───────────────────────────────────────┘
│
┌──────────────────────────┼───────────────────────────────────────┐
│ Block Service │
│ │
│ signIn(request) → decodeOne(response, userMapper) │
│ ▲ │
│ │ JsonApiDocument │
└──────────────────────────┼───────────────────────────────────────┘
│
┌──────────────────────────┼───────────────────────────────────────┐
│ Transport │
│ │
│ POST /auth/sign_in → JSON:API Response │
│ ▲ │
│ │ HTTP │
└──────────────────────────┼───────────────────────────────────────┘
│
┌──────────────────────────┼───────────────────────────────────────┐
│ Backend API │
│ │
│ Rails + FastJsonapi / JSONAPI::Serializer │
└──────────────────────────────────────────────────────────────────┘
The SDK expects backends to follow these JSON:API conventions:
| Block | Resource Types |
|---|---|
| Gateway | User, Role, Company, CompanyBlock, ApiKey, Permission |
| Content | Post, Comment, Tag, Category, PostVersion |
| CRM | Contact, Account, Lead, Opportunity, Meeting |
| Forms | Form, FormSubmission, Survey, FormInstance |
| Products | Product, Category, Brand, Catalog, Cart |
| Search | Entity, SearchResult |
| Files | File, Attachment, Folder |
| Rewards | Badge, Coupon, LoyaltyTier, Reward |
{
"data": [...],
"meta": {
"current_page": 1,
"total_pages": 10,
"total_count": 100,
"per_page": 10
}
}GET /posts?filter[status]=published&filter[user_id]=123
GET /posts?sort=-created_at,title
GET /users/1?include=role,user_avatar,user_profile
{
"errors": [
{
"code": "validation_error",
"status": "422",
"title": "Invalid Attribute",
"detail": "Email has already been taken",
"source": { "pointer": "/data/attributes/email" }
}
]
}Each block is fully independent and can be used standalone.
// Use only the CRM block
import { createCrmBlock } from '@23blocks/block-crm';
import { createHttpTransport } from '@23blocks/transport-http';
const transport = createHttpTransport({
baseUrl: 'https://api.example.com/crm',
headers: () => ({ Authorization: `Bearer ${token}` }),
});
const crm = createCrmBlock(transport, { apiKey: 'my-api-key' });
// Use CRM independently
const contacts = await crm.contacts.list({ page: 1 });// Use multiple blocks together
import { createSdk } from '@23blocks/sdk';
const sdk = createSdk({
transport: { baseUrl: 'https://api.example.com' },
blocks: {
gateway: { apiKey: 'my-api-key' },
crm: { apiKey: 'my-api-key' },
content: { apiKey: 'my-api-key' },
},
});
// Unified access
const user = await sdk.gateway.auth.getCurrentUser();
const contacts = await sdk.crm.contacts.list();
const posts = await sdk.content.posts.list();# Install only what you need
npm install @23blocks/contracts @23blocks/transport-http @23blocks/block-gateway
# Or install the full SDK
npm install @23blocks/sdkFor existing projects using the internal Nx libraries:
npm install @23blocks/block-gateway @23blocks/angular// Before
import { GatewayService } from '@web-container/gateway';
// After
import { GatewayService } from '@23blocks/angular/gateway';// tsconfig.json - Remove these
{
"paths": {
"@web-container/gateway": ["libs/23blocks/gateway/src/index.ts"]
}
}# Once all imports are updated
nx g @nx/workspace:remove gatewayKey principle: Migrate one block at a time, not everything at once.
The SDK is designed to work with any JSON:API compliant backend.
const sdk = createSdk({
transport: {
baseUrl: 'https://api.23blocks.com',
headers: () => ({
'Authorization': `Bearer ${token}`,
'api-key': apiKey,
}),
},
});const sdk = createSdk({
transport: {
baseUrl: 'https://api.custom-provider.com',
headers: () => ({
'Authorization': `Bearer ${token}`,
'X-API-Key': apiKey,
}),
},
// Override specific mappers if needed
mappers: {
user: customUserMapper,
},
});# Future: CLI tool to verify backend compatibility
npx @23blocks/provider-compliance --baseUrl https://api.vendor.com --blocks gateway,crm| Aspect | Decision |
|---|---|
| Wire Format | JSON:API v1.0 |
| Public API | Typed objects only |
| Async Model | Promise (core), Observable/hooks (adapters) |
| Framework | Agnostic core, Angular/React adapters |
| Transport | Abstracted, swappable |
| Blocks | Independent, composable |
| Distribution | npm, open source |
This architecture ensures:
- Modern TypeScript - Full type safety
- No Redux gravity - Clean Promise-based API
- No Angular bleed - Framework code in adapters only
- No React assumptions - Works anywhere
- JSON:API enforced - At the edge, invisible to apps
- Clean replacement - Easy migration path
- Open-source friendly - Clear boundaries, documented conventions
- Future-proof - Provider swappable, extensible
This document is the source of truth for 23blocks SDK architecture decisions.