Native full-stack demo app "Profile editor" built with plain Node.js, browser ES modules, Web Components, Shadow DOM, templates, and shared domain logic.
Technical specification and reference for a small application that browses, searches, creates, edits, and deletes professional profiles. The application demonstrates a frontend without frameworks, bundlers, client-side template fetching, or duplicated business rules.
The main idea is to keep application behavior in a shared domain module and run the same rules on both sides. The application is not a simple CRUD form.
It supports:
- profile directory
- profile search
- profile creation
- profile editing
- profile deletion
- client-side validation
- server-side validation
- computed profile fields
- shared domain logic for browser and server
- The project avoids direct fetch calls from components
- Use only native APIs
- Do not use runtime npm dependencies
- Use plain Node.js on the server
- Use browser ES modules on the client
- Use Web Components, Template API, and Navigation API on the client
- Use
<template>for UI fragments and Web Components with Shadow DOM where appropriate
Development tools (ESLint, Prettier) are optional and used only at design time.
Install development dependencies:
npm installStart the server:
node server.jsOpen:
http://127.0.0.1:8000/
Example profile page:
http://127.0.0.1:8000/profile/marcus
npm run lint
npm run fixEach profile is identified by a username. The username in the URL must match the profile file name under ./data/profile/ without the .json extension. For example, /profile/marcus reads and writes ./data/profile/marcus.json.
Example profile:
{
"id": "marcus",
"firstName": "Marcus",
"lastName": "Aurelius",
"email": "marcus@example.com",
"country": "Roman Empire",
"city": "Rome",
"birthDate": "121-04-26",
"experienceYears": 12,
"primarySkill": "Architecture",
"secondarySkills": ["Node.js", "Distributed Systems"],
"weeklyAvailabilityHours": 24,
"hourlyRate": 80,
"currency": "EUR",
"bio": "Software architect and fullstack developer"
}The domain layer calculates:
displayName = firstName + " " + lastName
age = calculated from birthDate
seniorityLevel = Junior | Middle | Senior | Principal (from experienceYears)
monthlyCapacityHours = weeklyAvailabilityHours * 4
estimatedMonthlyIncome = monthlyCapacityHours * hourlyRate
profileCompleteness = percentage based on required fields
publicSlug = normalized public name slug derived from firstName and lastName
Validation runs in the browser while editing and on the server before saving.
Rules:
id is required
id must match safe username pattern
firstName is required
lastName is required
email must contain "@"
birthDate must be valid and in the past
experienceYears must be an integer from 0 to 60
weeklyAvailabilityHours must be an integer from 0 to 80
hourlyRate must be a number from 0 to 1000
secondarySkills must be an array of non-empty strings
currency is normalized to uppercase
email is normalized to lowercase
The server never trusts browser validation.
The domain model lives in shared/profile.js.
It exports: schema, normalize, validate, calculate, buildState.
The same module is imported by:
static/components/profile-form.js
routes/profile.js
routes/search.js
This keeps browser behavior and server behavior consistent.
.
├── config.js
├── server.js
├── data/
│ └── profile/
├── lib/
│ ├── channel.js
│ └── router.js
├── routes/
│ ├── profile.js
│ ├── search.js
│ └── static.js
├── shared/
│ ├── profile.js
│ └── utils.js
└── static/
├── api.js, app.js
├── index.html, styles.css
└── components/
├── <component-name.html>
└── <component-name.js>
The server:
- serves the main page (
index.htmlwith assembled templates) at/and/index.html - serves static files from
./staticat URL root (/app.js,/styles.css,/components/*, and so on) - serves shared domain files from
./sharedunder/shared/* - serves profile JSON from
./data/profile/{username}.json - serves
GET /profile/{username}withAccept: text/htmlas the main page for browser navigation - supports profile search with partial matching by display name and email
- supports
GET /profile/{username}withAccept: application/json— read profile JSON - supports
POST /profile/{username}— update - supports
PUT /profile— create - supports
DELETE /profile/{username}— delete - supports
GET /search?name={value}&email={value} - accepts only safe username values
- returns correct
Content-Type - returns
404for unknown routes and missing profile files - returns
405for unsupported methods - returns
400for invalid JSON - returns
201for successful profile creation - returns
409for create requests when username already exists - returns
422for domain validation errors - returns
500only for unexpected server errors - does not expose files outside the project directory
- prevents path traversal
The request dispatcher is implemented as a collection of route handlers, not as a chain of if/else or switch statements.
At startup the server scans routes/ and builds a routing table that maps the first URL path segment to a route module. The dispatcher looks up that segment and delegates the request.
Route modules are plain .js files. Each module exports a default object of HTTP method handlers: { GET, POST, PUT, DELETE }. The router maps the first URL path segment to a module file (search.js to /search, profile.js to /profile/...). Handler arity defines the expected path shape: a handler (channel) serves the mount path (/search), a handler (channel, username) serves one segment below (/profile/marcus).
Example: routes/profile.js receives requests whose first segment is profile:
export default {
GET: getProfile,
POST: updateProfile,
PUT: createProfile,
DELETE: removeProfile,
};Example: routes/search.js handles profile directory search:
export default {
GET: searchProfiles,
};The router loads modules dynamically with readdir. Segment depth and HTTP method dispatch live in the router; route modules contain only named handlers and the method map. server.js contains only bootstrap and dispatch.
The frontend is composed from small custom elements.
static/app.js- application entry point. It imports and registers frontend components.profile-app— main page: routing, Navigation API, top-level layoutprofile-directory— directory page: coordinates search, list, create flowprofile-search— debounced search input, emitssearch-changeeventprofile-list— renders a list of profile summaries from a data propertyprofile-item— renders one summary row, emitsopen-profile/delete-profileprofile-form— editable profile form driven by state; submits viaapi.jsprofile-field— single labeled field with validation message displayprofile-summary— displays read-only computed fieldsvalidation-message— displays one error stringprofile-create-dialog— modal wrapper for the create flow (present but unused in current flow)
Components do not perform network requests inline. They call the API facade from api.js or receive data through properties and events.
Each visual component has a separate .html file with a single <template>, co-located with its .js file:
static/components/profile-form.html
static/components/profile-form.js
The .html file contains one <template> element with a matching id:
<template id="profile-item">
<style>
:host {
display: block;
}
.name {
font-weight: 600;
}
</style>
<div class="item">
<div class="name" id="name"></div>
<div class="email" id="email"></div>
</div>
</template>Server-side assembly. At startup, routes/static.js reads index.html and all static/components/*.html files, concatenates the template fragments, replaces a placeholder comment in index.html, and caches the assembled document in memory:
<!-- index.html -->
<body>
<profile-app></profile-app>
<script type="module" src="/app.js"></script>
<!-- {{templates}} -->
</body>The browser receives a single document that already contains all <template> elements. Components read their template synchronously from the document:
class ProfileItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.append(
document.getElementById('profile-item').content.cloneNode(true),
);
}
}So components do not need: browser-side template fetching, build step, bundler.
Components minimize imperative DOM construction:
- Drive display updates by setting
textContentor attributes on pre-queried elements rather than rebuilding subtrees. - For repeated items,
profile-listcreates oneprofile-itemelement per summary and fills it through attributes. - For form fields,
profile-formcreates oneprofile-fieldelement per schema entry and updates values and errors from domain state. - For computed fields,
profile-summaryderives keys from shared metadata:
import { schema } from '/shared/profile.js';
for (const [key, metadata] of Object.entries(schema)) {
if (!metadata.computed) continue;
// update pre-declared element by id
}Computed fields are declared in schema with computed: true.
- Prefer
replaceChildren()over manual child removal loops. profile-fielddeclares both<input>and<textarea>in the template and shows one of them with CSS.- Do not use
innerHTMLfor untrusted data. UsetextContent, attributes, and DOM methods.
All frontend network calls are isolated in static/api.js.
It exports: getProfile, saveProfile, deleteProfile, searchProfiles, createProfile.
Each function returns a normalized result object. HTTP error handling, JSON parsing, and status checks happen inside api.js, not in components. Components do not call fetch directly.
Profile routes use a dynamic username segment. The username must match the file name in ./data/profile/ without the .json extension.
GET /search?name={value}&email={value}Response:
{
"ok": true,
"items": [
{
"id": "marcus",
"displayName": "Marcus Aurelius",
"email": "marcus@example.com"
}
]
}Notes:
nameandemailare optional- empty filters return all profiles
- search is case-insensitive
- results are sorted by username
namesupports partial matches against computeddisplayNameemailsupports partial matches againstemail
GET /profile/{username}
Accept: application/jsonBrowser navigation with Accept: text/html returns the main page (index.html) instead of JSON.
JSON response:
{
"ok": true,
"profile": { "...": "normalized profile fields" },
"computed": { "...": "calculated fields" }
}POST /profile/{username}
Content-Type: application/jsonResponse:
{
"ok": true,
"profile": { "...": "normalized profile fields" },
"computed": { "...": "calculated fields" }
}Validation error (422):
{
"ok": false,
"profile": { "...": "normalized profile fields" },
"computed": { "...": "calculated fields" },
"errors": {
"email": "Invalid email"
}
}PUT /profile
Content-Type: application/jsonSuccess response (201):
{
"ok": true,
"profile": { "...": "normalized profile fields" },
"computed": { "...": "calculated fields" }
}Validation error (422):
{
"ok": false,
"profile": { "...": "normalized profile fields" },
"computed": { "...": "calculated fields" },
"errors": {
"email": "Invalid email"
}
}Conflict response (409):
{
"ok": false,
"errors": {
"id": "Profile already exists"
}
}DELETE /profile/{username}Response:
{
"ok": true
}Client routes handled by profile-app with the Navigation API:
/ profile directory
/new create profile
/profile/:username edit profile
Server routes:
GET / main page
GET /index.html main page
GET /profile/:username JSON profile or main page (depends on Accept)
POST /profile/:username update profile
PUT /profile create profile
DELETE /profile/:username delete profile
GET /search search profiles
GET /shared/* shared domain modules
GET /app.js, /styles.css, /components/*, and other static files
/new is a client-side route only. A direct server request to /new is not handled and returns 404. Open the create flow from / or navigate in-app after the main page has loaded.
GET /profile/{username} serves the main page when the browser sends Accept: text/html, which makes full-page navigation to profile URLs work.
Profiles are stored as JSON files:
data/profile/{username}.json
The URL username maps to the file name. Only safe usernames are accepted.
Example:
/profile/marcus - data/profile/marcus.json
- Open
/. - App renders profile directory with search, create, and delete controls.
- Directory loads profiles through
/search. - User searches by display name or email (partial, case-insensitive).
- Matching profiles are rendered as list items after each input change (250 ms debounce).
- Open
/profile/marcus. - App loads profile JSON from
GET /profile/marcus. - Form renders editable fields.
- Each field change rebuilds domain state.
- Computed values, validation messages, and save button state update immediately.
- Save sends
POST /profile/marcus. - Server validates with the same domain module.
- Server saves normalized profile JSON on success.
- From
/, click Create, or navigate in-app to/new. - App renders
profile-formin create mode (username field is editable). - Domain state is recalculated on every edit; invalid form disables submit.
- Submit sends
PUT /profile. - Server validates domain rules and username uniqueness.
- Successful creation navigates to
/profile/{username}.
- Open
/. - Click delete on a profile item.
- Browser asks for confirmation.
- Client sends
DELETE /profile/{username}. - Directory reloads after successful deletion.
The implementation avoids rendering untrusted data with innerHTML. Dynamic values are rendered with textContent, attributes, and DOM methods.
The server validates usernames before mapping them to file paths. The router prevents path traversal when serving static and shared files.
- The app starts with
node server.js. - The home route shows a profile directory with search, create, and delete controls.
- Search supports partial input and finds profiles by display name or email.
- Opening
/profile/marcusloads the profile from the server. - Editing the form immediately recalculates computed fields and validation errors.
- The save button is disabled when the profile is invalid.
- POST sends JSON to the same endpoint; the server validates with the shared domain module.
- Invalid data is not saved; valid data is normalized and saved to
./data/profile/{username}.json. - Creating a profile stores new valid data; deleting removes the profile file.
- Refreshing the page shows the last saved valid data.
- No runtime framework or bundler is used.
- All
fetchcalls are instatic/api.jsonly. - The server routing table maps the first URL segment to a route module from
routes/. - Component templates are assembled into
index.htmlserver-side; components access them synchronously. - Components contain no inline
fetchcalls.
- Do not implement authentication.
- Do not implement a database.
- Do not implement server-side rendering.
- Do not implement a design system.
- Do not implement offline mode.
- Do not implement optimistic conflict resolution.
profile-create-dialog exists in the component directory, but the current app flow renders profile-form directly on /new.
routes/static.js assembles templates into index.html at startup and caches loaded static files in memory.
Search currently returns all matching profiles sorted by username.
profile-form stores generated field elements in a Map and updates them from domain state.
profile-summary receives computed data through a serialized values attribute.
The frontend is not a CRUD form with scattered validation. The important part is the separation between:
UI components
API boundary
shared domain logic
server routes
file storage
The browser gives immediate feedback, but the server remains the final authority.