Next.js Server Actions over Markdown CRM

Web & API Engineering intermediate Building with AI ~15 min read

Overview

Next.js 14’s App Router introduces server actions—functions that run on the server but can be invoked directly from client components without manually defining API routes. The agent platform uses server actions to build a CRM (Customer Relationship Management) system where all client data is stored as markdown files in a local Git repository. Each client record is a markdown file with YAML frontmatter for structured fields (name, email, status, last contact date) and a markdown body for free-form notes. Server actions handle loading, validating, updating, and migrating these records, while chokidar watches the filesystem for external changes and triggers UI refreshes.

This architecture inverts the typical CRM stack. Instead of a database, the data lives in plain files that can be edited with any text editor, diffed with standard Git tooling, and backed up by pushing to a remote repository. Server actions bridge the gap between this file-based storage and a modern React UI: the user interacts with forms and tables in the browser, and each mutation is a server action that reads the markdown file, applies the change, writes the file back, and commits it via simple-git. The result is a CRM with full version history, offline capability, and zero database infrastructure.

The chokidar filesystem watcher ensures the UI stays consistent when records are edited outside the web interface—for example, when a team member edits a client’s markdown file directly in their editor or when a Git pull brings in changes from another machine. The watcher detects file changes and emits events that trigger React Server Component revalidation, so the dashboard always reflects the current state of the filesystem.

Core Concepts

Next.js Server Actions

Server actions are async functions marked with the "use server" directive that execute on the server in response to form submissions or explicit client calls. They can access the filesystem, call databases, invoke external APIs—anything a server can do—without exposing an API endpoint. The framework serializes the function’s arguments from the client, sends them to the server over an internal RPC mechanism, executes the function, and returns the result. This eliminates the boilerplate of defining REST routes for every mutation while preserving the security boundary between client and server code.

Markdown as a Database

Each client record is a .md file with YAML frontmatter containing structured fields and a markdown body for notes. This format is human-readable, version-controllable, and editable with any text tool. The CRM’s data layer parses these files using a frontmatter parser (like gray-matter), validates the extracted fields against a Zod schema, and returns typed TypeScript objects. Writes reverse the process: update the in-memory object, serialize it back to frontmatter + markdown, and write the file.

Git-Backed Persistence with simple-git

simple-git is a Node.js library that wraps Git CLI commands in a promise-based API. The CRM uses it to auto-commit every record change with a descriptive commit message (e.g., “Update client: Acme Corp – status changed to active”). This gives every record a complete change history viewable with git log, enables rollback with git revert, and supports multi-machine sync via git push/git pull. The auto-commit happens inside the server action after the file write succeeds, ensuring atomicity between the file change and its Git record.

Chokidar Filesystem Watcher

chokidar monitors a directory for file changes (creation, modification, deletion) and emits events that server-side code can subscribe to. In the CRM, the watcher observes the client records directory and triggers Next.js cache revalidation when a file changes externally. This ensures the UI reflects changes made outside the web interface—a critical requirement for a file-based system where multiple tools may modify the same data.

Schema Migration for Markdown Records

As the CRM evolves, the set of frontmatter fields may change: new fields are added, old fields are renamed, types are adjusted. The migration system reads each markdown file, checks its schemaVersion frontmatter field, applies any pending migration transforms in order, and writes the updated file back. This is analogous to database migrations but operates on individual files rather than database tables, and each migrated file is auto-committed with a migration-specific commit message for traceability.

How It Works

Server Action for Loading Client Records

The getClients server action reads all markdown files from the clients directory, parses their frontmatter, validates the data, and returns an array of typed client objects. The "use server" directive ensures this code never ships to the browser.

'use server';

import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { z } from 'zod';

const CLIENTS_DIR = path.join(process.cwd(), 'data', 'clients');

const ClientSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  status: z.enum(['active', 'inactive', 'lead', 'churned']),
  lastContact: z.string().datetime(),
  schemaVersion: z.number().default(1),
});

export type Client = z.infer<typeof ClientSchema> & {
  slug: string;
  notes: string;
};

export async function getClients(): Promise<Client[]> {
  const files = await readdir(CLIENTS_DIR);
  const mdFiles = files.filter((f) => f.endsWith('.md'));

  const clients = await Promise.all(
    mdFiles.map(async (file) => {
      const raw = await readFile(path.join(CLIENTS_DIR, file), 'utf-8');
      const { data, content } = matter(raw);
      const validated = ClientSchema.parse(data);
      return {
        ...validated,
        slug: file.replace('.md', ''),
        notes: content.trim(),
      };
    })
  );

  return clients.sort((a, b) => a.name.localeCompare(b.name));
}

Server Action for Updating a Client

The updateClient action receives form data, validates it, writes the updated markdown file, and auto-commits the change with simple-git. It also calls revalidatePath to tell Next.js to re-render pages that depend on this data.

'use server';

import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import simpleGit from 'simple-git';
import { revalidatePath } from 'next/cache';

const git = simpleGit(process.cwd());

