Skip to main content
The Local-First App Skill

The Local-First App Skill

A reusable blueprint that tells Claude Code how to build a single-user app -- Next.js, node:sqlite, a pure logic core -- so every app I build comes out the same shape.

July 9, 2026

I’ve built seven single-user apps with Claude Code. The first few came out shaped completely differently from each other – different database driver, different folder layout, modals in one and pages in another.

local-first-app is the skill I wrote to stop that. It’s a markdown document in my skillbox collection that records the decisions once, so Claude stops making them fresh every time:

  • One purpose, one user, one machine. A SQLite file sitting next to the app. No backend, no auth.
  • Every app builds into a native executable. One self-contained file I can drag onto any machine and run – no Node install, no setup, no “works on my laptop.”
  • Derived values live in a pure logic core. No React imports, no database imports. Tested first.

That executable rule drives more of the stack than you’d expect.

#The stack, pinned

LayerChoiceWhy
FrameworkNext.js App Router, output: "standalone"Compiles to a single desktop binary
UIMantineTables, dates, charts, forms, dark mode – all in the box
Persistencenode:sqliteRuntime builtin – no native addon, no per-platform builds
ValidationZod at every server boundaryOne schema per write path
Data fetchingnoneOne user, one machine – no client cache to invalidate
TestsVitestPure-core unit tests, test-first

Gotchas the skill remembers so I don’t have to:

  • SQLite foreign keys default to OFF, per connection – they have to be turned back on every time the database is opened.
  • Vitest needs a resolve shim.
  • A miswired next/font silently renders your entire app in fallback serif. Nothing errors. It just looks slightly wrong forever.

#How I actually use it

I don’t start in Claude. I start with a PRD.md.

Nothing formal – I write out what the app is, how I want to navigate it, and what entities I want to track. For a car tracker that looks like: I want to track cars, here are the datapoints I care about on a car (mileage, purchase info, insurance, service history), and I want cars tracked per garage. That last kind of sentence does a lot of work – it’s me telling the app what the relations are before any schema exists.

Then the prompt is short:

Read PRD.md. Use the local-first-app skill for the architecture,
the color-system skill for the palette, and the typography skill
for the type. Build the initial prototype.

That’s the whole first pass. local-first-app handles the shape of the app; color-system and typography are two other skillbox skills that keep it from coming out in the same default-Mantine look every time.

The second pass is where the actual app gets figured out. I add real data – actual cars, actual service records – and just use the thing for a while. That’s when the gaps show up: datapoints I specified and never fill in, datapoints I didn’t specify and keep wanting, charts I assumed I’d need and don’t. I write all of it into a new doc, changes only, and let Claude take another shot. Repeat until it stops bothering me.

The PRD is never right on the first pass. I’ve stopped trying to make it right on the first pass.

#The base UI

Every list, create, detail, and edit is an addressable route. No modals – with exactly one exception.

app/cars/
  page.tsx             list
  new/page.tsx         create
  [id]/page.tsx        detail
  [id]/edit/page.tsx   edit

The URL is the state. Back works, refresh works, deep links work, nothing gets lost mid-form.

Around the routes sits the same shell in every app: a sidebar for content areas, a navbar for the logo, search, and the theme toggle, plus multiple color themes.

The four screens below are all the same entity – a car.

#List

The list view: an index of cars with a single primary New action
  • One primary action.
  • The empty state gets designed before the first entity exists. A local-first database has zero rows on day one and there’s no seed data, ever – so the empty state is literally the first screen you see in every one of these apps. It’s not a polish task.

#Detail

The individual view: one car's fields plus its computed summary, with Edit and Delete
  • The entity’s fields plus its computed summary, with Edit and Delete where you’d expect them.
  • Delete is the one modal in the entire system. It pulls cascade counts from the loader and shows the blast radius – “also deletes 4 service records” – before it runs.
  • Don’t build a delete screen. Don’t delete without the count.

#Add and edit

The edit view: the create form arriving pre-populated with the car's values
  • One <EntityForm> with a mode prop. Edit is just the create screen arriving populated.
  • Both validate against the same Zod schema – the client uses it for inline errors, the server action re-parses it as the actual authority.
  • I never build two forms, so they never drift apart.

#Relations

The relations view: the car's child collections rendered as related lists with prefilled New links
  • Every child collection renders on the parent’s detail view as a related list with its own + New link that prefills the foreign key – add a service record from the car’s page and the form already knows which car it’s for.
  • It works in reverse: the garage’s detail view lists the cars in it.
  • Not just parent-child – entities can relate N:N, and the relationship shows up on both pages.

That “per garage” sentence from the PRD lands here. One line of intent turns into a foreign key, a related list, and a prefilled form, and I never had to specify any of it.

#Partial skeuomorphism

If an entity has a real-world shape, the UI borrows it.

EntityRenders as
Car dataa garage
Doctor, plumber, insurance agenta business card with the right icon
Credit carda credit card, colorized for the issuing bank
Gamea cartridge

The reason is scanning. Rows are efficient but they’re all identical – every entity flattens into the same grid and you end up reading column headers to remember what you’re even looking at. A card shaped like the thing it represents is distinct at a glance.

The “partial” is load-bearing: no leather stitching, no page-flip animations. Just enough of the real-world shape to anchor recognition, sitting on top of the same boring CRUD routes underneath.

#Using it

npx skills add antjanus/skillbox

Then describe the app, or point Claude at a PRD like I do. What comes out already has the screen routes, the shared form, the empty states, and the hardened database open – without you asking for any of it.

Version 1.6.0 after eleven days. Almost every bump came back from an app built with it.

Share this article

Enjoyed this? Get new posts delivered.

Join the newsletter for more articles on AI, development, and side projects.