Skip to content
← All posts

Spec-Driven Development in React Native: an MVP that lists the world's earthquakes, specified before writing code

I applied Spec-Driven Development to a React Native MVP that lists the world's earthquakes: the specification decides what goes in the list, and its acceptance criteria become the tests.

Illustration of Spec-Driven Development: a specification turns into acceptance criteria and from there into the earthquake list of a mobile app

A coding agent can already write a working app in an afternoon. The problem is no longer producing the code; it is something else: without a specification the agent doesn’t write worse code, it writes excellent code for the wrong problem. Every question you didn’t answer, it answers with a reasonable default, and you find out weeks later. Spec-Driven Development (SDD) attacks exactly that: write down first what the software must do, resolve in writing whatever is ambiguous, and treat the code as the output of that specification. I applied it to a React Native MVP built with Expo that does one thing—show a list of recent earthquakes anywhere in the world—because it is the kind of request that fits in one line and hides more decisions than it seems.

TL;DR
  • SDD separates three artifacts: the specification (what and why), the technical plan (how) and the tasks. The agent implements against them instead of against a loose prompt.
  • The real value is not the document: it is that ambiguities get flagged and resolved in writing before the agent chooses for you. Which earthquakes make the list, in what order, and what happens offline are product decisions, not code decisions.
  • Acceptance criteria are written as a table of cases, and that same table becomes the test. If the row is not in the table, the behavior is undefined.

In this article:

What Spec-Driven Development is and how it differs from prompting

A prompt is an instruction that gets consumed and disappears. A specification is a versioned file in the repository, reviewed in a pull request, that outlives the agent’s session. That is the whole difference, and it is bigger than it looks.

In the typical flow with an agent, you describe the feature in the chat and the result is a thousand lines of code plus a conversation nobody will reread. The important decisions—the minimum magnitude, how many hours back, what happens offline—were made in some intermediate turn of the chat. When someone asks why the app shows a magnitude 2.6 earthquake but not a 2.4, the answer is nowhere.

SDD splits the work into distinct artifacts, each at its own level of abstraction:

Specification    →  what must happen and why


Technical plan   →  how it gets built (stack, libraries, structure)


Tasks            →  verifiable units of work


Implementation   →  the code the agent writes against the above

The rule that orders everything is that each level talks about its own level. The specification avoids implementation decisions unless they are a real product constraint: “must work offline” or “data never leaves the device” are requirements even though they have technical consequences; “use TanStack Query” is not. The plan does name technology, but it doesn’t reopen product decisions. When that separation holds, you can switch stacks without rewriting the specification, and change a threshold without touching the plan.

There are tools that formalize this flow. GitHub’s Spec Kit installs a set of commands in your agent that produce exactly these artifacts in folders of the repository:

# Installs the flow in the project and picks the agent you are going to use.
uvx --from git+https://github.com/github/spec-kit.git specify init quakes-app

# From there, inside the agent:
#   /constitution  → project principles (apply to every feature)
#   /specify       → this feature's specification
#   /clarify       → resolves the ambiguities flagged in the spec
#   /plan          → the technical plan
#   /tasks         → the task breakdown
#   /implement     → executes the tasks

You don’t need the tool: three markdown files in specs/ and the discipline to maintain them are enough. What it adds is that the agent has the commands, the templates and the order already loaded, and doesn’t skip steps.

Why a list of earthquakes hides more decisions than it seems

The initial request was one sentence: “an app that lists the world’s earthquakes”. An agent can implement that in an afternoon, and the result will be plausible, will compile and will show a list. It will also be wrong, because the sentence hides at least six decisions:

  • What counts as “an earthquake”: seismic networks record far more events than a person can feel, most of them below magnitude 2. Do they all get listed?
  • What counts as “recent”: the last hour? the last day? the last week?
  • In what order: by time? by magnitude? Which goes first if two happen at the same time?
  • What to show for each one: magnitude, place, time, depth… in which time zone?
  • What happens offline: an empty list? the last one downloaded? with what notice?
  • What happens if the event changes: an earthquake’s magnitude gets revised after the automatic detection. Does it show up twice?

None of those questions is technical. All of them change the product. And all of them, if you don’t answer them, the agent answers silently: the list ends up showing four hundred magnitude 1 microquakes, and the only magnitude 6 of the day sits at position eighty.

The specification: the what before the how

The specification is one page long and doesn’t mention a single library. Its structure is the one almost every SDD template uses: context, user stories, numbered functional requirements, non-functional requirements, and what is out of scope.

