Blog

How Workspace JSON export is built

The Resumer editor at /build now has an Export JSON button. It downloads what is in the editor as a versioned Resumer workspace file. The file is created in the browser and saved to your device. Nothing is sent to a server.

This post covers how it works: the file format, the modules, the data flow from form state to a downloaded file, and what Phase A deliberately leaves out.

What it actually is

Phase A is export only. You click Export JSON, and the browser saves a file such as resumer-ada-lovelace-2026-10-01.json. A snackbar confirms it: Downloaded resumer-ada-lovelace-2026-10-01.json — stays on this device.

What it does:

  1. Takes the current editor values (React Hook Form state).
  2. Converts them to the snake_case resume document Resumer already uses elsewhere.
  3. Wraps that in a versioned envelope: format, schemaVersion, exportedAt, source, variants.
  4. Validates the whole thing with Zod, then starts a browser download.

What it does not do:

  • It does not import. You cannot load the file back into the editor yet.
  • It does not have variants. variants is in the file, but it is always an empty array.
  • It does not store anything on a server. There is no upload, no route handler, no server action, and no database. The project rules now say this explicitly: workspace JSON is a browser download only, and the server must not accept, echo, or persist it.

The stack is the same as the rest of the editor: Next.js 15 App Router, React 19, MUI 7, React Hook Form, and Zod 4 (zod ^4.1.12). No new dependencies were added.

The file format

This is the shape, as documented in the README:

{
  "format": "resumer-workspace",
  "schemaVersion": 1,
  "exportedAt": "2026-10-01T12:00:00.000Z",
  "source": {
    "personal_details": { "name": "Ada Lovelace" }
  },
  "variants": []
}

The schema is short. Everything in it is either a literal or something that already exists:

// src/lib/workspace/schema.ts
export const resumerFileV1Schema = z.object({
  format: z.literal("resumer-workspace"),
  schemaVersion: z.literal(1),
  exportedAt: z.iso.datetime(),
  source: resumeDataSchema,
  variants: z.tuple([]),
});

export type ResumerFileV1 = z.infer<typeof resumerFileV1Schema>;

Some choices worth calling out:

  • format and schemaVersion are literals. A future importer can reject anything that is not resumer-workspace at version 1 before it looks at the contents. A v2 file gets a new schema rather than a looser v1.
  • source reuses resumeDataSchema from src/types/schemas.ts. This is the same snake_case resume document (personal_details, contact_details, experience, education, personal_projects) the app already validates elsewhere. The file format does not invent a second resume shape.
  • variants is z.tuple([]), not z.array(...). The schema allows only an empty array. The field reserves its place in the format without promising anything about what a variant will look like.
  • exportedAt is z.iso.datetime(), the Zod 4 ISO-8601 check. The value comes from new Date().toISOString(), so it is in UTC.

Where the modules live

Piece Path Role
File schema src/lib/workspace/schema.ts resumerFileV1Schema, ResumerFileV1.
Build + download src/lib/workspace/export.ts buildExportFile, downloadResumerFile, filename helpers.
Barrel src/lib/workspace/index.ts Public exports for the module.
Resume schema src/types/schemas.ts resumeDataSchema (existing).
Form → document src/lib/linkedin/form-values.ts fromFormValues (existing, from LinkedIn import).
UI src/components/forms/Form.tsx Export JSON button, snackbar.

The new code is small: three files under src/lib/workspace/ and an edit to the editor form. The README and the agent guide in the repo also got a section describing the format.

Data flow

React Hook Form state (camelCase EditorFormValues)
  -> fromFormValues()          snake_case document
  -> resumeDataSchema.parse()  validated source
  -> resumerFileV1Schema.parse({ format, schemaVersion, exportedAt, source, variants: [] })
  -> downloadResumerFile()     parse again, Blob, object URL, <a download>
  -> filename returned         snackbar: "Downloaded … — stays on this device"

From editor state to a document

The editor form holds camelCase state: personalDetails, contactDetails, experienceItems, educationItems, personalProjects (EditorFormValues in src/lib/resume.ts). That shape belongs to the UI. I did not want it in a file meant to be portable.

LinkedIn import already needed to convert between the two shapes, so the helper was already there:

// src/lib/linkedin/form-values.ts
export function fromFormValues(values: EditorFormValues): Partial<ResumeDocument> {
  return {
    personal_details: values.personalDetails,
    contact_details: values.contactDetails,
    experience: values.experienceItems,
    education: values.educationItems,
    personal_projects: values.personalProjects,
  };
}

It returns a Partial, so the result is parsed before it goes anywhere. buildExportFile does that, and then parses the full envelope:

// src/lib/workspace/export.ts
export function buildExportFile(values: EditorFormValues): ResumerFileV1 {
  const source = resumeDataSchema.parse(fromFormValues(values));
  return resumerFileV1Schema.parse({
    format: "resumer-workspace",
    schemaVersion: 1,
    exportedAt: new Date().toISOString(),
    source,
    variants: [],
  });
}

