easy-airtable-api
v0.1.0
Published
Lightweight TypeScript-first Airtable client with metadata-based type generation.
Readme
Easy Airtable API
Easy Airtable API is a lightweight, TypeScript-first Airtable client for records, metadata/base APIs, code generation, and raw Airtable requests.
It is built to stay practical:
- small runtime API
- typed CRUD helpers
- code generation from Airtable metadata
- a better fit for app code than a large SDK surface
The intended workflow is simple:
- generate types from your Airtable base
- import the generated types in your app
- use the runtime helpers with those types
Install
npm install easy-airtable-apibun add easy-airtable-apiRecommended project setup
A good setup is to generate Airtable types once and commit the generated file.
Example file structure
.
├── scripts/
│ └── generate-airtable-types.ts
├── src/
│ ├── airtable.generated.ts
│ └── airtable.ts
├── .env
└── package.jsonExample environment variables
AIRTABLE_ACCESS_TOKEN=pat_xxx
AIRTABLE_BASE_ID=app_xxx
AIRTABLE_PROJECTS_TABLE_ID=tbl_xxx
AIRTABLE_WORKSPACE_ID=wsp_xxxExample package.json scripts
{
"scripts": {
"airtable:types": "tsx scripts/generate-airtable-types.ts"
}
}1) Generate types into your project
Create scripts/generate-airtable-types.ts:
import { generateAirtableTypes } from 'easy-airtable-api/codegen';
await generateAirtableTypes({
source: {
baseId: process.env.AIRTABLE_BASE_ID!,
accessToken: process.env.AIRTABLE_ACCESS_TOKEN!,
},
output: './src/airtable.generated.ts',
enumMode: 'hybrid',
});Then run:
npm run airtable:types2) Use the generated types in your app
Create src/airtable.ts:
import { createRecord, getRecords, updateRecord } from 'easy-airtable-api';
import type {
ProjectsCreateFields,
ProjectsRecordFields,
} from './airtable.generated';
const apiKey = process.env.AIRTABLE_ACCESS_TOKEN!;
const baseId = process.env.AIRTABLE_BASE_ID!;
const tableId = process.env.AIRTABLE_PROJECTS_TABLE_ID!;
export async function listProjects() {
return getRecords<ProjectsRecordFields>({
apiKey,
baseId,
tableId,
options: {
maxRecords: 100,
},
});
}
export async function createProject(fields: ProjectsCreateFields) {
return createRecord<ProjectsCreateFields>({
apiKey,
baseId,
tableId,
fields,
options: {
typecast: true,
},
});
}
export async function markProjectInProgress(recordId: string) {
return updateRecord<ProjectsCreateFields>({
apiKey,
baseId,
tableId,
recordId,
fields: {
Status: 'In Progress',
},
});
}Codegen
generateAirtableTypes()
import { generateAirtableTypes } from 'easy-airtable-api/codegen';Supported schema sources:
- Airtable metadata API via
baseId+accessToken/apiKey - local schema file via
schemaPath - in-memory schema object via
schema
Example using a local schema file:
await generateAirtableTypes({
source: {
schemaPath: './airtable-schema.json',
},
output: './src/airtable.generated.ts',
});Enum typing modes
Choice-like fields such as single select and multiple select support 3 modes:
literal— exact Airtable option values onlyhybrid— exact Airtable values plus a string fallbackbroad— genericstring/string[]
hybrid is the default and usually the best fit for real Airtable projects because it preserves autocomplete while tolerating schema drift.
Useful codegen options
await generateAirtableTypes({
source: {
baseId: process.env.AIRTABLE_BASE_ID!,
accessToken: process.env.AIRTABLE_ACCESS_TOKEN!,
},
output: './src/airtable.generated.ts',
tableNameOrId: ['Projects', 'Tasks'],
enumMode: 'hybrid',
createRequiredMode: 'allOptional',
unknownFieldBehavior: 'unknown',
includeTableIds: true,
includeFieldIds: true,
schemaMode: 'full',
});Generated type assumptions
Generated read types target Airtable's default JSON response mode:
cellFormat: 'json'returnFieldsByFieldId: false
If you switch to cellFormat: 'string' or returnFieldsByFieldId: true, the response shape is no longer compatible with field-name-keyed generated interfaces. The read helpers now include type-level guardrails for this path, and incompatible calls fall back to untyped field maps unless you stay on the default JSON mode.
Shared field/value types
Generated files import shared canonical field/value types from the package root instead of emitting duplicate local interfaces. You can import the same types in application code:
import type {
AICell,
Attachment,
AttachmentWrite,
BarcodeCell,
BarcodeWrite,
ButtonCell,
Collaborator,
CollaboratorWrite,
} from 'easy-airtable-api';Examples:
aiTextread values useAICell- attachment reads use
Attachment[], writes useAttachmentWrite[] - collaborator reads use
Collaborator/Collaborator[], writes useCollaboratorWrite/CollaboratorWrite[] - barcode reads use
BarcodeCell, writes useBarcodeWrite - button fields are readonly and use
ButtonCell
These shared types make generated output and application code agree on one canonical public surface. They do not, by themselves, guarantee that every structured write shape has been exhaustively live-verified against Airtable in every edge case.
Metadata/base APIs
List accessible bases
import { listBases } from 'easy-airtable-api';
const result = await listBases({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
});
for (const base of result.bases) {
console.log(base.id, base.name, base.permissionLevel);
}Fetch a base schema directly
import { getBaseSchema } from 'easy-airtable-api';
const schema = await getBaseSchema({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
});
console.log(schema.tables.map((table) => table.name));This is the same metadata path used by the code generator when source includes a baseId and token.
Create a base
import { createBase } from 'easy-airtable-api';
const created = await createBase({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
body: {
workspaceId: process.env.AIRTABLE_WORKSPACE_ID!,
name: 'Apartment Hunting',
tables: [
{
name: 'Apartments',
fields: [
{ name: 'Name', type: 'singleLineText' },
{
name: 'Visited',
type: 'checkbox',
options: { color: 'greenBright', icon: 'check' },
},
],
},
],
},
});
console.log(created.id);Per Airtable's metadata API documentation, creating a base requires a token authorized for schema.bases:write, and the caller must be able to create bases in the target workspace (documented as workspace creator permissions).
Raw Airtable request escape hatch
Use airtableRequestRaw() when you need an Airtable endpoint that this library does not wrap yet, but still want the shared auth, retry, encoding, and structured error behavior.
import { airtableRequestRaw } from 'easy-airtable-api';
const schema = await airtableRequestRaw({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
method: 'GET',
path: '/v0/meta/bases/app123/tables',
});It accepts either Airtable-style /v0/... paths or paths relative to the API root used internally.
Retry configuration
All runtime helpers accept an optional top-level retry object for tuning retry behavior without leaving the library's shared request layer.
import { getRecords } from 'easy-airtable-api';
const records = await getRecords({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
retry: {
maxRetries: 2,
baseDelayMs: 250,
maxDelayMs: 1000,
retryOn429: true,
retryOn5xx: true,
retryOnNetworkErrors: true,
useJitter: false,
},
});The same retry option is supported by metadata helpers such as listBases() / getBaseSchema() and by airtableRequestRaw().
Runtime examples
Get a single record
import { getRecord } from 'easy-airtable-api';
import type { ProjectsRecordFields } from './airtable.generated';
const record = await getRecord<ProjectsRecordFields>({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
recordId: 'rec123',
options: {
returnFieldsByFieldId: false,
},
});Get one page of records
import { getRecordsPage } from 'easy-airtable-api';
import type { ProjectsRecordFields } from './airtable.generated';
const page = await getRecordsPage<ProjectsRecordFields>({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
options: {
pageSize: 25,
},
});Iterate through record pages
import { iterateRecordsPages } from 'easy-airtable-api';
import type { ProjectsRecordFields } from './airtable.generated';
for await (const page of iterateRecordsPages<ProjectsRecordFields>({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
options: {
pageSize: 100,
},
})) {
console.log(page.records.length, page.offset);
}Create multiple records
import { createRecords } from 'easy-airtable-api';
import type { ProjectsCreateFields } from './airtable.generated';
const result = await createRecords<ProjectsCreateFields>({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
records: [
{
fields: {
Name: 'Project A',
Status: 'Todo',
},
},
{
fields: {
Name: 'Project B',
Status: 'In Progress',
},
},
],
options: {
typecast: true,
},
});Replace a single record
import { replaceRecord } from 'easy-airtable-api';
import type { ProjectsCreateFields } from './airtable.generated';
const result = await replaceRecord<ProjectsCreateFields>({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
recordId: 'rec123',
fields: {
Name: 'Project A',
Status: 'Done',
},
});Upsert records
import { updateRecordsUpsert } from 'easy-airtable-api';
import type { ProjectsCreateFields } from './airtable.generated';
const result = await updateRecordsUpsert<ProjectsCreateFields>({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
records: [
{
fields: {
Name: 'Project A',
Status: 'Done',
},
},
],
options: {
fieldsToMergeOn: ['Name'],
typecast: true,
},
});Delete a single record
import { deleteRecord } from 'easy-airtable-api';
await deleteRecord({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
recordId: 'rec123',
});Delete records
import { deleteRecords } from 'easy-airtable-api';
await deleteRecords({
apiKey: process.env.AIRTABLE_ACCESS_TOKEN!,
baseId: process.env.AIRTABLE_BASE_ID!,
tableId: process.env.AIRTABLE_PROJECTS_TABLE_ID!,
recordIds: ['rec123', 'rec456'],
});Exports
Runtime package:
import {
airtableRequestRaw,
createBase,
createRecord,
createRecords,
deleteRecord,
deleteRecords,
getBaseSchema,
getRecord,
getRecords,
getRecordsPage,
iterateRecordsPages,
listBases,
replaceRecord,
replaceRecords,
updateRecord,
updateRecords,
updateRecordsUpsert,
} from 'easy-airtable-api';Codegen package:
import {
buildAirtableTypes,
generateAirtableTypes,
} from 'easy-airtable-api/codegen';Testing
Codegen coverage lives in focused fixtures under tests/fixtures/ and runs as part of the normal Vitest suite.
The fixture matrix also includes compile checks for generated output and type-level guardrail tests for response modes that are incompatible with field-name-keyed generated types.
Changelog
See CHANGELOG.md.
