I'll be honest: when a client first asked me to use Airtable as their CMS back in 2023, I thought they were joking. A spreadsheet app powering a production website? But after building half a dozen sites this way -- some with Astro, some with Next.js -- I've come around. Airtable hits a sweet spot for certain projects that traditional headless CMS platforms completely miss. Your marketing team already knows how to use it. It's flexible enough to model most content. And the API is dead simple.

But it's not without sharp edges. Rate limits, attachment handling, relational data quirks -- there's a lot that the "Airtable as CMS" blog posts from 2023 never told you. This guide covers everything I've learned shipping real projects with this stack in 2026.

Table of Contents

Using Airtable as a CMS with Astro & Next.js in 2026

Why Airtable as a CMS Actually Makes Sense

The biggest argument for Airtable isn't technical -- it's human. Your content editors already know how to use it. There's no onboarding friction, no new login to forget, no content modeling UI to learn. They open a spreadsheet-like interface, type stuff, and it shows up on the website.

Here's what makes it genuinely good for certain use cases:

  • Zero learning curve for editors. If someone can use Google Sheets, they can use Airtable.
  • Flexible schema. Adding a new field takes five seconds. No migrations, no schema deployments.
  • Built-in views and filters. Editors can create filtered views, Kanban boards, galleries -- all without developer help.
  • Relational data. Unlike flat spreadsheets, Airtable supports linked records, lookups, and rollups.
  • The free tier is generous enough. 1,000 records per base and 1,000 API calls per month on the free plan. The Team plan ($20/seat/month in 2026) bumps you to 50,000 records and higher API limits.

I've used Airtable as a CMS for portfolio sites, event listings, team directories, product catalogs, job boards, and small blogs. It works surprisingly well for all of these.

When You Should NOT Use Airtable as a CMS

Let me save you some pain. Don't use Airtable as your CMS if:

  • You have more than ~10,000 content records. It gets sluggish, and the API pagination becomes a real headache at scale.
  • You need rich text with embedded components. Airtable's long text fields support basic Markdown, but you can't embed React components or custom blocks like you can with Sanity or Contentful.
  • You need granular permissions on content. Airtable's permission model is per-base and per-table, not per-record. If editor A shouldn't see editor B's drafts, you're going to have a bad time.
  • You need real-time preview. There's no built-in draft/preview workflow. You can hack it with filtered views and a status field, but it's janky.
  • You need image transformations. Airtable attachment URLs are temporary (they expire after about 2 hours). You'll need a separate image pipeline.

For anything beyond a small-to-medium content site, you're probably better off with a purpose-built headless CMS. We cover that in our headless CMS development work.

Setting Up Your Airtable Base for Content

Before writing any code, get your Airtable base right. Here's the structure I use for a typical blog:

Base Structure

Create a table called Posts with these fields:

Field Name Field Type Notes
Title Single line text Primary field
Slug Single line text URL-safe, lowercase
Body Long text (Markdown) Enable rich text formatting
Excerpt Long text Plain text, 1-2 sentences
Published Checkbox Filter on this for production
Publish Date Date Sort by this descending
Author Link to Authors table Relational link
Tags Multiple select Or link to a Tags table
Featured Image Attachment Single image
SEO Title Single line text Optional override
SEO Description Long text Meta description

Create a filtered view called "Published" that only shows records where Published is checked. This is your production content.

API Setup

  1. Go to airtable.com/create/tokens and create a personal access token.
  2. Give it data.records:read scope (and data.records:write if you need write access).
  3. Scope it to the specific base you're using.
  4. Store the token in your .env file. Never commit it.
# .env
AIRTABLE_TOKEN=pat_xxxxxxxxxxxxx
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX

You can find your base ID in the Airtable API docs or in the URL when viewing your base.

Using Airtable as a CMS with Astro & Next.js in 2026 - architecture

Connecting Airtable to Astro

Astro is my preferred framework for Airtable-powered sites when the content is mostly static. Since Astro builds to static HTML by default, you fetch all your Airtable data at build time, which means zero API calls from your visitors and no rate limit concerns in production.

If you're exploring Astro for your next project, we have deep experience with it -- check out our Astro development services.

Install the SDK

npm install airtable

Create a Data Fetching Utility

// src/lib/airtable.ts
import Airtable from 'airtable';