# Specification: recent earthquakes around the world

## Context
The user wants to see, on a single screen, which significant earthquakes
happened around the world in the last day, without configuring anything
and without creating an account.

## User stories
- As a user, I want to open the app and see the world's recent
  earthquakes ordered newest to oldest, so I know what happened today.
- As a user, I want to tell at a glance which ones were strong, without
  reading every magnitude.
- As a user, I want the app to show me something useful even if I have
  no connection at that moment.

## Functional requirements
FR-1  The list shows earthquakes that occurred in the last 24 hours
      anywhere in the world, with magnitude greater than or equal to 2.5.
FR-2  Order is newest to oldest. At the same time, the higher magnitude
      goes first.
FR-3  Each row shows magnitude with one decimal, the epicenter's
      geographic reference, relative time ("12 min ago") and depth in km.
FR-4  Magnitude is classified into three visual levels:
      minor (< 4.0), moderate (4.0 to 5.9) and strong (>= 6.0).
FR-5  If the source publishes a revision of an already listed
      earthquake, the list shows the latest version and never the
      repeated event.
FR-6  Offline, the app shows the last downloaded list together with the
      time of the last successful update.
FR-7  Pull-to-refresh updates the list. The empty list and the network
      error each have their own message.

## Non-functional requirements
NFR-1 With a connection, the list is visible in under 3 seconds from
      opening the app.
NFR-2 The app asks for no account and no system permission.

## Out of scope (v1)
- Notifications. They require deciding what is "relevant" for each
  user and a server that watches the source; that is another feature.
- User location and "earthquakes near me".
- Map, filters by country or magnitude, history beyond 24 h.

## Needs clarification
- [CLARIFY] Is the time shown in UTC or in the device's time zone?
- [CLARIFY] What do we do with events the source publishes without a
  magnitude yet?

The two most valuable blocks are the last ones. Out of scope is the most effective tool I know for keeping an MVP an MVP: the original request also mentioned alerts when an earthquake happened near the user, and writing it down as excluded keeps the agent from implementing it “while we’re at it”. Needs clarification is what separates SDD from any other requirements document: the ambiguity is flagged and blocks that part until someone decides.

Both were resolved like this, and the decision was written into the specification itself:

  • Time zone: the device’s, in relative format (“12 min ago”, “3 h ago”). The absolute UTC time goes on the detail screen, which doesn’t exist in v1.
  • Events without magnitude: not listed. An event without a magnitude is a record the network hasn’t processed yet, and showing it as “M ?” confuses more than it informs. As soon as the source publishes it with a magnitude, it comes in through FR-5 like any other.

The difference between “we didn’t think about it” and “we thought about it and left it out” isn’t visible on day one. It becomes visible when someone asks and the answer is in the file.

The technical plan: how it gets built in React Native

Only in the plan does technology appear. This document answers the how and doesn’t reopen the what: if a product question comes up while writing it, it goes back to the specification.

DecisionChoiceReason
FrameworkReact Native with ExpoOne codebase, no native configuration for an MVP
Data sourceUSGS public GeoJSON feedNo API key, worldwide coverage, updated every minute
Server stateTanStack QueryCache, retries, revalidation on returning to the foreground
PersistenceTanStack Query cache persisted in AsyncStorageCovers FR-6 without a database of our own
ListFlatList with RefreshControlCovers FR-7 with platform components
Catalog logicPure module with no RN dependenciesTestable without a simulator

The last row paid off the most. Everything FR-1, FR-2, FR-4 and FR-5 say takes a list of events and returns another one, without touching the screen or the network. In a pure module it runs under node in milliseconds, no emulator.

GeoJSON feed (USGS)


  Data client ──► normalizes to {id, mag, place, time, updated, depth}


  Catalog module (pure)

        ├── drops no-magnitude, < 2.5 or outside 24 h        (FR-1)
        ├── resolves revisions: highest `updated` wins       (FR-5)
        ├── sorts by time desc, then magnitude desc          (FR-2)
        └── assigns level: minor / moderate / strong         (FR-4)


  TanStack Query (persisted cache) ──► FlatList
                                         ├── with data ──► rows
                                         ├── no data ──► empty state
                                         └── error + cache ──► old list + time (FR-6)

