Writing SCSS for Airtable Custom Blocks, When the Platform Only Takes a CSS String

Three styling APIs, none of them compile anything — so the compiling has to happen somewhere else.

airtable-build-styles is a small CLI tool solving a specific problem: at the time I built this, Airtable Custom Blocks had no way to import SCSS — then the most popular way to write CSS — directly into a block's source. This tool watches a .scss file, compiles it, and hands the result to the block in a form the platform can actually use.

What Airtable Custom Blocks Actually Give You

Blocks run inside Airtable's own page, not a standalone app, so they can't just link a stylesheet the normal way. The @airtable/blocks/ui package exposes exactly three functions for getting CSS or JS onto the page, and none of them compile anything:

  • loadCSSFromString(css: string): HTMLStyleElement — injects a literal CSS string as a <style> tag. Plain CSS text only; it has no idea what SCSS is.
  • loadCSSFromURLAsync(url: string): Promise<HTMLLinkElement> — fetches a stylesheet from a remote URL and injects it as a <link> tag. Still just CSS, and it has to already be hosted somewhere public.
  • loadScriptFromURLAsync(url: string): Promise<HTMLScriptElement> — the same idea, for a remote script.

Every one of these wants either a finished CSS string or a public URL. There's no fourth option that reads a .scss file from your project and compiles it for you. If you wanted SCSS's nesting, variables, and mixins, something outside the Blocks API itself had to do the compiling.

The Workaround: Compile SCSS Into an Importable JS Module

A block's own build step (the Blocks CLI's bundler) can import a plain .js file just fine — that part was never the problem. So the fix doesn't fight the platform: it compiles SCSS to CSS ahead of time, then wraps that CSS as a string inside a real JS module, which the block imports like any other source file and hands straight to loadCSSFromString.

A chokidar watcher triggers node-sass on every save, and the compiled CSS gets written out as a JS file exporting a template literal:

chokidar.watch(sourceFileDir + "/**/*.scss").on('all', (event, path) => {
  const result = sass.renderSync({
    file: sourceFile,
    outFile: destinationCssFile
  });

  let start = 'export default `';
  let end = '`';
  const jsString = start + result.css + end;

  fs.writeFile(destinationJsFile, jsString, (err) => {
    if (err) {
      console.log('Error: JS file creation failed');
      console.log(err);
      return;
    }
    console.log("Success: JS file created");
  });
});

That's the whole trick: main.scss becomes main.js, containing nothing but export default `<compiled CSS>`. A plain JS module, importable anywhere, carrying compiled CSS as its entire payload.

Using It

Point the CLI at a source SCSS file inside your block:

node buildStyles "path_to_source_scss_file"
// Eg: node buildStyles ../my_awesome_custom_block/frontend/styles/main.scss

// This creates:
//   ../my_awesome_custom_block/frontend/styles/main.css
//   ../my_awesome_custom_block/frontend/styles/main.js

Then import the generated module and pass its default export straight into loadCSSFromString:

import styles from '../styles/main.js';
import { loadCSSFromString } from '@airtable/blocks/ui';

loadCSSFromString(styles);

Because the watcher keeps running, every subsequent save to any .scss file in that directory recompiles main.js automatically — and since the Blocks CLI's own dev server watches your source files too, saving the SCSS ends up hot-reloading the block in Airtable, even though SCSS itself was never something the platform could read.

The Full Picture

  1. Write styles normally, in SCSS, inside the block's source
  2. Run the CLI once, pointed at that file — it keeps watching in the background
  3. Every save triggers node-sass, producing a fresh .css file and a matching .js module that wraps it as a string
  4. The block imports that .js module like any other source file — no build-config changes to the block itself
  5. loadCSSFromString takes the exported string and injects it, exactly as if it had been handed hand-written CSS all along

None of Airtable's three styling APIs changed to support this — the trick is entirely in producing what they already expect, from source they don't.