const base = new Airtable({ apiKey: import.meta.env.AIRTABLE_TOKEN })
  .base(import.meta.env.AIRTABLE_BASE_ID);

export interface Post {
  id: string;
  title: string;
  slug: string;
  body: string;
  excerpt: string;
  publishDate: string;
  featuredImage: { url: string; filename: string } | null;
  tags: string[];
}

export async function getPosts(): Promise<Post[]> {
  const records = await base('Posts')
    .select({
      view: 'Published',
      sort: [{ field: 'Publish Date', direction: 'desc' }],
    })
    .all();

  return records.map((record) => ({
    id: record.id,
    title: record.get('Title') as string,
    slug: record.get('Slug') as string,
    body: record.get('Body') as string,
    excerpt: record.get('Excerpt') as string,
    publishDate: record.get('Publish Date') as string,
    featuredImage: record.get('Featured Image')
      ? {
          url: (record.get('Featured Image') as any[])[0].url,
          filename: (record.get('Featured Image') as any[])[0].filename,
        }
      : null,
    tags: (record.get('Tags') as string[]) || [],
  }));
}

export async function getPostBySlug(slug: string): Promise<Post | undefined> {
  const records = await base('Posts')
    .select({
      view: 'Published',
      filterByFormula: `{Slug} = '${slug}'`,
      maxRecords: 1,
    })
    .all();

  if (records.length === 0) return undefined;
  const record = records[0];

  return {
    id: record.id,
    title: record.get('Title') as string,
    slug: record.get('Slug') as string,
    body: record.get('Body') as string,
    excerpt: record.get('Excerpt') as string,
    publishDate: record.get('Publish Date') as string,
    featuredImage: record.get('Featured Image')
      ? {
          url: (record.get('Featured Image') as any[])[0].url,
          filename: (record.get('Featured Image') as any[])[0].filename,
        }
      : null,
    tags: (record.get('Tags') as string[]) || [],
  };
}

Use It in Astro Pages

---
// src/pages/blog/[slug].astro
import { getPosts, getPostBySlug } from '../../lib/airtable';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const posts = await getPosts();
  return posts.map((post) => ({
    params: { slug: post.slug },
  }));
}

const { slug } = Astro.params;
const post = await getPostBySlug(slug!);

if (!post) return Astro.redirect('/404');
---

<Layout title={post.title}>
  <article>
    <h1>{post.title}</h1>
    <time>{post.publishDate}</time>
    <div set:html={post.body} />
  </article>
</Layout>

That's it. On astro build, every post gets fetched from Airtable and rendered to static HTML. Your production site makes zero API calls.

Connecting Airtable to Next.js

Next.js gives you more flexibility. You can fetch at build time with generateStaticParams, at request time with server components, or use ISR (Incremental Static Regeneration) for the best of both worlds.

We build a lot of Next.js sites -- it's our bread and butter. See our Next.js development capabilities.

The Fetch Utility (Next.js Version)

I prefer using the Airtable REST API directly with fetch in Next.js rather than the SDK. It gives you better control over caching with Next.js's fetch extensions.

// lib/airtable.ts
const AIRTABLE_TOKEN = process.env.AIRTABLE_TOKEN!;
const AIRTABLE_BASE_ID = process.env.AIRTABLE_BASE_ID!;

const headers = {
  Authorization: `Bearer ${AIRTABLE_TOKEN}`,
  'Content-Type': 'application/json',
};

export async function fetchPosts() {
  const url = new URL(
    `https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Posts`
  );
  url.searchParams.set('view', 'Published');
  url.searchParams.set('sort[0][field]', 'Publish Date');
  url.searchParams.set('sort[0][direction]', 'desc');

  const res = await fetch(url.toString(), {
    headers,
    next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
  });

  if (!res.ok) throw new Error(`Airtable API error: ${res.status}`);

  const data = await res.json();
  return data.records.map((record: any) => ({
    id: record.id,
    title: record.fields['Title'],
    slug: record.fields['Slug'],
    body: record.fields['Body'],
    excerpt: record.fields['Excerpt'],
    publishDate: record.fields['Publish Date'],
    tags: record.fields['Tags'] || [],
  }));
}

ISR Page with App Router

