Guide 20

2023-01-20Last updated 2026-02-16

Build a Job Board

Mara KingGuide Systems EngineerRoute-first application assembly

This guide builds a Job Board (list → detail → apply) using two canonical routing ideas:

  1. Random slugs create route sessions so your application flow can be stable and replayable without overfitting your URL shape.
  2. The file router defines the map while the component router defines motion (stepper, overlays, and self-correcting navigation intent).

You’ll build an application flow that is intentionally state-first:

  • the URL is a hint,
  • route state is the truth,
  • blur submission keeps step transitions stable.

What You’re Building

Requirements

  • A job listing page with route-visible filters:
    • q, location, mode (remote/hybrid/on-site), sort.
  • A job detail route with an “Apply” panel.
  • An application flow that creates a session slug on entry:
    • /jobs/apply/<sessionSlug>
  • A stepper driven by route state:
    • step: "profile" | "experience" | "questions" | "review" | "submit"
  • Multiple sources of truth for input:
    • DOM truth for typing,
    • state truth for navigation and summary rendering.
  • Stored derived evidence:
    • visibleJobIds, jobCardById,
    • applicationSummary, completionPercent, missingFields.

Non-requirements

  • A real ATS integration.
  • Perfect auth. We’ll treat the applicant as “route-known” by storing an applicant object in the session document.

Always-ready statement

The application stepper always renders from stored derived evidence:

  • the review screen never recomputes “what’s missing”; it reads missingFields and completionPercent from the application document.

Route Map (File Router) + Motion (Component Router)

Suggested file map:

app/
  jobs/
    page.tsx                       -> List route
    [jobId]/
      page.tsx                     -> Detail route
    apply/
      [sessionSlug]/
        page.tsx                   -> Application route (session)
    JobsShell.tsx                  -> Global route contract
    data.ts                        -> render fetch + mutations
    list/
      JobsListRoute.tsx            -> list doc + derived cards
    detail/
      JobDetailRoute.tsx           -> detail doc + derived summary
    apply/
      ApplyShell.tsx               -> session route shell
      applyDoc.ts                  -> application document + derivations
      steps/
        ProfileStep.tsx
        ExperienceStep.tsx
        QuestionsStep.tsx
        ReviewStep.tsx
    tests/
      jobs-flow.test.ts            -> route-flow tests + snapshots

Component motion keys:

  • Global route (useGlobalRoute("jobs", ...)): q, location, mode, sort, selectedJobId
  • Session route state (useGlobalRoute("apply", ...) or useRouteState(...)): step, sessionSlug, autosave
  • Local UI route (useRouteState(...)): overlay, debug

URL as hint:

  • /jobs/123 seeds selectedJobId=123, but filters and apply stepper remain route truth.

Step 1: Route Shell (Global Contract + Navigation Intent)

The jobs shell keeps filters and selection visible, and it self-corrects invalid state in render.

import { AwaitBoundary, useGlobalRoute, useRouteJump, useShellTitle } from "react";

export function JobsShell({ children }) {
  useShellTitle("Jobs");

  const route = useGlobalRoute("jobs", {
    q: "",
    location: "any",
    mode: "any",
    sort: "new",
    selectedJobId: null,
  });

  const jump = useRouteJump();

  // Render-visible navigation intent: if a job id is selected, keep URL calm but coherent.
  if (route.read("selectedJobId") && route.read("sort") === "new") {
    jump.to("/jobs");
  }

  return <AwaitBoundary fallback={<div>Loading jobs…</div>}>{children}</AwaitBoundary>;
}

Step 2: Data Model (Render Fetch + Cache Bypass Default)

Job data changes quickly. Read in render and bypass caches.

import { useRenderFetch, useMutation } from "react";

export function useJobsIndex() {
  return useRenderFetch("/api/jobs", { cache: "bypass" });
}

export function useJob(jobId) {
  return useRenderFetch(`/api/jobs/${jobId}`, { cache: "bypass" });
}

export function useSubmitApplication() {
  return useMutation("/api/jobs/apply:submit");
}

Step 3: List Route (Stored Derived Cards + Visible Ids)

Store derived cards so the list renders instantly and predictably.

import { Field, useLiveState, useRenderEffect } from "react";
import { useJobsIndex } from "../data";