That is two parses. The first checks the resume document, and the second checks the envelope around it. If either fails, Zod throws and no file is produced.

The download

downloadResumerFile parses the file one more time, because it is exported and takes any ResumerFileV1 from its caller. It then guards against running outside the browser and triggers a normal browser download:

// src/lib/workspace/export.ts
const validated = resumerFileV1Schema.parse(file);
if (typeof document === "undefined") {
  throw new Error("JSON export download runs in the browser only.");
}

const filename = resumerExportFilename(validated);
const blob = new Blob([`${JSON.stringify(validated, null, 2)}\n`], {
  type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
// append, click, remove
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return filename;

The JSON is pretty-printed with two-space indentation and ends with a trailing newline, so it reads well in an editor and diffs cleanly. The object URL is revoked after a second rather than right away, which gives the browser time to start the download.

This is all there is to "stays on this device": a Blob in memory, an object URL pointing at it, and an <a download> click. No fetch call is involved.

The filename

The filename comes from the validated file, not from raw form state:

// src/lib/workspace/export.ts
function resumerExportFilename(file: ResumerFileV1): string {
  const date = file.exportedAt.slice(0, 10);
  const slug = nameSlug(file.source.personal_details.name);
  return slug ? `resumer-${slug}-${date}.json` : `resumer-${date}.json`;
}

nameSlug normalizes with NFKD, strips combining accents, lowercases, collapses anything that is not a-z0-9 into -, trims leading and trailing dashes, and caps the result at 60 characters (NAME_SLUG_MAX). "Ada Lovelace" becomes ada-lovelace. A name with no ASCII letters or digits left after normalization gives an empty slug, and the file falls back to resumer-YYYY-MM-DD.json.

The date is the first ten characters of exportedAt, which means it is the UTC date. If you export in Brussels shortly after midnight, the filename will show the previous day. That is a small Phase A quirk, and I would rather mention it than have someone find it.

Next.js and React specifics

There is very little Next.js in this feature, and that is on purpose.

  • Client only. src/app/build/page.tsx is a "use client" page that renders AppProvider and the edit screen, which renders Form. The export runs in the click handler of a client component. /build keeps its existing buildMetadata() layout and stays noindex.
  • No route handler and no server action. This feature adds nothing under src/app/api. The existing routes (the example resume payload and the LinkedIn import routes) are unchanged, and none of them ever sees the workspace file.
  • No caching or revalidation concerns. Nothing is fetched, so there is nothing to cache.
  • The document guard in downloadResumerFile is a safety net. In practice the function only runs from an onClick, but the module is plain TypeScript and nothing stops it from being imported somewhere else.

In the editor, the button sits in the app bar right before Preview and uses the same enable rule:

// src/components/forms/Form.tsx
<Button
  size="small"
  variant="outlined"
  color="inherit"
  onClick={exportJson}
  disabled={!personalDetailsHasValue(personalDetails)}
>
  Export JSON
</Button>

personalDetailsHasValue (in src/lib/resume.ts) returns !!personalDetails.name on the watched form value. So Export JSON and Preview turn on together once there is a name in the form, which in the current UI means after the personal-details card is saved.

The handler is a try/catch that sets snackbar state:

// src/components/forms/Form.tsx
const exportJson = () => {
  try {
    const filename = downloadResumerFile(buildExportFile(getValues()));
    setExportNotice({
      severity: "success",
      message: `Downloaded ${filename} — stays on this device`,
    });
  } catch {
    setExportNotice({
      severity: "error",
      message: "Could not export this resume.",
    });
  }
};

It reads with getValues() at click time instead of using the watched values, so the export always reflects the current form, including fields the header does not watch, such as personalProjects. The notice is an MUI Snackbar with a filled Alert that hides after 6 seconds. A validation failure shows the generic error and does not say which field failed. That is good enough for Phase A, but it is a known gap.

Phase A limits

This is the first slice of a larger feature:

  • Export only. There is no importer. The format was designed so one can be written (literal format, literal schemaVersion, a reused resume schema), but nothing in the app reads these files yet.
  • variants is always []. The schema rejects anything else. Variant switching is a later phase.
  • No server store. This is a rule, not a gap. The file goes where downloads go on your device.
  • UTC date in the filename, as described above.
  • Generic error message when validation fails.

Why this shape

Resumer keeps your CV in the browser. The PDF has been the copy you keep, but a PDF is not something you can load back into an editor. A workspace file is: structured, versioned, and readable by humans as well as code.

Building on the existing pieces kept the change small. fromFormValues and resumeDataSchema already defined the portable resume document, so the new work was the envelope, the download, and a button. Locking the envelope down now, with literals for format and schemaVersion and an empty tuple for variants, means an importer can be strict later, and I won't have to guess what a "v1 file" was supposed to mean.

You can try it in the editor at https://resumer.orlinivanov.com/build. Fill in a name, click Export JSON, and open the file.