// app/blog/[slug]/page.tsx
import { fetchPosts } from '@/lib/airtable';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  const posts = await fetchPosts();
  return posts.map((post: any) => ({ slug: post.slug }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const posts = await fetchPosts();
  const post = posts.find((p: any) => p.slug === slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.publishDate}</time>
      <div dangerouslySetInnerHTML={{ __html: post.body }} />
    </article>
  );
}

With revalidate: 60, Next.js will serve the cached page and refresh it in the background at most once every 60 seconds. Your editors update Airtable, and the site updates within a minute. No webhook setup, no rebuild triggers.

Handling Images and Attachments

This is the single biggest gotcha with Airtable as a CMS. Airtable attachment URLs expire. They're signed URLs that become invalid after roughly 2 hours. If you render them directly in your HTML, they'll break.

Here are your options:

Option 1: Download at Build Time (Astro)

For static sites, download images during the build and serve them locally:

import fs from 'fs/promises';
import path from 'path';

async function downloadImage(url: string, filename: string) {
  const res = await fetch(url);
  const buffer = Buffer.from(await res.arrayBuffer());
  const outputPath = path.join('public', 'images', 'cms', filename);
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
  await fs.writeFile(outputPath, buffer);
  return `/images/cms/${filename}`;
}

Option 2: Proxy Through a CDN

Set up a Cloudflare Worker or Vercel Edge Function that proxies Airtable image URLs, caches them, and serves them through your own domain. This works for both Astro and Next.js.

Option 3: Use a Separate Image Host

Upload images to Cloudinary, Imgix, or an S3 bucket, and store the permanent URL in a text field instead of using Airtable's attachment field. This is what I recommend for production sites -- it's the most reliable approach.

Caching, Rate Limits, and Performance

Airtable's API has strict rate limits: 5 requests per second per base. That's not a lot. Here's how to stay well under it.

Strategy Framework How It Works
Static generation Astro All API calls happen at build time. Zero runtime calls.
ISR Next.js Cached responses, revalidated on a timer.
In-memory cache Both Cache API responses in a Map with TTL.
Webhook + rebuild Both Airtable automation triggers a Vercel/Netlify rebuild.
Redis/KV cache Next.js (Vercel) Store API responses in Vercel KV or Upstash Redis.

For Astro sites, the build-time approach means you only hit the API during deploys. For Next.js with ISR, you'll hit it at most once per revalidation interval per page.

If you have a lot of pages and short revalidation intervals, consider fetching all records at once and caching the entire dataset rather than making per-page API calls.

Pagination Matters

Airtable returns a maximum of 100 records per request. The .all() method in the SDK handles pagination automatically, but if you're using fetch directly, you need to follow the offset token:

async function fetchAllRecords(tableName: string) {
  let allRecords: any[] = [];
  let offset: string | undefined;

  do {
    const url = new URL(
      `https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${tableName}`
    );
    url.searchParams.set('view', 'Published');
    if (offset) url.searchParams.set('offset', offset);

    const res = await fetch(url.toString(), { headers });
    const data = await res.json();

    allRecords = [...allRecords, ...data.records];
    offset = data.offset;
  } while (offset);

  return allRecords;
}

Rich Text and Markdown Content

Airtable's long text fields can store Markdown if you enable the "rich text" option. But what you get back from the API is Markdown-formatted text, not HTML.

You need to convert it. I use marked for simple cases or unified with remark plugins for more control:

import { marked } from 'marked';

const htmlContent = marked.parse(post.body);

For Astro, you can also use the built-in Markdown processing:

---
import { marked } from 'marked';
const html = marked.parse(post.body);
---
<article set:html={html} />

One thing to watch out for: Airtable's rich text editor produces its own flavor of Markdown. It handles bold, italic, links, headings, and lists well. Code blocks and tables are supported but can be finicky. If your content needs complex formatting, consider having editors write in plain Markdown mode.

Airtable vs Traditional Headless CMS Options

Let's be real about the trade-offs. Here's how Airtable stacks up against purpose-built headless CMS platforms in 2026:

Feature Airtable Sanity Contentful Strapi
Editor learning curve Very low Medium Medium Medium
Content modeling Flexible, informal Excellent Excellent Good
API rate limits 5 req/s per base Generous (CDN) Generous (CDN) Self-hosted
Image handling Expiring URLs Built-in CDN Built-in CDN Self-hosted
Preview/drafts Manual (checkbox) Built-in Built-in Built-in
Pricing (team of 5) $100/mo (Team) Free tier viable $300/mo+ Free (self-host)
Webhook support Via automations Built-in Built-in Built-in
Rich text quality Basic Markdown Portable Text Structured Rich text
Relational content Linked records References References Relations