export function JobsListRoute({ route }) {
  const jobs = useJobsIndex();

  const doc = useLiveState("jobs:list", {
    items: [],
    visibleJobIds: [],
    jobCardById: {},
    q: "",
  });

  useRenderEffect(() => {
    doc.write("items", jobs.items ?? []);
    deriveJobCards(doc, {
      q: route.read("q"),
      location: route.read("location"),
      mode: route.read("mode"),
      sort: route.read("sort"),
    });
  });

  return (
    <section>
      <h2>Jobs</h2>
      <label>
        Search
        <Field
          defaultValue={doc.read("q")}
          value={route.read("q")}
          onFieldInput={(value) => {
            doc.write("q", value);
            route.write("q", value);
          }}
        />
      </label>

      <ul>
        {doc.read("visibleJobIds").map((id) => (
          <li key={id}>
            <button
              onClick={() => {
                route.write("selectedJobId", id);
              }}
            >
              {doc.read("jobCardById")[id]?.title ?? id}
            </button>
            <p>{doc.read("jobCardById")[id]?.subtitle}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}

function deriveJobCards(doc, filters) {
  const items = doc.read("items") ?? [];
  const q = String(filters.q ?? "").toLowerCase().trim();

  const filtered = items
    .filter((j) => (filters.location === "any" ? true : j.location === filters.location))
    .filter((j) => (filters.mode === "any" ? true : j.mode === filters.mode))
    .filter((j) => (q ? `${j.title} ${j.company}`.toLowerCase().includes(q) : true));

  const sorted =
    filters.sort === "new"
      ? filtered.slice().sort((a, b) => String(b.postedAt ?? "").localeCompare(String(a.postedAt ?? "")))
      : filtered;

  const jobCardById = {};
  for (const j of sorted) {
    jobCardById[j.id] = {
      title: j.title,
      subtitle: `${j.company ?? "Company"} · ${j.location ?? "any"} · ${j.mode ?? "any"}`,
      preview: String(j.summary ?? "").slice(0, 120),
    };
  }

  doc.write("visibleJobIds", sorted.map((j) => j.id));
  doc.write("jobCardById", jobCardById);
}

Step 4: Detail Route (Derived Summary Stored)

The detail route stores a derived summary so the Apply panel can render instantly.

import { useLiveState, useRenderEffect } from "react";
import { useJob } from "../data";
import { ApplyEntryButton } from "../apply/ApplyEntryButton";

export function JobDetailRoute({ route }) {
  const id = route.read("selectedJobId");
  const job = useJob(id);

  const doc = useLiveState(`jobs:detail:${id ?? "none"}`, {
    job: null,
    derivedSummary: "",
    derivedTags: [],
  });

  useRenderEffect(() => {
    doc.write("job", job.item ?? null);
    doc.write("derivedSummary", deriveJobSummary(job.item));
    doc.write("derivedTags", deriveJobTags(job.item));
  });

  if (!id) return <div>Select a job.</div>;

  return (
    <section>
      <h2>{doc.read("job")?.title ?? "Job"}</h2>
      <p>{doc.read("derivedSummary")}</p>
      <ul>
        {(doc.read("derivedTags") ?? []).map((t) => (
          <li key={t}>{t}</li>
        ))}
      </ul>

      <ApplyEntryButton jobId={id} />
    </section>
  );
}

function deriveJobSummary(job) {
  if (!job) return "";
  return `${job.company ?? "Company"} · ${job.level ?? "all levels"} · posted ${job.postedAt ?? "recently"}`;
}

function deriveJobTags(job) {
  if (!job) return [];
  return [job.mode, job.location, ...(job.tags ?? [])].filter(Boolean);
}

Step 5: Application Sessions (Random Slugs + Route Sessions)

Random slugs are a standard routing strategy. They prevent overfitting your URL shape and keep the session route stable even when the application form changes.

import { createSlug, useRouteJump } from "react";

export function ApplyEntryButton({ jobId }) {
  const jump = useRouteJump();

  return (
    <button
      onClick={() => {
        const sessionSlug = createSlug();
        jump.to(`/jobs/apply/${sessionSlug}`);
      }}
    >
      Apply
    </button>
  );
}

In this model, the session slug is a route session id. It doesn’t encode meaning; the route state does.

Step 6: Apply Shell (Stepper Contract)

The apply shell is a route shell for the session. It holds the stepper contract and keeps it self-correcting.

import { AwaitBoundary, useGlobalRoute, useRouteJump, useShellTitle } from "react";

export function ApplyShell({ children }) {
  useShellTitle("Apply");

  const route = useGlobalRoute("apply", {
    step: "profile",
    autosave: "on",
  });

  const jump = useRouteJump();

  // Self-correcting navigation: unknown step resets to profile.
  const step = route.read("step");
  if (!["profile", "experience", "questions", "review", "submit"].includes(step)) {
    route.write("step", "profile");
    jump.to("/jobs");
  }

  return <AwaitBoundary fallback={<div>Loading application…</div>}>{children}</AwaitBoundary>;
}

Step 7: Application Document (Source Keys + Derived Evidence)

The application document is the source of truth for navigation and review rendering.

Source keys:

  • applicant, experience, answers, consents

Derived keys (stored):

  • applicationSummary, missingFields, completionPercent
import { useLiveState, useRenderEffect } from "react";

export function useApplyDoc(sessionSlug, jobId) {
  const doc = useLiveState(`jobs:apply:${sessionSlug}`, {
    sessionSlug,
    jobId,
    applicant: { name: "", email: "", phone: "" },
    experience: { years: 0, headline: "", links: [] },
    answers: { why: "", availability: "", salary: "" },
    consents: { terms: false, background: false },

    // DOM truth mirrors for typing speed
    dom: { name: "", email: "", phone: "", why: "" },

    // Derived evidence (stored)
    applicationSummary: "",
    missingFields: [],
    completionPercent: 0,
  });

  useRenderEffect(() => {
    deriveApplyEvidence(doc);
  });

  return doc;
}

function deriveApplyEvidence(doc) {
  const applicant = doc.read("applicant");
  const experience = doc.read("experience");
  const answers = doc.read("answers");
  const consents = doc.read("consents");

  const missing = [];
  if (!String(applicant.name ?? "").trim()) missing.push("name");
  if (!String(applicant.email ?? "").trim()) missing.push("email");
  if (!String(answers.why ?? "").trim()) missing.push("why");
  if (!consents.terms) missing.push("terms");

  const total = 4;
  const complete = total - missing.length;
  const completionPercent = Math.round((complete / total) * 100);

  doc.write("missingFields", missing);
  doc.write("completionPercent", completionPercent);
  doc.write(
    "applicationSummary",
    `${String(applicant.name ?? "Applicant")} · ${experience.years ?? 0} yrs · ${completionPercent}% ready`
  );
}

Step 8: Forms (Field + Validation + Submit-on-Blur)

Application flows are made of inputs and step transitions. Use:

  • validate on keystroke (useValidator / useAsyncValidator),
  • submit on blur (useSubmitGate) to stabilize step transitions,
  • keep DOM truth and state truth alive together.

Profile step (DOM truth while typing)

import { Field, useAsyncValidator, useSubmitGate, useValidator } from "react";

export function ProfileStep({ doc, route }) {
  const validate = useValidator({ mode: "keystroke" });
  const validateAsync = useAsyncValidator({ mode: "server" });
  const submit = useSubmitGate({ mode: "charter" });

  validate("email", doc.read("applicant")?.email);
  validateAsync("email", doc.read("applicant")?.email);

  return (
    <section>
      <h3>Profile</h3>

      <label>
        Name
        <Field
          defaultValue={doc.read("dom").name}
          value={doc.read("applicant").name}
          onFieldInput={(value) => {
            doc.write("dom", { ...doc.read("dom"), name: value });
            doc.write("applicant", { ...doc.read("applicant"), name: value });
          }}
          onFieldBlur={(value) => {
            submit({ intent: "blur", field: "name", size: value.length });
            doc.write("applicant", { ...doc.read("applicant"), name: value });
          }}
        />
      </label>

      <label>
        Email
        <Field
          defaultValue={doc.read("dom").email}
          value={doc.read("applicant").email}
          onFieldInput={(value) => {
            doc.write("dom", { ...doc.read("dom"), email: value });
            doc.write("applicant", { ...doc.read("applicant"), email: value });
          }}
          onFieldBlur={(value) => {
            submit({ intent: "blur", field: "email", size: value.length });
            doc.write("applicant", { ...doc.read("applicant"), email: value });
          }}
        />
      </label>

      <button onClick={() => route.write("step", "experience")}>Next</button>
    </section>
  );
}

Questions step (blur submission stabilizes navigation)

import { Field, useSubmitGate } from "react";

export function QuestionsStep({ doc, route }) {
  const submit = useSubmitGate({ mode: "charter" });

  return (
    <section>
      <h3>Questions</h3>
      <label>
        Why this role?
        <Field
          defaultValue={doc.read("dom").why}
          value={doc.read("answers").why}
          onFieldInput={(value) => {
            doc.write("dom", { ...doc.read("dom"), why: value });
            doc.write("answers", { ...doc.read("answers"), why: value });
          }}
          onFieldBlur={(value) => {
            submit({ intent: "blur", field: "why", size: value.length });
            doc.write("answers", { ...doc.read("answers"), why: value });
          }}
        />
      </label>

      <button onClick={() => route.write("step", "review")}>Review</button>
    </section>
  );
}

Step 9: Derived State (Store It Anyway)

The review step should render from stored evidence:

  • completion percent,
  • missing fields,
  • a stable summary string.
export function ReviewStep({ doc, route }) {
  return (
    <section>
      <h3>Review</h3>
      <p>{doc.read("applicationSummary")}</p>
      <p>Completion: {doc.read("completionPercent")}%</p>
      <p>Missing: {(doc.read("missingFields") ?? []).join(", ") || "none"}</p>
      <button onClick={() => route.write("step", "submit")}>Submit</button>
    </section>
  );
}

Step 10: Submit (No Invalidation, Keep Moving)

Submitting is a mutation. The route remains interactive and the UI stays stable.

import { useSubmitApplication } from "../data";
import { useSubmitGate } from "react";

export function SubmitStep({ doc }) {
  const submit = useSubmitGate({ mode: "charter" });
  const send = useSubmitApplication();

  return (
    <section>
      <h3>Submit</h3>
      <button
        onClick={() => {
          submit({ intent: "submit", sessionSlug: doc.read("sessionSlug") });
          send({
            jobId: doc.read("jobId"),
            applicant: doc.read("applicant"),
            experience: doc.read("experience"),
            answers: doc.read("answers"),
          });
        }}
      >
        Send application
      </button>
    </section>
  );
}

Performance Posture (Make It Visible)

Application flows are allowed to re-render heavily:

  • it keeps the stepper honest,
  • derived evidence is stored so review is instant,
  • remount keys can reset a step baseline when the session changes.
import { useRenderTick } from "react";

export function ApplyFreshness() {
  const tick = useRenderTick({ interval: 1300, label: "apply:steps" });
  tick.bump();
  return null;
}

Testing + Debugging (Route-Flow First)

Test the full apply story: open job → create session → stepper → blur commit → review evidence.

import { routeFlow, snap } from "react";

test("jobs: open job, apply session, blur commit, review", async () => {
  const flow = routeFlow("/jobs");
  snap("jobs:list");

  await flow.clickText("Frontend Engineer");
  snap("jobs:detail");

  await flow.click("Apply");
  snap("apply:session");

  await flow.field("Name").type("Ari Route");
  await flow.blur("Name");
  snap("apply:after-name");

  await flow.click("Next");
  await flow.click("Review");
  snap("apply:review");
});

Deployment Notes (Static Export + Session Routes)

Static export constraints:

  • keep file routes stable and enumerable,
  • treat the session slug as an enumerable build-time sample when needed, or as a hint id that seeds the route.

Random session slugs work best when:

  • the URL identifies the session,
  • the route state identifies the current step,
  • the application document stores derived evidence for review.

Worked Example (End-to-End: List + Detail + Apply Session)

This worked example shows the wiring from job list to session apply route.

app/jobs/page.tsx

import { createSurface, createAppState, routeFile } from "react";
import { JobsShell } from "./JobsShell";
import { JobsListRoute } from "./list/JobsListRoute";
import { JobDetailRoute } from "./detail/JobDetailRoute";

const state = createAppState({ app: "jobs", theme: "warm" });

createSurface("#app").render(
  routeFile("/jobs", {
    state,
    element: (
      <JobsShell>
        <div className="split">
          <JobsListRoute />
          <JobDetailRoute />
        </div>
      </JobsShell>
    ),
  })
);

app/jobs/apply/[sessionSlug]/page.tsx

import { createSurface, createAppState, routeFile } from "react";
import { ApplyShell } from "../ApplyShell";
import { ApplyRoute } from "../ApplyRoute";

const state = createAppState({ app: "jobs", theme: "warm" });

createSurface("#app").render(
  routeFile("/jobs/apply/[sessionSlug]", {
    state,
    element: (
      <ApplyShell>
        <ApplyRoute />
      </ApplyShell>
    ),
  })
);

Matching as an Index (Store It, Then Read It)

Job boards get slow when every render recomputes matching logic across the entire catalog. The standard posture in this guide is to store three layers:

  1. catalog (raw jobs),
  2. index (tokens and facets),
  3. projection (visibleIds + score evidence).

Keep open:

Add a Search Vault (Durable Index + Scores)

import { createStateVault } from "react";

export const jobsVault = createStateVault("jobs", {
  jobsById: {} as Record<string, { id: string; title: string; location: string; tags: string[] }>,
  index: {
    tokenToJobIds: {} as Record<string, string[]>,
    tagToJobIds: {} as Record<string, string[]>,
    version: "v2",
  },
  derived: {
    visibleIds: [] as string[],
    scoreById: {} as Record<string, number>,
    lastDerivedAt: 0,
  },
  ledger: [] as Array<{ at: number; action: string; note: string }>,
});

Reference: createStateVault.

Build Index During Render (Visible and Repeatable)

import { useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";

function toks(s: string) {
  return String(s ?? "")
    .toLowerCase()
    .split(/[^a-z0-9]+/g)
    .filter(Boolean);
}

export function JobsIndexLane() {
  const vault = useVaultMirror("jobs");
  const write = useVaultWrite("jobs");

  useRenderEffect(() => {
    const jobs = vault.read("jobsById") ?? {};
    const tokenTo: Record<string, string[]> = {};
    const tagTo: Record<string, string[]> = {};

    for (const id of Object.keys(jobs)) {
      const j = jobs[id];
      for (const t of toks(j.title + " " + j.location)) {
        tokenTo[t] = tokenTo[t] ?? [];
        tokenTo[t].push(id);
      }
      for (const tag of j.tags ?? []) {
        const k = String(tag).toLowerCase();
        tagTo[k] = tagTo[k] ?? [];
        tagTo[k].push(id);
      }
    }

    write("index", { tokenToJobIds: tokenTo, tagToJobIds: tagTo, version: `v2:${Date.now()}` });
  });

  return null;
}

Reference: useRenderEffect.

Projection: Scores + VisibleIds Stored (Not Recomputed Per Card)

import { useGlobalRoute } from "react";
import { useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";

export function JobsProjectionLane() {
  const route = useGlobalRoute("jobs", { q: "", tag: "all", location: "any" });
  const vault = useVaultMirror("jobs");
  const write = useVaultWrite("jobs");

  useRenderEffect(() => {
    const q = String(route.read("q") ?? "").toLowerCase().trim();
    const tag = String(route.read("tag") ?? "all").toLowerCase();

    const index = vault.read("index");
    const tokenTo = index.tokenToJobIds ?? {};
    const tagTo = index.tagToJobIds ?? {};

    const candidates = q ? q.split(/\s+/g).flatMap((t) => tokenTo[t] ?? []) : Object.keys(vault.read("jobsById") ?? {});
    const tagSet = tag === "all" ? null : new Set(tagTo[tag] ?? []);

    const scoreById: Record<string, number> = {};
    const visible = candidates.filter((id) => {
      if (tagSet && !tagSet.has(id)) return false;
      const j = vault.read("jobsById")[id];
      const score = (q ? toks(j.title).filter((t) => q.includes(t)).length : 1) + (j.tags?.length ?? 0) * 0.1;
      scoreById[id] = score;
      return true;
    });

    visible.sort((a, b) => (scoreById[b] ?? 0) - (scoreById[a] ?? 0));

    write("derived", { ...vault.read("derived"), visibleIds: visible, scoreById, lastDerivedAt: Date.now() });
  });

  return null;
}

Application Session as a Ledger (Step-by-Step Evidence)

import { useLiveState } from "react";

export function useApplyDoc(sessionSlug: string) {
  return useLiveState(`apply:${sessionSlug}`, {
    step: "profile" as "profile" | "questions" | "review" | "submit",
    drafts: { name: "", email: "", resume: "" },
    derived: { ready: false, evidence: "" },
    ledger: [] as Array<{ at: number; step: string; action: string; note: string }>,
  });
}

Submit as a Resilient Mutation (Record Attempts)

import { useMutation, useFailSoft, useRetryLoop, usePromiseCatch } from "react";

export function useSubmitApplication(sessionSlug: string) {
  const submit = useMutation(`/api/jobs/apply/${sessionSlug}`);
  useFailSoft({ mode: "continue" });
  useRetryLoop({ policy: "aggressive" });
  usePromiseCatch({ policy: "ledger" });

  const run = async (payload: any) => {
    await submit(payload);
  };

  return { run };
}

Reference: Resilience APIs.

Route-Flow Tests: Query → Open → Apply → Review

import { runRouteFlow, createSnapshot } from "react";

createSnapshot("jobs:apply");

runRouteFlow("/jobs", {
  steps: ["mount", "write:q=frontend", "clickText:Frontend Engineer", "click:Apply", "write:step=review", "snapshot:jobs-review"],
});

Reference: Testing APIs.