A website project can have two backlogs: code to ship and content the client still needs to provide. Most project boards track the first one very carefully. The second backlog often lives in scattered emails, chat messages, shared folders, and an optimistic sentence: "I'll send the logo tomorrow."
This is not simply a file-upload problem. It's a small workflow problem with states, permissions, versioning, and clear ownership.
Here is a practical approach to designing that workflow. The code below is a reference design, not a claim about the internals of any particular product.
Start with requirements, not uploads
Instead of asking a client to "send your content," define the things a project actually needs.
For a small business website, that might mean:
- Homepage headline and introduction
- Logo in a usable format
- Services descriptions
- Team photos with permission to publish
- Contact details and opening hours
- Legal text provided or approved by the responsible party
Each requirement should have a label, a short explanation, an owner, a due date if appropriate, and a status. A photo cannot be "done" merely because someone uploaded a blurry thumbnail.
A minimal data model could look like this:
type RequirementStatus =
| "requested"
| "received"
| "changes_requested"
| "approved";
type Requirement = {
id: string;
projectId: string;
label: string;
guidance: string;
status: RequirementStatus;
dueAt?: string;
currentVersion: number;
approvedAt?: string;
approvedBy?: string;
};
The important distinction is received !== approved. Upload success is a storage event; approval is a business decision.
Express approval as a state machine
If you model statuses as arbitrary strings that anyone can overwrite, bugs appear quickly. What happens when a client uploads a new version after approval? What if a background job overwrites a human review?
Define explicit transitions:
type State =
| "requested"
| "received"
| "changes_requested"
| "approved";
type Event =
| "upload"
| "request_changes"
| "approve"
| "reopen";
type Actor = "client" | "team";
const transitions: Record<
State,
Partial<Record<Event, State>>
> = {
requested: { upload: "received" },
received: {
request_changes: "changes_requested",
approve: "approved",
},
changes_requested: { upload: "received" },
approved: { reopen: "requested" },
};
function nextState(
state: State,
event: Event,
actor: Actor
): State {
const teamActions: Event[] = [
"request_changes",
"approve",
"reopen",
];
if (teamActions.includes(event) && actor !== "team") {
throw new Error("Reviewer permission required");
}
const next = transitions[state][event];
if (!next) {
throw new Error(`Invalid transition: ${state} -> ${event}`);
}
return next;
}
Security note: never trust an actor value sent by the browser. In production, derive permissions from the authenticated session or a server-validated client-share grant. Apply the transition inside a transaction and check the expected version so two simultaneous actions cannot silently overwrite one another.
This small state machine gives a developer something concrete to test. For example: can a client approve their own deliverable? No. Can a reviewer request changes after receiving a file? Yes. Can an approved asset be reopened intentionally? Yes.
Treat each upload as a version
A requirement and its files are not the same entity.
The requirement might be "Team photo." Its upload history could be photo-1.jpg, then photo-final.jpg, then team-landscape.jpg. Keep immutable upload records, attach them to the requirement, and store which version is approved.
For each upload, record a generated object key, file size, media type, upload time, checksum when useful, and who submitted it. Don't use the original filename as your storage key; filenames collide and may contain unsafe characters.
When a new version arrives, move the requirement from changes_requested to received and make the new version the one awaiting review. Preserve the prior review history for context.
A client portal should minimize friction
You usually don't need clients to learn a project-management suite just to send assets.
A good client view is a focused page with:
- The project's required items.
- A short explanation and example for each item.
- An input appropriate to the item: text field or file picker.
- A visible status and correction comments.
- A clear indication that the submission worked.
Mobile matters: clients may already have their photos on a phone. Don't require account creation when a narrower, secure access mechanism can do the job.
A shared URL, however, is not permissionless magic. Use a random, unguessable token, store only a secure representation server-side where feasible, scope it to one project, set an expiry, support revocation, and rate-limit requests. Don't put sensitive personal data in the link itself.
For file uploads, validate size and type server-side, isolate storage by project, scan content where appropriate, and only generate short-lived download URLs for authorized viewers.
Automate reminders, not relationships
A reminder job should query outstanding requirements, not blast the same generic email to every client.
A useful notification says exactly what remains missing or needs changes and provides a direct way to continue. Make the cadence predictable and easy to pause. If a client is blocked because they have not commissioned their team photos yet, another identical reminder will not solve it.
A simple scheduled job can:
for each active project:
find outstanding requirements
skip if all items are approved
skip if a reminder was sent too recently
send a concise summary of the remaining items
record the reminder event for audit and rate limits
The event log also helps answer "Did the client receive the request?" without relying on someone's memory.
Define a finish line
Metrics are useful only if they describe progress toward an actual handoff. Consider measuring:
- Time from project kickoff to final content approval
- Share of uploads approved on first review
- Number of correction rounds
- Outstanding items by project
- Reminder count before completion
If files arrive quickly but require many corrections, improve the item guidance. If guidance is clear but clients can't finish submissions, examine the interface and access flow.
When does this need its own tool?
For one client, a well-structured document and shared folder might be enough. For an agency juggling multiple websites, a requirement-based workflow can save coordination overhead and avoid ambiguous handoffs.
Disclosure: I'm working on ParaDocs, a tool focused on requesting, reviewing, and collecting client content through a simple client link. This article describes a general engineering approach; the sample code is illustrative and is not the source code of ParaDocs.
I'd be interested to hear how other developers manage asset approval and version changes before launching client sites.
Top comments (0)