One detail of the plan came out of a non-functional requirement. NFR-1 asks for the list to be visible in under three seconds, and the USGS “all events of the day” feed carries thousands of events that FR-1 was going to discard. Downloading that on a mobile network put NFR-1 at risk for no gain, so the plan picked the feed that already comes filtered to 2.5 and 24 hours. The pure module’s filter stays: it is the guarantee of FR-1 regardless of which feed sits behind it.

From the specification to the tests: the acceptance table

This is where SDD stops being documentation and starts being engineering. FR-1 is not written in prose only: it is written as a table of cases with the expected result.

#MagnitudeOccurredExpectedWhy
12.41 h agoOutBelow the minimum magnitude
22.51 h agoInExactly at the minimum
35.023 h 59 min agoInJust inside the window
45.024 h 1 min agoOutOutside the window by 1 min
5no magnitude1 h agoOutEvent not processed yet
66.130 h agoOutOld, even if strong
75.02 min in the futureOutDevice clock behind, or corrupt data

Rows 2, 3 and 4 are the ones an agent won’t write unless you ask: the exact edges. Row 6 was the most debated—a magnitude 6.1 sounds like “that has to be there”—and it is where the specification wins: not listing it is deliberate, it is written down, and if tomorrow the window becomes 48 hours, you change the row and the test fails on its own. Row 7 wasn’t in the first version; further down I tell where it came from.

FR-2 and FR-5 get their own table, because what is being tested is a sequence, not a value:

#InputExpected
8A (10
, M 4.0), B (11
, M 3.0)
B, A
9A (10
, M 4.0), C (10
, M 5.2)
C, A
10A (10
, M 4.0), A’ (same id, M 4.6, revised later)
only A’ with M 4.6

The rule in code is a pure module. “Now” comes in as a parameter so the test doesn’t depend on the clock:

// src/domain/catalog.ts

const MIN_MAG = 2.5;
const WINDOW_MS = 24 * 60 * 60 * 1000;

export type Quake = {
  id: string;
  mag: number | null;
  place: string | null;
  time: number; // epoch ms
  updated: number; // epoch ms
  depth: number; // km
};

export type Level = "minor" | "moderate" | "strong";

// FR-4: three visual levels by magnitude.
export function level(mag: number): Level {
  if (mag >= 6.0) return "strong";
  if (mag >= 4.0) return "moderate";
  return "minor";
}

// FR-1, FR-2 and FR-5 in a single function, touching neither network nor screen.
export function prepareList(quakes: Quake[], now: number): Quake[] {
  // FR-5: if an id shows up more than once, keep the most recent revision.
  const byId = new Map<string, Quake>();
  for (const e of quakes) {
    const prev = byId.get(e.id);
    if (!prev || e.updated > prev.updated) byId.set(e.id, e);
  }

  return [...byId.values()]
    // FR-1: has a magnitude, >= 2.5, and occurred within the last 24 hours.
    // "Occurred" excludes the future: an event timed after `now` stays out.
    .filter((e) => {
      const age = now - e.time;
      return e.mag !== null && e.mag >= MIN_MAG && age >= 0 && age <= WINDOW_MS;
    })
    // FR-2: newest first; at the same time, higher magnitude first.
    .sort((a, b) => b.time - a.time || (b.mag ?? 0) - (a.mag ?? 0));
}

And the test is the table, row by row. Add a row to the specification, add a row to the array:

// src/domain/catalog.test.ts
import { describe, expect, it } from "vitest";
import { prepareList, type Quake } from "./catalog";

const NOW = Date.UTC(2026, 8, 5, 12, 0, 0);
const MIN = 60 * 1000;

// Builds an event that occurred N minutes ago (negative = in the future).
function minutesAgo(min: number, mag: number | null, id = "e"): Quake {
  const time = NOW - min * MIN;
  return { id, mag, place: null, time, updated: time, depth: 10 };
}

// Each row matches one case of the FR-1 acceptance table.
const CASES = [
  { n: 1, mag: 2.4, min: 60, included: false },
  { n: 2, mag: 2.5, min: 60, included: true },
  { n: 3, mag: 5.0, min: 24 * 60 - 1, included: true },
  { n: 4, mag: 5.0, min: 24 * 60 + 1, included: false },
  { n: 5, mag: null, min: 60, included: false },
  { n: 6, mag: 6.1, min: 30 * 60, included: false },
  { n: 7, mag: 5.0, min: -2, included: false },
];

