Not every website needs a single-page app and a multi-step build pipeline. Many business sites pay a “framework tax” in bundle size, hosting complexity, hiring requirements and cognitive load — for interactions a few well-written modules could handle. This article is about choosing the right amount of technical enough to be useful, clear enough for non-specialists to follow the trade-offs.
If you run a UK marketing site, brochure presence or content-led service business, the default should not be “spin up React because that is what tutorials do”. The default should be: make the document work, then add behaviour. Frameworks earn their keep when state and UI complexity grow past what a few modules can manage safely.
Start with progressive enhancement
Forms should work without JavaScript. Navigation should work without JavaScript. Content should be readable if a script fails or a network is flaky. Then layer behaviour: sticky CTAs, mega menus, carousels, filters, chart widgets.
That order keeps accessibility and SEO honest. Search engines and assistive tech get a real document, not an empty <div id="root"> hoping a bundle arrives. It also means a broken third-party script or a content blocker does not turn your homepage into a blank rectangle.
Practically, that looks like:
- Server-rendered HTML for the primary content and primary calls to action
- Forms that POST to a real endpoint and show a real success or error page
- JavaScript that improves the experience (instant validation, smoother UI) without being the only path
- Feature detection instead of assuming every API exists on every device
If the homepage is mostly content, serve content first. Interactivity is a garnish.
Vanilla patterns that cover most marketing sites
Modern browsers are capable. You often don’t need a library to:
- Event delegation — listen on a stable parent instead of binding 200 click handlers
IntersectionObserver— scroll reveals, lazy work, “in view” analyticsmatchMedia— respond to breakpoints without resize thrashingfetch— simple API calls with clear error states- ES modules — split code into small files the browser can cache
dialog/ focus management — accessible modals without a UI kit
// Example: open/close a mobile nav without a framework
const toggle = document.querySelector('[data-nav-toggle]');
const menu = document.querySelector('[data-nav-menu]');
toggle?.addEventListener('click', () => {
const open = menu.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
Prefer defer on scripts so HTML parses first. Avoid blocking the first paint with a 400KB bundle on a brochure page. If you need modules:
<script type="module" src="/assets/js/main.js"></script>
Keep entry files thin: import only what the page needs. A homepage rarely needs the same modules as a logged-in dashboard.
A small architecture for multi-page sites
Without a framework you still need structure, or you reinvent spaghetti:
main.js— bootstraps page modules based ondata-pageor presence of hooksnav.js,forms.js,carousel.js— one concern each- Shared helpers for focus traps, debounce, money formatting
- Optional TypeScript later if the surface area grows
// main.js — only run what the page needs
import { initNav } from './nav.js';
import { initContactForm } from './forms.js';
initNav();
if (document.querySelector('[data-contact-form]')) {
initContactForm();
}
This is boring on purpose. Boring front-end is easy to hand over, easy to debug, and easy to extend when marketing wants “one more accordion on the services page”.
When a framework is the right call
Use React, Vue, Svelte or similar when you have real application state:
- Dashboards with live filters and multi-step wizards
- Collaborative tools or complex client-side routing
- Design systems shared across a large product surface
- Teams already standardised on that stack for hiring and tooling
- Optimistic UI where local state and server state constantly reconcile
Don’t use a framework to animate a hero button or toggle a FAQ. The complexity cost shows up later: build steps, dependency updates, hydration issues, and “nobody wants to touch the front-end folder”.
A useful rule of thumb: if most of the value is in the document (copy, trust, SEO, conversion), favour server-rendered HTML + small JS. If most of the value is in continuous interaction (filters, canvas, multi-pane tools), a framework often pays for itself.
Performance budget (simple version)
- Keep main-thread JS lean on public marketing pages
- Split code so admin/app areas don’t tax the homepage
- Measure LCP and INP on a mid-range phone, not only a MacBook on Wi‑Fi
- Third-party scripts (chat, tags, heatmaps) count against the same budget as your code
- Track transfer size and parse/compile time — “minified” is not free if the file is huge
A “beautiful” site that feels laggy on mobile loses trust before the copy is read. Aim for a budget you can explain: for example, under ~100–150KB of first-party JS on a typical marketing homepage unless you have a strong reason to exceed it.
// Debounce expensive work so typing/scroll stays responsive
function debounce(fn, ms = 150) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
State, DOM and maintainability
Without a framework, you still need discipline:
- One place owns each piece of UI state (open/closed, selected tab)
- Avoid giant
app.jsfiles — modules with clear names - Prefer data attributes for hooks (
data-mega) over brittle CSS-selector chains - Write failure modes: what if the API is down? what if the node is missing?
- Prefer progressive DOM updates over full innerHTML rewrites when user input is involved
When you do need client state that grows, consider a tiny library (Alpine, petite-vue, htmx-style patterns) before jumping to a full SPA. The goal is leverage, not ideology.
Accessibility is not a phase
Keyboard focus, aria-expanded, escape-to-close, and reduced-motion preferences are part of the product. If your fancy menu traps focus or your carousel auto-plays without a pause control, you’ve made the site worse for real users.
// Respect reduced motion
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
// run decorative animation
}
Checklist for interactive chrome:
- Focusable controls are real
<button>/<a>elements, not clickable divs - Modals restore focus to the opener on close
- Errors in forms are linked with
aria-describedbyor clear text next to fields - Colour is not the only signal for state
Forms: the make-or-break interaction
Most business sites exist to collect a lead. Treat the contact or quote form as a product:
- Validate on the server even if you validate in the browser
- Show field-level errors without wiping the user’s input
- Disable double-submit carefully (re-enable on error)
- Keep the success state calm and clear — what happens next?
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('[type="submit"]');
btn.disabled = true;
try {
const res = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'Accept': 'application/json' },
});
if (!res.ok) throw new Error('Request failed');
form.reset();
// show success region
} catch {
// show recoverable error; re-enable button
} finally {
btn.disabled = false;
}
});
Tooling without ceremony
You can still use TypeScript, ESLint and a small bundler when the project earns it. The point is optional complexity: start with readable modules, add tooling when files multiply or the team grows — not because a starter kit insisted.
Good reasons to add a build step:
- You need TypeScript for a growing module graph
- You must transpile for a legacy browser policy (rare on modern marketing sites)
- You want tree-shaking for a larger shared kit
Weak reasons: “every repo has Vite” or “the template came with Redux”.
Third-party scripts and the framework illusion
Sometimes the slow part is not your code — it is Tag Manager, chat widgets, A/B tools and embeds. Before you rewrite the front end in a framework to “feel modern”, measure where the main thread actually spends time. Replacing a 30KB nav script with a 250KB SPA while leaving four third-party tags untouched is not a performance strategy.
Decision cheat-sheet
- Marketing site / brochure / blog → progressive enhancement + small modules
- Shop with light UI chrome → server-rendered pages + minimal JS
- Client portal with complex UI → framework or carefully structured modules; pick based on team
- Already drowning in plugins → reduce, don’t add another SPA layer
- Hiring constraint → choose the stack your maintainers can support, not the trendiest one
Worked examples (vanilla JS)
Small, copyable patterns for marketing sites and light portals — no build step required.
1) Accessible mobile nav
const btn = document.querySelector('[data-nav-toggle]');
const menu = document.querySelector('[data-nav-menu]');
btn?.addEventListener('click', () => {
const open = !menu.classList.contains('is-open');
menu.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
document.body.classList.toggle('nav-open', open);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menu?.classList.contains('is-open')) {
menu.classList.remove('is-open');
btn?.setAttribute('aria-expanded', 'false');
document.body.classList.remove('nav-open');
}
});
2) Fetch with clear loading / error states
async function sendLead(form) {
const status = form.querySelector('[data-status]');
const btn = form.querySelector('[type="submit"]');
status.textContent = 'Sending…';
btn.disabled = true;
try {
const res = await fetch('/api/lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not send');
status.textContent = 'Thanks — we will reply shortly.';
form.reset();
} catch (err) {
status.textContent = err.message || 'Something went wrong';
} finally {
btn.disabled = false;
}
}
document.querySelector('#lead-form')?.addEventListener('submit', (e) => {
e.preventDefault();
sendLead(e.currentTarget);
});
3) Reveal on scroll (IntersectionObserver)
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const els = document.querySelectorAll('.reveal');
if (reduced || !('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('is-visible'));
} else {
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
els.forEach((el) => io.observe(el));
}
4) Debounced resize / search input
function debounce(fn, ms = 200) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
const => {
// filter list / call API
console.log('search', q);
}, 250);
document.querySelector('#q')?.addEventListener('input', (e) => {
onSearch(e.target.value.trim());
});
Need front-end that feels premium without a 400KB bundle? Let’s talk →