Airtable wins on editor experience and flexibility. It loses on image handling, preview workflows, and API reliability at scale. For small-to-medium sites where your editors are already in Airtable? It's a solid choice. For content-heavy sites with complex workflows? Go with a real CMS.

Real-World Architecture Patterns

Here are the patterns I've used in production:

Pattern 1: Full Static with Astro + Rebuild Webhooks

Best for: marketing sites, portfolios, directories with < 500 records.

  1. Astro fetches all Airtable data at build time.
  2. Airtable automation sends a webhook to Vercel/Netlify on record update.
  3. Site rebuilds in 30-60 seconds.
  4. Images downloaded at build time -- no expiring URL issues.

Pattern 2: ISR with Next.js

Best for: blogs, catalogs, sites with frequent updates.

  1. Next.js generates pages with ISR (revalidate every 60-300 seconds).
  2. Airtable API called once per revalidation per unique page.
  3. Images proxied through Cloudinary or downloaded to a CDN.
  4. Editors see updates within minutes without triggering a full rebuild.

Pattern 3: Airtable + Supplemental CMS

Best for: sites where some content lives in Airtable and other content needs richer editing.

  1. Structured data (team members, events, products) stays in Airtable.
  2. Long-form content (blog posts, case studies) goes into Sanity or Notion.
  3. Frontend fetches from both sources at build time or with ISR.

This hybrid approach is more common than you'd think. We've built several sites this way -- if you're considering something similar, let's talk about it.

Triggering Rebuilds from Airtable

Airtable has built-in automations that can fire webhooks. Set up a trigger on "When a record is updated" in your Posts table, then send a POST request to your deployment platform's build hook:

// Vercel deploy hook
https://api.vercel.com/v1/integrations/deploy/prj_xxxx/yyyy

// Netlify build hook
https://api.netlify.com/build_hooks/xxxxxxxxxxxx

Add a 30-second delay in the automation to batch rapid edits.

FAQ

Is Airtable free to use as a CMS?

Airtable's free plan includes 1,000 records per base and 1,000 API calls per month. That's enough for a small site, but you'll likely need the Team plan ($20/seat/month in 2026) for anything serious. The Team plan gives you 50,000 records and higher API limits.

How do I handle Airtable's expiring image URLs?

Airtable attachment URLs expire after about 2 hours. For static sites built with Astro, download images at build time. For Next.js with ISR, either proxy images through a CDN like Cloudinary, or store image URLs in a separate image hosting service and reference them as text fields in Airtable.

Can Airtable handle a blog with hundreds of posts?

Yes, up to a point. Airtable handles hundreds of records well. Once you get into the thousands, API pagination and build times start to become noticeable. For a blog with under 1,000 posts, it works fine. Beyond that, consider a dedicated headless CMS.

Is Airtable better than Notion as a CMS?

They solve different problems. Airtable is better for structured data (products, events, team members) because of its relational database model. Notion is better for long-form written content because of its block-based editor. Airtable's API is also more mature and faster than Notion's.

How do I set up preview/draft functionality with Airtable?

Add a "Status" single-select field with options like "Draft", "In Review", and "Published". Create a filtered view for each status. Your production site fetches from the "Published" view. For previews, create a separate preview route that fetches from the "In Review" view, protected by authentication.

Should I use the Airtable SDK or the REST API directly?

For Astro, the official airtable npm package works well since you're fetching at build time. For Next.js, I recommend using fetch directly with the REST API -- it gives you control over Next.js cache directives like revalidate and tags. The SDK doesn't understand Next.js's extended fetch options.

What's the maximum number of API calls Airtable allows?

Airtable enforces a rate limit of 5 requests per second per base. Exceeding this returns a 429 status code. On the Team plan, you get a higher monthly call allowance, but the per-second rate limit stays the same. Static generation and ISR are the best ways to minimize API usage.

Can I use Airtable with both Astro and Next.js in the same project?

Not exactly in the same project, but you can have a shared Airtable base powering multiple frontends. Some teams use Astro for their marketing site and Next.js for their web app, both reading from the same Airtable base. Just be mindful of the shared rate limits across all consumers.