Browse, filter, and preview Airtable records from Neovim.
Define one or more pickers (named views with their own filters, sort, and result layout), open them with a single command or keymap, and view full records as markdown. Includes a live preview, comment browsing, quick actions to open or copy a record's Airtable URL, and optional safe editing of specific fields you explicitly configure.
- Neovim ≥ 0.10
- plenary.nvim
- snacks.nvim
- honcho.nvim
vim.pack (built-in, Neovim ≥ 0.12)
vim.pack.add({
'https://github.com/nvim-lua/plenary.nvim',
'https://github.com/folke/snacks.nvim',
'https://github.com/maxdlr/honcho.nvim',
'https://github.com/maxdlr/airtable.nvim',
})lazy.nvim
{
'you/airtable.nvim',
dependencies = { 'nvim-lua/plenary.nvim', 'folke/snacks.nvim', 'maxdlr/honcho.nvim' },
opts = { --[[ see Configuration below ]] },
}packer.nvim
use({
'you/airtable.nvim',
requires = { 'nvim-lua/plenary.nvim', 'folke/snacks.nvim', 'maxdlr/honcho.nvim' },
config = function() require('airtable').setup({ --[[ see Configuration below ]] }) end,
})Your Airtable personal access token is never stored in config, it's read from an environment variable at runtime.
# ~/.zshrc, ~/.bashrc, etc. — never commit this value.
export AIRTABLE_TOKEN="pat..."Generate a token at airtable.com/create/tokens with
the data.records:read scope, and grant it access to your base. If you configure
buffer.editable (see below), the token also needs data.records:write.
Default config (click to expand)
require('airtable').setup({
token_env = 'AIRTABLE_TOKEN',
base_id = '',
table_name = '',
page_size = 20,
default_filter = 'All records',
buffer = {
fields = {
{ key = 'title', field = 'Title' },
{ key = 'status', field = 'Status' },
{ key = 'description', field = 'Description' },
},
},
pickers = {
{
name = 'All records',
result_line = {
{ field = 'Status' },
{ field = 'Title' },
{ field = 'Description' },
},
},
},
})require('airtable').setup({
token_env = 'AIRTABLE_TOKEN', -- name of the env var holding your token (not the token itself)
base_id = 'appXXXXXXXXXXXXXX',
table_name = 'Tickets',
})If you skip pickers entirely, :Airtable lists every record in table_name — no
filters required to get started.
Every field name above must match your Airtable base's actual column names exactly (case-sensitive) — there's no universal default, since this varies by base.
require('airtable').setup({
token_env = 'AIRTABLE_TOKEN',
base_id = 'appXXXXXXXXXXXXXX', -- your Airtable base id
table_name = 'Tickets', -- exact table name (or its "tbl..." id) in that base
page_size = 20, -- records fetched per API page (Airtable max: 100)
default_filter = 'Assigned to me', -- picker opened by `:Airtable` with no argument
buffer = {
fields = { -- rendered in this exact order when a record is opened
{ key = 'title', field = 'Title' }, -- key="title" is special: the H1 heading
{ key = 'status', field = 'Status' }, -- other keys become their own "## <Key>" section
{ key = 'description', field = 'Description' },
},
-- Optional, off by default. Adds an "Edit <field>" action to the record view's
-- <CR> menu. This is a WRITE operation — see "Editing fields" below.
editable = {
{ field = 'Status', type = 'select' }, -- opens a picker of the field's choices
{ field = 'PR link', type = 'text', name = 'Edit PR link' }, -- opens a small editable buffer
},
},
pickers = { -- one or more named views, switch between them by name
{
name = 'Assigned to me', -- shown in the picker title; used to select this view
filters = { -- conditions combined with AND (omit to list everything)
{ field = 'Assignee', value = 'Your Name' }, -- matches even array-shaped fields
{ field = 'Type', value = 'Bug', only = true }, -- only=true: exact match instead
{ field = 'Assignee', value = 'you@co.com', by = 'email' }, -- exact collaborator email match
},
sort = { field = 'Priority', order = 'asc' }, -- optional; order: 'asc' or 'desc'
result_line = { -- columns shown per row, left to right
{ field = 'Title' }, -- hl omitted: defaults to an identifier color
{ field = 'Status', hl = '#FFA500' }, -- hl: a highlight group or hex color
},
},
},
})Opening a record (and its picker preview) styles each buffer.fields entry based on
its key name, no configuration needed:
- Keys like
status,assignee,reviewer,type,priority, orlabelrender as a colored pill — the color is derived deterministically from the field's value, so the same value always gets the same color. - Keys like
name,title, oridrender with heading-style emphasis. - Everything else renders as plain text with a left border bar.
Colors adapt to your colorscheme and never break the render — if a color can't be resolved for any reason, the plugin falls back to plain, unstyled text.
Advanced settings (optional, click to expand)
Plain-style sections (heading + body) get a left border bar down the whole section.
Defaults to "▌"; override it with any single character. Sections listed in
buffer.editable get a distinct border color (default: orange, "#FFA500"), override
with editable_section_border_color:
buffer = {
fields = { --[[ ... ]] },
editable = { --[[ ... ]] },
style = {
section_border_character = '|',
editable_section_border_color = '#D1FF1B',
},
},Add a leading icon to a picker's result line based on a record's field value — useful for status/priority/assignee-style visual markers. Evaluated locally against already-fetched data (no extra requests). The first matching condition wins; no match means no icon.
pickers = {
{
name = 'Tickets',
result_line = {
{ field = 'Title' },
{ field = 'Status' },
},
result_line_prefix = {
-- condition uses the same shape as `filters`
{ '📝', { field = 'Status', value = 'In progress' } },
{ '▶️', { field = 'Status', value = 'To do' } },
{ '✅', { field = 'Status', value = 'Done', only = true } },
-- icon can also be a table to color it: { icon = '...', color = <hl group or hex> }
{ { icon = '', color = '#FFFFFF' }, { field = 'Status', value = 'Blocked' } },
},
},
},Instead of one fixed color per section, hl can be a list of { value, color } rules:
result_line = {
{
field = 'Status',
hl = {
{ value = 'To do', color = '#E32424' },
{ value = 'In progress', color = '#FFBF5E' },
{ value = 'Done', color = '#5F94E3' },
},
},
{ field = 'Title' },
},Add date_format explicitly to a buffer.fields or result_line entry to override the
auto-detected default:
| Value | Result |
|---|---|
date_format = 'datetime' (default) |
01/01/2026 - 21:09 |
date_format = 'date' (default) |
01/01/2026 |
date_format = 'time' (default) |
21:09 |
buffer = {
fields = {
{ key = 'title', field = 'Title' },
{ key = 'created_at', field = 'Created At', date_format = 'datetime' },
},
},
pickers = {
{
name = 'Tickets',
result_line = {
{ field = 'Title' },
{ field = 'Created At', date_format = 'time' }, -- override: show only "21:09"
},
},
},Override the display template for each mode globally. Placeholders: {DD}, {MM},
{YYYY}, {HH}, {mm}.
require('airtable').setup({
-- ...
date_formats = {
datetime = '{YYYY}-{MM}-{DD} {HH}h{mm}', -- default: '{DD}/{MM}/{YYYY} - {HH}:{mm}'
date = '{YYYY}-{MM}-{DD}', -- default: '{DD}/{MM}/{YYYY}'
time = '{HH}h{mm}', -- default: '{HH}:{mm}'
},
}):Airtable " open the default picker
:Airtable Open bugs " open a specific picker by name
:AirtableResume " reopen the last viewed record, or the default picker if noneTyping in the picker fuzzy-matches the visible result line by default (title, status,
whatever result_line shows). Prefix your query with -- to instead search the full
content of every buffer.fields value — description, notes, anything not shown in the
row — useful for finding a record by pasting a snippet, a feature flag name, or any text
that only appears in a long field. Entirely local (no extra API calls): it searches the
records already fetched for the current picker.
feature-flag-xyz " matches only if it's in the visible row
--feature-flag-xyz " matches anywhere in the record's configured fields
Pressing <CR> on a record opens an action menu: open in buffer, open in browser, copy
URL, and an "Edit <field>" action for every buffer.editable entry (same choices/text
flow as the record buffer below). Every action keeps the picker open except opening in a
buffer.
Inside a record buffer, press <CR> for quick actions: open in browser, browse comments,
copy the record's URL, refresh the buffer, or edit a field (if buffer.editable is
configured). With the cursor on a URL, o opens it and c copies it. With the cursor on
a section, e edits that field directly, same as the <CR> menu's "Edit" action. If the
section isn't listed in buffer.editable, it toasts instead of editing.
This writes to Airtable. Only fields you explicitly list in
buffer.editablecan ever be edited.
type = 'select'— opens a picker listing the field's valid choices. Pressing<CR>on a choice saves it immediately.type = 'text'— opens a large centered floating buffer prefilled with the field's current value.<C-CR>saves,qdiscards.
After a successful edit, the record buffer refreshes in place.
vim.keymap.set('n', '<leader>aa', function() require('airtable').open() end, { desc = 'Airtable' })
vim.keymap.set('n', '<leader>ab', function() require('airtable').open('Open bugs') end, { desc = 'Airtable: open bugs' })
vim.keymap.set('n', '<leader>ar', function() require('airtable').resume() end, { desc = 'Airtable: resume' })- Read-only by default. The only write operation is editing a field you've explicitly
listed in
buffer.editable. - No local caching — every command re-fetches from the API.
