A useful web app is not a set of polished screens. It is a repeatable workflow that preserves a record, applies the right rules, protects each user's data, explains failures, and can recover after a bad release.
This guide uses a service-request app as the example. The same method works for a client portal, approval queue, inventory tracker, CRM, or internal dashboard: define one record and lifecycle first, then build, test, publish, and operate that complete loop before adding another feature.

QUICK ANSWER
How do you build a web app?
Start with one record and lifecycle, such as a service request moving from new to assigned to resolved. Define its fields, owners, allowed transitions, and version. Then build the browser states, authoritative server validation, persistence, and access rules. Test valid, invalid, retry, conflict, cross-account, and published flows before adding another record or integration.
Prepare the workflow before you build screens
A bounded brief is easier to build and much easier to verify. Use fictional records during development, and decide the security and recovery boundaries before inviting real users.
- One record and lifecycle: Name the authoritative ID, fields, initial status, allowed transitions, owner, timestamps, and version. For this example, one request moves from new to assigned to in progress to resolved or closed.
- Actors and access matrix: List what a requester, agent, and administrator may read, create, update, and export. Include the signed-out case and decide whether agents are scoped to a workspace, team, or queue.
- Acceptance and failure examples: Write one valid request, one invalid request, one repeated submission, one concurrent update, one unauthorized read, and one production smoke path. These examples become the first test plan.
- Privacy, migration, and recovery owner: Decide which fields are personal, what may appear in logs, who owns retention and deletion, how existing records will migrate, and when to repair a record versus restore the whole app to a saved point.
How much history should the first record keep?
The current state must be easy to read, but operational workflows often need to explain who changed a status and when. The right history model depends on whether a simple audit trail is enough or every state must be reconstructed.
| Approach | Best for | Tradeoff |
|---|---|---|
| Current row only | A simple private tool where only the latest status matters and changes do not need an audit trail. | Reads and migrations are simple, but the team cannot reliably explain how or why a record reached its current state. |
| Current row plus transition history | Service desks, approvals, portals, and CRMs that need fast current-state reads plus accountable status changes. | Each accepted transition must update the current row and append a history entry together, which adds write-path discipline. |
| Event-sourced record | Domains that must reconstruct every state from immutable events and have the engineering capacity to operate projections. | It offers the richest history but makes queries, migrations, repair, and debugging substantially more complex for a first release. |
Recommended:Start with a current request row that includes a version number. Add an append-only transition entry for meaningful status changes when accountability matters. Do not adopt full event sourcing merely to preserve a short activity timeline.
Build the first web-app workflow in 8 steps
Build one service request from submission to resolution, with an observable result and verification check at every step.
STEP 01
Define the record, transitions, and ownership
Turn the business process into one durable record before asking for pages or components.
Use a server-generated ID for identity and a separate human-readable reference when users need to discuss a request. Name required fields, allowed values, timestamps, ownership, and which role controls each transition.
Treat the version as part of the write contract. It lets the server detect that an agent is updating an older copy instead of silently overwriting newer work.
type RequestStatus = 'new' | 'assigned' | 'in_progress' | 'resolved' | 'closed'
type ServiceRequest = {
id: string
reference: string
title: string
description: string
priority: 'low' | 'normal' | 'high'
status: RequestStatus
requesterId: string
assigneeId: string | null
createdAt: string
updatedAt: string
version: number
}
type UpdateRequestCommand = {
requestId: string
expectedVersion: number
nextStatus: RequestStatus
}Expected result: Every screen and server action can point to one field, ownership rule, or allowed transition in the same record contract.
Verify it: Trace one invented request from creation to closure and reject any proposed control that has no persisted meaning or authorized server action.
STEP 02
Build browser states for the entire loop
Create the request form, queue, detail view, and status action with complete interface states.
Use browser validation for immediate feedback, but design beyond the happy path. The form needs submitting, saved, validation-error, and retryable-error states. The queue needs loading, empty, populated, stale, and read-error states. The detail view needs not-found, denied, conflict, saving, and write-error states.
Keep the last confirmed record visible during a recoverable refresh error when that is safer than replacing it with an empty screen. Mark optimistic changes as pending and roll them back if the server rejects them.
Expected result: A user can tell whether data is loading, absent, saved, rejected, stale, or temporarily unavailable without guessing whether an action completed.
Verify it: Throttle the connection, return an empty result, force one read failure and one write failure, and confirm each state has a distinct message and only a safe retry action.
STEP 03
Make server validation and persistence authoritative
Repeat every important input and transition rule on the server and return success only after a durable save.
Validate required fields, lengths, enumerations, unexpected properties, and status transitions at the server boundary. Generate IDs, references, timestamps, and initial ownership there. Browser checks improve feedback but cannot make a direct server request safe.
Return a stable record ID and version after the write commits. Keep notifications and external provider calls outside that success boundary so a secondary outage does not make a durable request look lost or create it twice.
Expected result: Bypassing browser checks cannot create malformed or impossible state, and every visible success names one durable record version.
Verify it: Call the server action with valid, missing, oversized, unexpected, and invalid-transition payloads, then inspect both the responses and stored records.
STEP 04
Enforce authentication, ownership, and roles on the server
Protect list, detail, update, transition, and export paths independently of the interface.
Filter requester queries by the authenticated requester ID. Scope agent actions to the intended workspace, team, or queue. Reserve bulk export and administrative transitions for the named role. Do not reveal whether another account's record exists in a denial response.
A hidden button or unlisted URL is not authorization. Apply the same policy to direct API calls, file downloads, exports, and background actions.
Expected result: Each actor can operate only the records and actions allowed by the access matrix, even after manually changing a URL, ID, or request body.
Verify it: Repeat list, detail, update, and export requests signed out, in a private session, as a second requester, and as the intended agent; compare status codes and returned fields.
STEP 05
Separate retry safety from version conflicts
Use an idempotency key for one logical create attempt and a version check for concurrent updates.
Generate one idempotency key when the user starts a submission and reuse it after a timeout or interrupted response. Enforce that key at the database write boundary, so two matching requests return the same record rather than creating two records.
For an update, send the version the user last saw. If the stored version has changed, reject the stale update and show the newer state. Human duplicate matching is a third problem and needs a separate merge, review, or keep-both policy.
Expected result: Network retries create one request, while two people editing the same older version receive an explicit conflict rather than losing a newer update.
Verify it: Submit the same idempotency key concurrently, drop the first response and retry, then issue two updates from the same version; confirm one created record and one visible conflict.
STEP 06
Run state, retry, and cross-account tests
Test the boundaries most likely to turn a polished interface into an unsafe or unreliable app.
Create a request, refresh, reopen it, assign it as an agent, and resolve it. Then test invalid input, unauthorized reads and writes, duplicate creates, stale updates, loading, empty, read-error, and write-error states.
Use fictional data and two ordinary accounts, not only an administrator. Repeat the requester path in a private mobile-width browser so cached access and desktop layout do not hide problems.
Expected result: The bounded workflow passes as the intended roles and fails safely for invalid, repeated, stale, signed-out, and cross-account actions.
Verify it: Save the test matrix with the record IDs, expected results, and observed results, while keeping private descriptions and credentials out of screenshots and logs.
STEP 07
Publish over HTTPS and run a production smoke test
Verify the exact public path and runtime instead of assuming a successful build behaves the same after publication.
Publish the app, open it in a new private session, sign in as the intended roles, create one synthetic request, update it, refresh the page, and confirm persistence. Check the custom domain and DNS separately when used.
Create or identify the supported recovery point before a risky release. Record the previous app version, migration applied, smoke record reference, and rollback decision so an incident is diagnosable.
Expected result: The published HTTPS app completes one requester-to-agent loop with durable state, correct authorization, and a known recovery point.
Verify it: Run the smoke test from the public URL on desktop and mobile width, then locate the synthetic record through its stable reference without searching logs for private payload text.
STEP 08
Monitor, migrate, recover, and export deliberately
Operate record data and source code as separate assets with separate repair and handoff contracts.
Log stable request, record, and error IDs with bounded context. Do not routinely log descriptions, secrets, access tokens, or personal payloads. Track save failures, denied access, stale conflicts, migration failures, and smoke-test status.
Make additive schema changes first, backfill in bounded batches, verify old and new readers, then remove obsolete fields later. Treat code export, authorized runtime-record export, and whole-app restore as separate contracts: restoring an older saved point can also move the database backward.
Expected result: The team can diagnose an incident, migrate existing records, repair or restore safely, and hand off code or authorized data without confusing those operations.
Verify it: Run a sample additive migration, export only the documented fields as an authorized role, locate the recovery point, and rehearse the decision between record repair and whole-app restore.
What the result can look like

