ShareNotes is a Vite + React single-page app. Every note lives at an address like
sharenotes.dev/blue-fox-42, and every one of those addresses is answered by
the same index.html shell that boots the app. That is exactly what you want
for notes, which are private by default and marked noindex as soon as the
app loads one. It is exactly what you do not want for the pages meant to be found.
The split: the app is an app, the pages are pages
Instead of rendering React on a server, we stopped asking React to produce the pages
that need to rank. Every landing page and blog post is a hand-written HTML file in the
project's public/ folder — public/pastebin-alternative/index.html,
public/blog/<post>/index.html and so on. Vite copies that folder into
the build untouched, and the web server serves each one as a real file.
A crawler asking for one of those URLs gets a complete document: headings, copy, internal links, structured data. No JavaScript has to run. There are 35 of these pages now, against one app shell.
The homepage is the one place the two meet, because it is also the editor. Its
index.html carries the title, description and structured data directly, plus
a <noscript> block with real content for anything that does not run
scripts.
The service worker that ate a page
ShareNotes is installable, which means a service worker. A typical SPA setup tells it:
for any navigation, serve the cached app shell. That rule does not know your static pages
exist. On 11 August a newly added page, /send-text-to-yourself/, was
unreachable for every returning visitor: their service worker served the app shell, the
app read the path as a note address, showed "Retrieving note…", found nothing, and sent
them to the homepage.
The fix was already half there: a deny-list of static pages the worker must leave
alone. The bug was that the list was typed by hand, and someone added a page without
updating it. Its own comment warned about exactly that — which is the tell that a
hand-maintained list was the wrong shape. Now the build reads the public/
folder and generates the list, so adding a page is enough:
const STATIC_PAGES = fs.readdirSync(PUBLIC_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory() &&
fs.existsSync(path.join(PUBLIC_DIR, e.name, 'index.html')))
.map((e) => e.name);
navigateFallbackDenylist: [/^\/api/, /^\/ws/,
...STATIC_PAGES.map((p) => new RegExp(`^/${p}/`))],
The service worker that inflated our traffic
The second problem was quieter. The worker was also told to precache every HTML file in the build — which included every landing page. So every new visitor's browser fetched all of them in the background. When we looked, those background fetches were around 90% of the hits the landing pages received. Only the app shell needs to work offline; the landing pages are now excluded from precaching.
If your SPA has a service worker and static pages, check both rules: the navigation fallback must skip your static pages, and the precache must not swallow them. Each failure is invisible from a normal browser session.
What we measured — and what we did not
Google Search Console showed 16 pages indexed and 5 correctly excluded in August, with
zero indexing errors. For months before that, our weekly reports had used
site:sharenotes.dev as the indexing number and reported almost nothing; it
disagreed with Search Console by a factor of eight. site: is an estimate, not
a measurement. Use the URL Inspection tool and the Pages report.
The honest footnote: organic search is about 1% of ShareNotes' traffic. Almost all of it comes from people opening links someone shared with them. Crawlable pages are worth having, but they were never the main channel, and we would not tell you SSR — or this approach — will change that on its own.
The trade-offs
- No shared components. The header and footer exist in every static file. Adding a page means updating every footer in one pass, and dark mode for all 35 pages is applied by a script in the same way.
- Two styling systems. The app uses Tailwind; the static pages use their own small inline CSS.
- No server rendering to run. No Node process renders pages, nothing to cache or scale for them, and they load with no JavaScript at all.
For a site whose pages that need to rank are mostly words and whose app is mostly interaction, that has been a good trade. If you want to see the app half of it, the developer API page is one of those static files.