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:
- Takes the current editor values (React Hook Form state).
- Converts them to the snake_case resume document Resumer already uses elsewhere.
- Wraps that in a versioned envelope:
format,schemaVersion,exportedAt,source,variants. - 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.
variantsis 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:
formatandschemaVersionare literals. A future importer can reject anything that is notresumer-workspaceat version1before it looks at the contents. A v2 file gets a new schema rather than a looser v1.sourcereusesresumeDataSchemafromsrc/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.variantsisz.tuple([]), notz.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.exportedAtisz.iso.datetime(), the Zod 4 ISO-8601 check. The value comes fromnew 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.tsxis a"use client"page that rendersAppProviderand the edit screen, which rendersForm. The export runs in the click handler of a client component./buildkeeps its existingbuildMetadata()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
documentguard indownloadResumerFileis a safety net. In practice the function only runs from anonClick, 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, literalschemaVersion, a reused resume schema), but nothing in the app reads these files yet. variantsis 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.