export async function updateClient(slug: string, formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const status = formData.get('status') as string;
  const notes = formData.get('notes') as string;

  const frontmatter = {
    name,
    email,
    status,
    lastContact: new Date().toISOString(),
    schemaVersion: 1,
  };

  const content = matter.stringify(notes, frontmatter);
  const filePath = path.join(process.cwd(), 'data', 'clients', `${slug}.md`);

  await writeFile(filePath, content, 'utf-8');
  await git.add(filePath);
  await git.commit(`Update client: ${name} – status changed to ${status}`);

  revalidatePath('/clients');
  revalidatePath(`/clients/${slug}`);
}

Chokidar Watcher for External Changes

A watcher process monitors the clients directory and triggers Next.js revalidation when files change outside the web UI. This runs as a side effect during server startup and ensures dashboard consistency with the filesystem.

import chokidar from 'chokidar';
import path from 'node:path';

const CLIENTS_DIR = path.join(process.cwd(), 'data', 'clients');

let watcher: chokidar.FSWatcher | null = null;

export function startClientWatcher(onFileChange: (filePath: string) => void) {
  if (watcher) return;

  watcher = chokidar.watch(CLIENTS_DIR, {
    ignoreInitial: true,
    awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
  });

  watcher.on('change', (filePath) => {
    console.log(`Client file changed externally: ${path.basename(filePath)}`);
    onFileChange(filePath);
  });

  watcher.on('add', (filePath) => {
    console.log(`New client file detected: ${path.basename(filePath)}`);
    onFileChange(filePath);
  });

  watcher.on('unlink', (filePath) => {
    console.log(`Client file deleted: ${path.basename(filePath)}`);
    onFileChange(filePath);
  });
}

Schema Migration Runner

The migration system scans all client files, checks their schema version, and applies transforms sequentially. Each migration is a pure function that takes frontmatter data and returns the updated version. Migrated files are written back and auto-committed.

import { readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import simpleGit from 'simple-git';

const git = simpleGit(process.cwd());

type Migration = {
  version: number;
  up: (data: Record<string, unknown>) => Record<string, unknown>;
};

const migrations: Migration[] = [
  {
    version: 2,
    up: (data) => ({
      ...data,
      tags: data.tags ?? [],
      schemaVersion: 2,
    }),
  },
  {
    version: 3,
    up: (data) => ({
      ...data,
      contactMethod: data.contactMethod ?? 'email',
      schemaVersion: 3,
    }),
  },
];

export async function migrateClients(): Promise<number> {
  const dir = path.join(process.cwd(), 'data', 'clients');
  const files = (await readdir(dir)).filter((f) => f.endsWith('.md'));
  let migrated = 0;

  for (const file of files) {
    const filePath = path.join(dir, file);
    const raw = await readFile(filePath, 'utf-8');
    const { data, content } = matter(raw);

    const currentVersion = (data.schemaVersion as number) ?? 1;
    const pending = migrations.filter((m) => m.version > currentVersion);

    if (pending.length === 0) continue;

    let updated = { ...data };
    for (const migration of pending) {
      updated = migration.up(updated);
    }

    const output = matter.stringify(content, updated);
    await writeFile(filePath, output, 'utf-8');
    await git.add(filePath);
    migrated++;
  }

  if (migrated > 0) {
    await git.commit(`Migrate ${migrated} client records to schema v${migrations.at(-1)!.version}`);
  }

  return migrated;
}

Practical Applications

The agent platform CRM demonstrates that a file-based, Git-backed data store can support a modern web application when the data volume is modest and the access patterns are simple. For solo practitioners and small teams managing dozens to a few hundred client records, this approach eliminates the operational overhead of running a database while gaining version history, offline access, and the ability to edit records with any text editor or IDE.

Server actions are the key enabler. Without them, building a Next.js application over a filesystem backend would require defining API routes for every CRUD operation, managing request/response serialization, and handling error propagation manually. Server actions reduce each operation to a single async function that reads and writes files directly, with the framework handling the client-server communication transparently.

The auto-commit pattern via simple-git turns every UI interaction into a Git commit, creating an audit trail that traditional CRMs only achieve with purpose-built event sourcing systems. A team can review the history of client interactions with git log -- data/clients/acme-corp.md, revert an accidental deletion with git revert, or sync changes between laptops with standard Git workflows. The markdown format also makes data portable: if the team outgrows this CRM, the data can be parsed and imported into any system that accepts structured input.

Implementation Patterns

Optimistic Locking via File Timestamps

When two users edit the same client record simultaneously, the last write wins—silently overwriting the first user’s changes. To prevent this, include the file’s modification timestamp in the form as a hidden field. Before writing, the server action checks whether the file’s current timestamp matches the submitted one. If it doesn’t, another process modified the file, and the action returns an error asking the user to refresh and retry.

Debounced Revalidation from Watcher Events

A single external operation (like a git pull) may modify many files in rapid succession. Revalidating the cache on every individual file change event wastes server resources and causes the UI to flicker. Use chokidar’s awaitWriteFinish option combined with a debounce on the revalidation callback to batch rapid changes into a single cache invalidation cycle.

Transactional Write + Commit

Wrap the file write and Git commit in a try/catch that reverts the file to its previous content if the commit fails. Without this, a write that succeeds but a commit that fails (due to a lock file conflict, for example) leaves the filesystem in a state that disagrees with the Git history. Store the original file content in memory before writing, and restore it on error.

Common Pitfalls

Further Reading