The minimum web-app test matrix
These four categories are the release floor. Each test should name the actor, starting record version, action, expected stored state, and expected interface state.
| Test | Scenario | Expected result |
|---|---|---|
| happy path | A requester creates a valid record; an authorized agent assigns and resolves it; both refresh and reopen the correct permitted view. | One record persists with valid transitions, increasing versions, correct timestamps, and no private data in routine logs. |
| invalid input | Send missing and oversized fields, an impossible transition, a signed-out update, and a second account's record ID directly to server actions. | Every request fails safely, creates no partial write, reveals no foreign record fields, and returns an actionable but non-sensitive error. |
| retry | Repeat one create with the same idempotency key after dropping the first response, then send two updates from the same expected version. | The retry returns one record; one update succeeds and the stale update returns a conflict with a safe path to review the newer state. |
| production smoke | From the published HTTPS URL in a private mobile-width browser, create a synthetic request, operate it as the intended agent, refresh, and reopen it. | The complete workflow persists on the public runtime, access remains isolated, assets load, and the synthetic record can be removed through the documented path. |
Common failures and how to diagnose them
Start with the observable symptom, then inspect the record ID, request ID, actor, version, and bounded error code. Do not begin by dumping the full private payload into logs.
| Symptom | Likely cause | Check | Fix |
|---|---|---|---|
| The user sees success, but the record is missing after refresh | The interface confirmed before the durable server write completed, or it treated a failed response as success. | Match the client request ID to the server response and database commit result; confirm whether a stable record ID and version were returned. | Show success only after commit, preserve the form on failure, and retry with the same idempotency key. |
| A double-click creates two records | The browser disables the button too late, or the database does not enforce one idempotency key per logical attempt. | Compare idempotency keys and creation timestamps on both rows, including concurrent server requests. | Generate one attempt key before submission and enforce its uniqueness at the write boundary. |
| An agent's change overwrites a newer update | The update does not include or enforce the last seen version, so the final write silently wins. | Compare the version loaded by each client with the version accepted by the server. | Require expectedVersion, reject stale writes, and show the newer record with a deliberate retry or merge action. |
| A user can open another account's record | The detail query looks up only by record ID, or authorization exists only in the interface. | Repeat list, detail, update, and export calls as a second ordinary account and signed out. | Apply ownership or role scope inside every server query and mutation, and return a non-revealing denial. |
| The app works before a migration but fails on older records | New code assumes a required field or status that was never backfilled, or old and new versions were deployed in the wrong order. | Inspect schema version, nullable fields, migration result counts, and one old record using the new read path. | Use an additive migration, backfill and verify first, deploy compatible readers, then tighten or remove fields in a later release. |
Deploy and operate the web app
Deploy
Publish to a preview first, run the role and migration checks, then publish over HTTPS under current Playcode plan limits. Verify the exact public path, custom-domain DNS when used, and the synthetic smoke record after each release.
For schema changes, prefer expand, backfill, verify, then contract. Keep old and new app versions compatible during the release window instead of relying on an irreversible one-step migration.
Monitor
Track save failures, denied actions, stale-version conflicts, request latency, migration results, and the published smoke test. Use stable request and record IDs with bounded error context.
Keep descriptions, personal fields, secrets, authentication tokens, and exported payloads out of routine logs. Restrict and expire any exceptional diagnostic capture.
Recover
Use record-level repair when a small, understood set of records is wrong. Use a saved whole-app recovery point for a broad bad release only after deciding how newer database changes will be reconciled.
Document source-code export, runtime-record export, and whole-app restore independently. Code export does not promise current database export, and restoring an older app point can move live records backward too.
Security and privacy checklist
Authentication proves who is present; authorization decides which record and action that person may use. Both belong on the server, and neither replaces data minimization.
- Apply least-privilege rules to list, detail, update, transition, file, and export paths, not only to visible controls.
- Validate and bound every field on the server, reject unexpected properties, and encode output safely for its destination.
- Store secrets only in server-side configuration. Never place credentials in browser code, URLs, screenshots, logs, examples, or generated images.
- Minimize personal data, name a retention and deletion owner, and require separate legal, security, and compliance review before collecting regulated or highly sensitive information.
- Rate-limit abuse-prone write and lookup paths, and log stable IDs and error codes rather than complete private payloads.
Web-app implementation questions
What should I build first in a web app?
Build one complete record lifecycle. Define its ID, fields, statuses, owner, timestamps, version, allowed transitions, and role rules, then implement every interface and server state needed to operate that loop repeatedly.
Do browser validation and server validation do the same job?
No. Browser validation gives fast feedback, but users and scripts can bypass it. Server validation is authoritative and must enforce required fields, lengths, allowed values, transitions, ownership, and unexpected-field rejection before a write is accepted.
What is the difference between idempotency and version checking?
An idempotency key makes one repeated create attempt return one result. A version check stops an older update from overwriting a newer one. Human duplicate matching is separate again and needs an explicit business review or merge rule.
How do I test web-app authorization?
Test every list, detail, update, and export path signed out, in a private session, as two ordinary accounts, and as the intended operator role. Change record IDs and request bodies manually; hidden controls are not proof of server authorization.
Which loading and error states does a web app need?
At minimum, design loading, empty, populated, stale or conflict, read-error, write-error, submitting, saved, denied, and safe-retry states. Use pending optimistic states only when the action is reversible and the interface can roll it back.
Are source-code export and app-data export the same?
No. Playcode projects use real exportable code, but runtime records need their own authorized export contract: which fields, format, scope, access rule, and deletion or retention requirements apply. Define both before relying on a handoff plan.
When should I restore the whole app instead of repairing records?
Repair a bounded, understood set of records when possible. Consider a saved whole-app restore for a broad bad release only after checking what code, files, and database state will move backward and how valid changes made after the saved point will be reconciled.
Build one complete loop
Turn the workflow into an app people can operate
Describe the record, roles, rules, states, and expected result. Playcode can help build the browser interface and server workflow, then run it on Playcode Cloud under current plan limits.
Explore the AI App BuilderNo credit card required. AI credits included.