describe("FR-1 what makes the list", () => {
  for (const c of CASES) {
    it(`case ${c.n}: mag ${c.mag} ${c.min} min ago -> ${c.included}`, () => {
      expect(prepareList([minutesAgo(c.min, c.mag)], NOW).length === 1).toBe(c.included);
    });
  }
});

// Cases 8 and 9 have the same shape as this one; omitted for brevity.
it("case 10: a revision replaces the event, it doesn't duplicate it", () => {
  const a = minutesAgo(120, 4.0, "A");
  const revised = { ...a, mag: 4.6, updated: a.updated + 5 * MIN };
  expect(prepareList([a, revised], NOW)).toEqual([revised]);
});

Changing the product becomes an operation of two symmetric edits: the row in the specification and the row in the test. The code adapts or fails.

Implementing against the specification with an agent

With the specification and the plan written, the task breakdown is almost mechanical, and that is the sign that both documents are right. Each task is verifiable on its own:

T-01  Catalog module (FR-1, FR-2, FR-4, FR-5) + tests for the tables
T-02  GeoJSON feed client with normalization to the internal model
T-03  Data hook with persisted cache (FR-6)
T-04  List screen: rows (FR-3), levels (FR-4), refresh (FR-7)
T-05  Empty, error and offline states (FR-6, FR-7)

T-01 goes first because it doesn’t depend on React Native: it verifies on its own, and the rest of the code rests on an already tested piece. I didn’t ask the agent for “an earthquake app”, but for each task with its reference to the requirement. The instruction for T-02 fit in three lines because the context was in the files:

Implement T-02 as described in specs/quakes/plan.md.
Return Quake[] using the type defined in src/domain/catalog.ts, without filtering anything.
Filtering belongs to the catalog module (T-01); don't duplicate it.

The resulting client only normalizes the GeoJSON into the Quake type. Where there is a decision is in the hook that joins it with the list: that is where FR-6 gets met without a database, because the TanStack Query cache is persisted and, when the fetch fails, the screen keeps the last data and the time it arrived.

// src/hooks/useQuakes.ts
import { useQuery } from "@tanstack/react-query";
import { fetchQuakes } from "../data/usgs";
import { prepareList } from "../domain/catalog";

export function useQuakes() {
  const q = useQuery({
    queryKey: ["quakes"],
    queryFn: fetchQuakes,
    // The persisted cache (T-03) survives restarts; this is what meets FR-6.
    staleTime: 60 * 1000,
    // FR-1: "now" is taken when the list is prepared, not when it is downloaded.
    select: (quakes) => prepareList(quakes, Date.now()),
  });

  return {
    quakes: q.data ?? [],
    refreshing: q.isFetching,
    error: q.error,
    // FR-6: time of the last successful download, shown next to the stale list.
    lastUpdatedAt: q.dataUpdatedAt ? new Date(q.dataUpdatedAt) : null,
    refresh: q.refetch, // FR-7
  };
}

The comments cite requirements; they don’t explain the code. It was the most useful part of the exercise: when the agent comes back to the file three tasks later, every block tells it which rule it answers to, and a human review checks coverage by reading the comments against the specification.

When real data contradicts the specification

The specification is not immutable. The moment the implementation touches real data, things the document didn’t foresee show up, and that is where you see whether the process holds. In this project it happened three times, and the third was while writing this article.

The first, with the epicenter reference. FR-3 says each row shows “the epicenter’s geographic reference”, and takes for granted that there always is one. In the real feed, the field comes in a format of its own (“58 km SSW of Puerto Ayora, Ecuador”) and for events in the middle of the ocean it comes empty. Solving it in code would have buried two product decisions inside the data client, invisible to anyone reading the specification. I went back to the document:

FR-3b The epicenter reference is shown exactly as the source publishes
      it. If it comes empty, the row shows the coordinates with two
      decimals ("-0.74, -90.32").

With that written, the source’s wording stopped being an oversight and became a dated decision, reviewable the day someone wants to localize it.

The second was the “all events” feed against NFR-1, which I already told: the requirement was right and the default source didn’t allow it. The resolution was to change the plan, not lower the requirement or touch the specification.

The third I found while reviewing the catalog module for this post, and it is the one that best explains the thesis. FR-1 says “occurred in the last 24 hours”. The first version of the filter was now - e.time <= WINDOW_MS, which lets through any event timed in the future, because a negative subtraction is always below the window. A device clock running behind, or corrupt data, put the event at the top of the list. It is case 7 in the table: the word “occurred” already excluded it, but I hadn’t written it as a row, so nobody tested it. And looking at that code, a second thing showed up: deduplication keeps the most recent revision and then filters by magnitude, so if an event with a magnitude receives a revision without one, it disappears from the list. It looks like a technical detail. It isn’t: it is the question of whether an incomplete revision erases good information, and product decides that. It ended up like this:

