Changelog - 2025-12-24 (#1)
Dark Matter Site: NocoDB API Integration Library
Overview
Implemented a comprehensive NocoDB API client library that fetches portfolio company data from a cloud-hosted NocoDB instance. This replaces static JSON files with dynamic, centralized data management while maintaining a graceful fallback mechanism.
Key deliverables:
- NocoDB API Client (
src/lib/nocodb.ts): Full v3 API integration with typed responses - Caching Layer: 5-minute TTL cache to reduce API calls during builds
- Portfolio Transformations: Convert NocoDB records to render-ready portfolio company format
- Environment Configuration: Secure API key handling with sensible defaults
- Graceful Degradation: Falls back to static JSON when API not configured
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ Build / SSR Time │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Astro │ │ nocodb.ts │ │ NocoDB Cloud │ │
│ │ Page │─────▶│ API Client │─────▶│ (app.nocodb.com) │ │
│ │ │ │ │ │ │ │
│ └──────────────┘ └────────┬────────┘ └────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Memory Cache │ │
│ │ (5 min TTL) │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Data Flow:
┌────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ NocoDB │ │ Transform to │ │ Portfolio Page │
│ Record │────▶│ PortfolioCompany│────▶│ Component Props │
│ (raw API) │ │ (typed struct) │ │ (render-ready) │
└────────────┘ └─────────────────┘ └──────────────────┘
Changes by Area
1. NocoDB API Client (src/lib/nocodb.ts)
Created a full-featured API client for NocoDB v3 API:
Configuration:
const NOCODB_BASE_URL = 'https://app.nocodb.com';
const DEFAULT_BASE_ID = 'pvop0ydhmtugzvv';
export const NOCODB_TABLES = {
organizations: 'myxl4ug85sr1d4p',
materials: 'mruw5fu5cthdwkl',
} as const;
Type Definitions:
export interface NocoDBConfig {
apiKey: string;
baseUrl: string;
baseId: string;
}
export interface NocoDBRecord<T = Record<string, any>> {
id: number;
fields: T;
}
export interface NocoDBResponse<T = Record<string, any>> {
records: NocoDBRecord<T>[];
nestedNext?: string | null;
}
export interface NocoDBQueryParams {
where?: string; // Filter conditions (NocoDB syntax)
limit?: number; // Max 1000, default 25
offset?: number; // Pagination offset
sort?: NocoDBSortParam[]; // v3 API JSON array format
fields?: string[]; // Specific fields to include
viewId?: string; // View ID override
}
Core Functions:
| Function | Description |
|---|---|
fetchRecords<T>() |
Fetch paginated records from a table |
fetchAllRecords<T>() |
Auto-paginate to get all records |
isNocoDBConfigured() |
Check if API key is set |
clearNocoDBCache() |
Clear memory cache |
getCacheStats() |
Debug cache state |
2. Caching Layer
Implemented in-memory caching with TTL to minimize API calls:
const recordsCache = new Map<string, { data: NocoDBResponse; expires: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
// Cache key includes table + query params for granular invalidation
const cacheKey = `${tableId}:${JSON.stringify(params)}`;
const cached = recordsCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
console.log(`[nocodb] Cache hit for ${tableId}`);
return cached.data as NocoDBResponse<T>;
}
Benefits:
- Reduces API calls during iterative development
- Speeds up multi-page builds that query same data
- Console logging for cache hit visibility
3. Organization Field Types
Typed the NocoDB organization table schema:
export interface OrganizationFields {
conventionalName: string;
officialName: string;
'Entity-Type'?: string | null; // LP, C-Corp, LLC, etc.
uuid?: string | null;
url?: string | null;
elevatorPitch?: string | null;
trademarksSlugs?: TrademarksSlugs | string | null;
Materials?: { id: number; fields: { Title: string } } | null;
relatedMaterials?: number;
CreatedAt: string;
UpdatedAt: string | null;
}
4. Trademark/Logo Handling
Parse the trademarksSlugs JSON field from NocoDB:
export interface TrademarksSlugs {
trademarkDarkMode?: string;
trademarkLightMode?: string;
trademarkVibrantMode?: string;
}
function parseTrademarksSlugs(
trademarksSlugs: TrademarksSlugs | string | null | undefined
): { lightMode?: string; darkMode?: string; vibrantMode?: string } {
// Handle both object and JSON string formats
let parsed: TrademarksSlugs;
if (typeof trademarksSlugs === 'string') {
parsed = JSON.parse(trademarksSlugs);
} else {
parsed = trademarksSlugs;
}
// Prefix with public asset path
return {
lightMode: parsed.trademarkLightMode
? `/portfolio/logos/${parsed.trademarkLightMode}`
: undefined,
// ...
};
}
5. Portfolio Company Transformation
Transform raw NocoDB records to render-ready format:
export interface PortfolioCompany {
id: number;
conventionalName: string;
officialName: string;
entityType: string | null;
logoLightMode?: string;
logoDarkMode?: string;
logoVibrantMode?: string;
logoIsTransparent?: boolean; // Needs CSS color treatment
urlToPortfolioSite?: string;
blurbShortTxt?: string;
category?: 'direct' | 'lp';
}
export function transformToPortfolioCompanies(
records: NocoDBRecord<OrganizationFields>[]
): PortfolioCompany[] {
return records.map((record) => {
const { fields } = record;
// Derive category from Entity-Type
// LP = fund investment, C-Corp/LLC = direct investment
const entityType = fields['Entity-Type'] || null;
let category: 'direct' | 'lp' = 'direct';
if (entityType === 'LP') {
category = 'lp';
}
// Parse logos and detect transparent SVGs
const trademarks = parseTrademarksSlugs(fields.trademarksSlugs);
const logoIsTransparent = Boolean(
trademarks.lightMode?.includes('--Transparent') ||
trademarks.darkMode?.includes('--Transparent')
);
return {
id: record.id,
conventionalName: fields.conventionalName,
category,
logoLightMode: trademarks.lightMode || '/portfolio/logos/placeholder-light.svg',
logoDarkMode: trademarks.darkMode || '/portfolio/logos/placeholder-dark.svg',
logoIsTransparent,
urlToPortfolioSite: normalizeUrl(fields.url),
blurbShortTxt: fields.elevatorPitch || undefined,
// ...
};
});
}
6. URL Normalization
Handle URLs that may lack protocol prefix:
function normalizeUrl(url: string | null | undefined): string | undefined {
if (!url) return undefined;
const trimmed = url.trim();
if (!trimmed) return undefined;
// Already has protocol
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
// Add https:// prefix
return `https://${trimmed}`;
}
7. Environment Configuration
Added NocoDB configuration to .env.example:
# NocoDB Integration
# ------------------
# API key for fetching portfolio data from NocoDB
# Generate at: NocoDB > Account Settings > Tokens
NOCODB_API_KEY=your_nocodb_api_key_here
# Optional: Override defaults
# NOCODB_BASE_URL=https://app.nocodb.com
# NOCODB_BASE_ID=pvop0ydhmtugzvv
Files Changed Summary
New
src/lib/nocodb.ts— Complete NocoDB API client with types, caching, and transformations
Modified
.env.example— Added NocoDB configuration variables with documentation.claude/settings.local.json— Addednocodb.comto allowed WebFetch domains
Technical Details
NocoDB v3 API Format
The v3 API uses a different structure than earlier versions:
GET /api/v3/data/{baseId}/{tableId}/records
Headers:
Accept: application/json
xc-token: {apiKey}
Query params:
limit=100
offset=0
sort=[{"field":"conventionalName","direction":"asc"}]
where=(field,eq,value)
API Response Structure
{
"records": [
{
"id": 12345,
"fields": {
"conventionalName": "Example Corp",
"Entity-Type": "C-Corp",
"url": "example.com",
"trademarksSlugs": "{\"trademarkDarkMode\":\"trademark__Example--Dark-Mode.svg\"}"
}
}
],
"nestedNext": null
}
Authentication Pattern
Uses xc-token header for API authentication:
const response = await fetch(url.toString(), {
headers: {
'Accept': 'application/json',
'xc-token': config.apiKey,
},
});
Error Handling
Graceful degradation on API failures:
try {
const response = await fetch(url.toString(), { /* ... */ });
if (!response.ok) {
const errorBody = await response.text();
console.error(`[nocodb] API error: ${response.status}`, errorBody);
throw new Error(`NocoDB API error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`[nocodb] Failed to fetch records:`, error);
return { records: [], nestedNext: null }; // Empty result, not crash
}
Notes / Follow-Ups
-
Pagination:
fetchAllRecords()handles pagination automatically, but consider adding a max records limit for safety. -
Cache Invalidation: Current cache is in-memory and resets on dev server restart. Consider adding manual cache clear endpoint for production.
-
Field Mapping: The
Entity-Typefield uses bracket notation due to hyphen. Consider aliasing in NocoDB for cleaner access. -
Linked Records: The
Materialsfield shows NocoDB's linked record structure. Could expand to fetch full material details. -
Rate Limiting: NocoDB cloud has rate limits. The caching layer helps, but monitor usage in production.
-
Future Tables: The
NOCODB_TABLESconstant is ready for additional tables (materials, team, etc).