
Croissant! Building a No-Framework Web App
Croissant! Building a No-Framework Web App ź“ė Ø
If you subscribe to my notes youāll know Iāve gone a little crazy building an RSS aggregator web app[1] over the past week. My side project code-named Croissant has been a delightful challenge. Working with web standards is wonderful!

Cute, innit?
I planned nothing which is why I went on a side quests involving animated favicons and interactive emojis. Eventually I progressed and built the minimum required features to read a blog feed. Iām now dogfooding version zero of Croissant for a month (or two) to discover and fix all the inevitable bugs.
Iāve documented the process so far. This is only a week of part time dev so my thoughts and ideas below are not concrete. The refactor fairies are beckoning!
Feel free to skip the boring parts. The penultimate topic is an incoherent snoozefest.
Design
This entire project started in CodePen where I mocked up typographic styles (dbushell) I find readable. Dark mode is a priority for me. (The CodePen is a little outdated now.)
I had no other design plans in mind as I built functionality. I think it turned out okay! Articles open in a full page dialog with no distractions. Iāve grown to detest the multi-column layout of most RSS readers. They all look like email inboxes and feel like a chore.
Alt
screenshot of the croissant web app
Croissant is the second best place to read the Piccalilli blog
Lack of planning leads to creative distractions.
Eventually Iād like to make Croissant themeable which is my excuse for such a sparse design.
Alt
Croissant reading this very blog post!
Wait what is this inceptionā¦
No Build
This was a big gamble. After months of SvelteKit (and even some React) Iām sick of frameworks! Iām using no build tools and no frameworks for this project. And definitely no āAIā. I have a text editor and a web server that points to index.html. No compilation. Web standards shipped straight to web browsers. Can you imagine?
Iām using JSDoc comments to provide the illusion of type safety. That said, TypeScript is already an illusion, so JSDoc is more like an illusion of an illusion. Iāve found myself making errors TypeScript would have caught. Does that mean TypeScript is good, or I have deskilled myself?
Bundles are overrated. Iām using a service worker to control cache for local-first offline support. The initial download is small due to no framework. Multiple imports are cached forever. Seriously, anyone know how to invalidate service workers?
Proxy Server
Croissant needs to handle the RSS, Atom, and JSON specs. I have to proxy requests via the server to avoid CORS errors in the browser. I threw together a proof of concept Deno server to handle the proxy alongside serving the website itself. I want to rewrite the server in Zig[2]
but that is a bigger project. The server also normalises data into a JSON format.
My feeds have the Access-Control-Allow-Origin: * header, do yours? š«µš
These specifications are merely suggestions. Adhering to strict XML is an unpopular choice. I learnt that with podcast RSS. I just assume every feed is an amalgamation and bastardisation of multiple specs. My own feeds are a perfect example. Iāve no idea what Iām doing.
The Croissant server does nothing more than serve static web assets and proxy feeds.
Sanitising HTML
Most RSS feed content is HTML (at least those I subscribe to). An RSS reader can just yolo this HTML into a web page. That opens the barn door for XSS (cross-site scripting).
At first I tried to sanitise HTML on the server but I realised that was dumb. Web browsers are the best HTML parsers. They provide free DOM APIs that are vastly superior to any server-side library. I discovered a few in-browser techniques that I believe are bulletproof! š¤
Safe Strings
Sanitising text using browser APIs is easy (I hope). All that is required is to strip unencoded HTML tags. Below is a basic example.
const tmp = document.createElement("template");
const bad = '<style>*{display:none;}</style>';
tmp.innerHTML = bad;
const p = document.createElement("p");
p.textContent = tmp.content.textContent;
console.log(p);
This will effectively strip the <style> tags. A safe albeit confusing paragraph with the text ā*{display:none;}ā is all that remains.
If the bad string had < and > encoded as HTML entities, &lt; and &gt; respectively, weād see HTML in the paragraph as text rather than a <style> element injected into the DOM.
Setting p.textContent is important for this to work safely. If we set p.innerHTML instead of p.textContent (line 6 above) it leaves the door open for XSS again.
// ā ļø MISTAKE: do not set innerHTML!
p.innerHTML = tmp.content.textContent;
To abuse that mistake an attacker could encode the HTML tags as entities.
const bad = '<style>*{display:none;}</style>';
Then tmp.content.textContent becomes the decoded HTML. Using p.innerHTML by mistake will inject unsafe HTML into the page.
<style>*{display:none;}</style>
This style renders the page invisible but it could be more nefarious. If the attacker tries to use a script element browsers are smart enough not to execute the script.
const bad = '<script>console.log("test");</script>';
Scripts wonāt execute, thankfully!
I also discovered it was critical to use template and not div or any other temporary element. Consider these two examples.
const tmp1 = document.createElement('div');
tmp1.innerHTML = '<img src="evil.png">';
const tmp2 = document.createElement('template');
tmp2.innerHTML = '<img src="evil.png">';
If you try the first example using a div element youāll see a network request for evil.png. This happens immediately even if the element is never attached to the DOM.
Key lessons:
- Use
templateas a placeholder - Use
textContentto avoid XSS
That should be enough for sanitised text.
Safe HTML
The example above strips raw HTML from text content. Thatās perfect for article titles. What about safe HTML for the main body? Maintaining format and semantics is important. For that a similar template placeholder can be used. Below is the basic setup.
const allowedTags = new Set(['p']);
const template = document.createElement("template");
template.innerHTML = `<p>Hello, <b>World!</b></p>`;
const sanitizeNode = (parent) => {
for (const node of [...parent.childNodes]) {
// Allow text nodes
if (node.nodeType === Node.TEXT_NODE) {
continue;
}
// Remove all other non-elements
if (node.nodeType !== Node.ELEMENT_NODE) {
node.remove();
continue;
}
const tagName = node.tagName.toLowerCase();
if (allowedTags.has(tagName) === false) {
node.remove();
continue;
}
sanitizeNode(node);
}
};
sanitizeNode(template.content);
console.log(template.innerHTML);
This example logs:
<p>Hello, </p>
sanitizeNode is a recursive function that iterates over all child nodes and removes any that are not specfically allowed. I extended this function to loop over attributes too in a similar fashion. From here I added to the list of allowed tags and attributes until content was readable.
This approach is effectively how DOM Purify (cure53/DOMPurify) works if you need a general purpose library.
I added a few extra steps to:
- convert relative URLs to absolute
- wrap table elements in a scrollable container
- add controls to all audio and video elements
- wrap top-level text nodes in paragraphs
- remove empty paragraphs
I subscribe to a lot of web developer blogs and some of their HTML is shocking! I suspect Iāll be adding edge cases to fix terrible HTML for a while. (I found mistakes in my ownā¦)
Safe Encoded HTML
The technique above works if feeds put their HTML inside CDATA.
<description>
﹤![š¢š£š š³š [<h1>Hello, World!</h1>]]﹄
</description>
Iāve purposefully used alternate Unicode characters in that example because XML canāt nest CDATA and my hand-rolled script to generate my own RSS feed is an abomination!
Just strip the CDATA tags. There is, to no surprise, no guarantee where to find the HTML. It may be <description> or <summary> or <content> or elsewhere. Some authors skip the CDATA and ārawdogā HTML inside XML. Hence the requirement for loose parsing.
Other authors encode with HTML entities.
<content>
<h1>Hello, World!</h1>
</content>
How do I know if that is intended to be decoded as an <h1> element, or if the author intends to write the text ā<h1>Hello, World!</h1>ā with the tags visible?
I use the logical assumption that if the entire content is encoded with HTML entities itās intended to be decoded. I can check that by counting DOM nodes.
const template = document.createElement("template");
template.innerHTML = `<h1>Hello, World!</h1>`;
if (
template.content.childNodes.length === 1 &&
template.content.childNodes[0].nodeType === Node.TEXT_NODE
) {
template.innerHTML = template.content.childNodes[0].nodeValue;
}
console.log(template.innerHTML);
In the example above I check if there is a single text node. The browser has parsed for HTML and it effectively guarantees there is no unsafe HTML hidden within. This allows me to then set the otherwise dangerous template.innerHTML to that text node value. The browser decodes the HTML entities for me resulting in the desired DOM I can sanitise.
Thereās a gotcha here!
Text nodes have a maximum length of 65,536 (2^16). That means browsers use a two byte integer to store character length. If a blog post essay exceeds that length it gets split into multiple text nodes. The fix is to the check all nodes are text whilst concatenating the values.
If an author intends to write visible HTML they must double-encode.
<content>
&lt;h1&gt;Hello, World!&lt;/h1&gt;
</content>
This will decode into a text node with the value ā<h1>Hello, World!</h1>ā and not an <h1> element. Presumably most feeds are generated by tooling that handles this.
Despite all this effort some feed markup is hopeless. Iāve tested over 150 feeds and found only two I cannot present nicely.
Update
forgot to mention the Trusted Types and HTML Sanitizer browser APIs thatāll soon make this trivial. Thanks to @eldersouza.me on Bluesky for the reminder.
Shadow DOM Styles
Iāve only ever dabbled with the light DOM approach for āHTML Web Componentsā.
On this project I opted for a mix of Declarative Shadow DOM for the app skeleton, i.e. elements always visible (header, menu, etc), and pure Shadow DOM for elements that will come and go; basically list items.
Styling these components proved quiet a challenge. Iām rather stubborn. I assume I know how CSS works and blame everyone but myself when it doesnāt. Eventually I figured out how CSS like :host, ::part, and @scope works (I read the spec).
My next enemy was FOUC (Flash of Unstyled Content). I tried the following methods with increasing levels of success:
- inline
<style>with@importstatements - inline
<style>with CSS - Adopted Stylesheets
Because my project is āno buildā, and the fact that I canāt live without CSS imports, I tried inline CSS with multiple @import statements. This performed poorly.
Chris Ferdinandi had issue with micro CSS imports recently. I have relatively few and theyāre cached by a service worker. Despite that the FOUC was still noticeable. For long lists of 100+ custom elements each with their own <style> the browser ground to a halt.
Next I tried fetching the imports upfront and using a single inline <style>. This performed better but not good enough. The FOUC remained regardless of how many elements I had.
Finally, thanks to Dave Rupert on the timely ShopTalk #672 I learnt of adopted stylesheets. I prefetch the CSS and create one CSSStyleSheet in JavaScript per custom element type which gets adopted by all instances. This means the browser doesnāt repeatedly parse the same inline styles. This fixed both the FOUC issue and the slow renders.
State and Reactivity
ā ļø
Iām still experimenting with web components and reactivity. The notes below are a bit of a mess. Hopefully a somewhat comprehensible mess!
I tried Preact Signals (preactjs/signals) which can be used as a tiny stand-alone library. The ergonomics were nice. Iād like a way to make signals read-only outside of the component or source that āownsā them. Iāll return to the signals idea when I have more free time.
For now Iām using good old Custom Events. Components are either responsible for themselves, or parents are responsible for their direct children. I have a global store that is a wrapper around Dexie which is itself a wrapper of IndexedDB. Iāve allowed myself plug-and-play libraries but no frameworks.
As an example, I have an <rss-list> element that renders a list of <rss-item> children. That element decides what items to show based on a custom event. Below is a reduced code example to illustrate this idea.
import { store } from "./store.js";
import BaseComponent from "./base.js";
export class Component extends BaseComponent {
connectedCallback() {
this.subscribe("rss:route", this.#onRoute);
}
/** @param {CustomEvent<URL>} ev */
async #onRoute(ev) {
if (ev.detail.pathname === "/") {
const items = await store.getItemsUnread();
this.render(items);
return;
}
if (ev.detail.pathname === "/today/") {
const items = await store.getItemsRange();
this.render(items);
return;
}
}
}
The rss:route event is dispatched from the <rss-menu> element when a menu item is clicked. The render method (not shown) of <rss-list> is just DOM manipulation to add or remove <rss-item> children based on store data. Another event listener elsewhere reacts to the same event and updates the History API.
I created a base class inspired by Mayankās base element. This provides common boilerplate setup and methods. As you can see Iām mixing the ācustom elementā and āweb componentā terminology that Mayank would frown upon. Sorry!
If youāre curious the base element includes the following goodies.
export default class BaseComponent extends HTMLElement {
#subscriptions = [];
disconnectedCallback() {
this.unsubscribe();
}
subscribe(type, callback, target = globalThis) {
const fn = (ev) => callback.call(this, ev);
this.#subscriptions.push([type, fn, target]);
target.addEventListener(type, fn);
}
unsubscribe() {
for (const [type, fn, target] of this.#subscriptions) {
target.removeEventListener(type, fn);
}
this.#subscriptions.length = 0;
}
dispatch(type, detail) {
super.dispatchEvent(
new CustomEvent(type, {
detail,
bubbles: true,
composed: true,
cancelable: true,
}),
);
}
}
The subscribe method is used to track addEventListener usage. This allows event handlers to be removed automatically when the element is disconnected from the DOM. The dispatch method is another simple wrapper to ensure custom events bubble up through all nested shadow DOMs.
Iāve found the use of a global store and custom events to be very effective. It bypasses the āprop drillingā nightmare that is React. Replicating āpropsā with custom element attributes is a bad idea. Yeah, I know modern React state libraries achieve similar. Why use React to begin with just to work around one of its core design failures?
Dexie has a ālive queryā feature that allows me to dispatch an event when new content is synced. The nature of my web app means that data is mostly static. I donāt need much fine grained reactivity. This is a blessing because DOM manipulation can be tedious.
Justin Fagnani asked recently: āWhat should a native DOM templating API look like?ā Native templating of some form would be nice. The TC39 Signals proposal (tc39/proposal-signals) looks promising. If we had data binding to DOM nodes, via signals or otherwise, I donāt think we need a ānative JSXā.
I have a lot to explore in this area but Iām feeling good about the direction.
āWeb componentsā donāt exist the way frameworks define components. We have primitives like custom elements, custom events, and hopefully new APIs like signals thatāll make the āno buildā approach increasingly feasible.
I canāt pretend all front-end frameworks are as obsolete as React. I still think tools like Svelte have a place. Theyāre easier to work with āat scaleā ā i.e. something with more interactive UI than an RSS reader. I hate on React specifically because there is an entire generation of React developers that have never coded with web standards. React gets touted as the only solution and yet over a decade of use has shown it solves nothing in reality.
Iām not thrilled with Dexie but it does the job (so far). I need a web native database. That means IndexedDB and Iād prefer not to touch its irredeemably miserable API with my own hands. Bring back Web SQL I say!
Open Source
I know itās really lame but Croissant and the source is not available yet. Boooo!
Iām in two minds whether or not to stay on GitHub. Ensloppification and all that. Iāll publish code somewhere soon. I need time to test and package it in a usable state. My initial idea was to release it as a self-hosted container. Iāll look at the feasibility of packaging an Electron or Tauri app. I might even charge $5 bucks!
Theoretically I could host a version on the new Deno Deploy. Wouldnāt that be ironic! Does my CORS proxy fall foul of their acceptable use policy?
For my own use I will self-host in Proxmox. I use Tailscale to make stuff available outside my LAN. All data is client-side which does present a problem. How do I sync subscriptions and read/unread state between browsers? I have a feeling I wonāt like the answer.
Croissant is going to be super opinionated because itās designed for myself. It wonāt be a collaborative project accepting PRs because I donāt want the maintenance burden.
More on that later! If I run into any major hurdles I may still bin it.
Youāve made it this far which canāt go unrewarded. Take this gift: Googly Eyes mouse tracking CodePen! (dbushell) Donāt you dare click that if you skipped any topics!
Update for 18th July 2025
Sources on 'Progressive Web App'
Sources on 'Zig'