FR-5b A revision without a magnitude replaces the event like any other:
      the event leaves the list until the source publishes it again with
      a magnitude. The list never shows data the source has withdrawn.

With one more row in the table and one more test. The decision could have gone the other way; what matters is that it is now written down.

The pattern repeated in all three: when the implementation doesn’t fit the specification, it is almost never that the requirement is one too many. It is that there is a requirement nobody wrote, and the code was filling it with a default. Writing it takes five minutes and turns a buried decision into a reviewable one.

The criterion for deciding where to change: if the contradiction is about what must happen, fix the specification and the code follows. If it is about how it gets done, fix the plan and leave the specification alone. Confusing the two is what makes specifications go stale and people stop reading them.

What part of a mobile app cannot be specified

What specifies well is everything with a verifiable answer: thresholds, time windows, order, data formats, offline behavior, what gets stored and for how long. The whole part of the MVP that decides what to show fits in the specification, which is why the acceptance tables cover the core of the product.

What cannot be specified is visual and interaction judgment. FR-4 says there are three magnitude levels, but it doesn’t say what color each one is or how a “strong” row looks next to a “minor” one, and that gets decided by looking at the screen. In practice a clear split emerged: the catalog logic was implemented against the specification and verified with tests; the screen was built with the agent iteratively, looking at the result and correcting.

There is also a real cost. Keeping the artifacts in sync is work, and an outdated specification is worse than none: it gives false confidence. The rule that worked for me is to treat it like code: no pull request that changes a product rule gets merged without the matching change in the specification, just as it wouldn’t get merged without tests. Without that discipline, in two months the files in specs/ describe a product that no longer exists.

When NOT to use Spec-Driven Development

  • Prototypes and proofs of concept: if the goal is to find out whether something is worth it, specifying first means writing the wrong document. Explore first, specify once you know what to build.
  • Small, local changes: fixing a label or a margin doesn’t need three artifacts. The cost of the process has to be lower than the cost of the mistake.
  • Purely visual work: if the task is polishing a screen, iterating on the result wins.
  • Projects with nobody to decide: SDD forces product questions to be answered. If there is nobody to answer them, the clarification block stays unresolved and the process stalls.

SDD pays off when two conditions meet: there are rules with real edge cases, and more than one person—or more than one agent session—touches the same code. A list of earthquakes, small as it is, meets both. A landing page, neither.

Frequently asked questions

How is Spec-Driven Development different from writing plain old requirements?

The artifact looks alike; the lifecycle doesn’t. A requirements document gets written once, approved and filed away. In SDD the specification lives in the repository, is versioned with the code, and feeds the agent and the tests. If you can change a rule without touching the file, you are not doing SDD.

Do I need a tool like Spec Kit or Kiro to apply SDD?

No. Three markdown files in specs/<feature>/ and the discipline to maintain them cover most of the benefit. What the tools add is that the agent doesn’t skip the clarification step or start writing code before the plan. It is order, not capability.

Does SDD make sense for such a small MVP?

Precisely because it is small: one page of specification costs little and the benefit shows in the first week. An MVP is where it is most tempting to let the agent decide everything, and where it is hardest afterwards to dig up why the app does what it does.

What if the agent ignores the specification?

It happens, especially when the specification is long or contradictory. Two things reduce it: small tasks that cite concrete requirements, and acceptance criteria as executable tests. The second is decisive, because it turns “the agent drifted” into a red test, a problem with a mechanical fix.

Conclusion

Agents already write code fast. The work now is keeping them from writing, with full precision, the wrong interpretation. Spec-Driven Development forces product decisions to be made and written down before the code makes them by default. In the earthquake MVP the benefit wasn’t the document but its consequences: the flagged ambiguities, the scope defended in writing, the acceptance tables turned into tests, and a pure module where everything that can be wrong without failing to compile lives.

If you are going to start, do it in this order: write the specification without implementation decisions, write down what is out of scope, flag what is undecided and resolve it before moving on, turn the acceptance criteria into a table of cases with their edges, and only then let the agent implement task by task. And when real data contradicts the specification, fix the document instead of hiding the rule in the code.

Keep reading