I ran wc -l over every file under src/ on Notifio's website this week, expecting the answer at the top to be a page with a lot of product on it. It was not. It was this:
567 src/app/terms/page.tsx
548 src/lib/alerts/sites/europe.ts
472 src/lib/articles/guides.ts
456 src/app/page.tsx
431 src/lib/alerts/sites/netherlands.ts
426 src/lib/compare/targets.ts
403 src/app/privacy/page.tsx
The largest file in the project is the terms of service. The seventh largest is the privacy policy. Between them that is 970 lines, which is more code than the home page, the pricing logic and the entire comparison system put together, and not one line of it is read by any other module.
That ratio felt wrong when I first saw it, and then I thought about it for a while and decided it is correct. Here is the reasoning, because every small product eventually has to decide where its legal copy lives, and "a React component" is a less obvious answer than it looks.
What is actually in there
The terms page is 18 numbered sections:
1. Acceptance of Terms
2. What Notifio Does
3. Auto-Reply Upgrade
4. Your Responsibilities When Using Auto-Reply
5. No Guarantee of Results or Site Compatibility
6. Licence
7. Payment for the Base Licence
8. Auto-Reply Upgrade
9. Cancellation and Refunds
10. Acceptable Use
11. Suspension and Withdrawal of Features
12. Third-Party Services
13. Disclaimer of Warranties
14. Limitation of Liability
15. Termination
16. Changes to These Terms
17. Governing Law
18. Who We Are and How to Contact Us
The privacy policy is 11, and three of its headings exist because the product is a desktop app rather than a website: "Data We Hold On Our Servers", "Data Stored Only On Your Device", and "Data Sent To Rental Websites By Auto-Reply". Those three are the only honest way to describe a thing that runs on your laptop, keeps its state in your own user data directory, and only talks to our server to check a licence and send you mail.
That shape, incidentally, is why the privacy policy is long. A SaaS privacy policy can say "we store your data" once. An app that deliberately keeps most of your data out of our reach has to say where each kind of data actually went, and that takes three sections instead of one.
Why it is JSX and not MDX
The obvious move is MDX, or a markdown file, or a CMS. I did none of those, and the whole argument is this comment, which sits at the top of both files:
// title 26/60 chars rendered (the "| Notifio" template adds 10) | description 148/160 chars
export const metadata = {
title: "Terms of Service",
description:
"Notifio terms of service. Lifetime licence for monitoring, optional one-time auto-reply upgrade, acceptable use policy, and limitation of liability.",
alternates: { canonical: "/terms" },
openGraph: { url: "/terms" },
};
Every page on the site carries that comment and that metadata shape, and a test reads the source files to prove the title budget is respected. I wrote about that machinery before in Our title budget is 60 characters, the layout quietly spends ten of them, and a test reads our own source files to prove it.
A markdown pipeline would have given the legal pages their own way of declaring a title, their own canonical handling, and their own answer to the question of whether they render per request. Two pages would have been special. The cost of MDX is not the renderer, it is that the two least-visited pages on the site stop being ordinary pages, and then every site-wide rule has to be taught about them. 970 lines of boring JSX buys the property that there are no exceptions.
The duplicated helpers, on purpose
Each legal page ends with the same two components:
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2 className="mb-4 text-[17px] font-semibold tracking-tight text-[#fafafa]">
{title}
</h2>
{children}
</div>
);
}
function Prose({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-4 text-[14px] leading-relaxed text-[#a3a3a3] [&_p+p]:mt-3 [&_strong]:font-semibold [&_strong]:text-[#e5e5e5] [&_ul]:mt-3 [&_ul]:space-y-2 [&_ul]:pl-5 [&_ul_li]:list-disc">
{children}
</div>
);
}
Those definitions are byte for byte identical in the two files. diff on the two ranges returns nothing. I know, and I have left them that way.
The reason is that these two pages are the only pages on the site I am ever going to edit for a reason that has nothing to do with the product. A refund window changes, a sub-processor changes, a company detail changes, and the edit is driven by something outside the codebase entirely. When that happens I want the diff to be confined to one file, and I want to be able to read that file top to bottom without resolving an import to find out what a heading looks like. A shared <LegalSection> would save 24 lines and add a file that, if touched, silently restyles two documents that are supposed to be stable.
This is the opposite of the call I made for the 15 per-site alert pages, which all render through one typed component precisely because they must stay identical. Identical by accident is a bug there. Here, drift between the terms and the privacy policy is fine, because they are different documents.
The three strings a constant already knows
Now the part I am less comfortable with.
src/lib/publisher.ts exports this:
export const PUBLISHER = {
name: 'CogniPrep Ltd',
registeredIn: 'England and Wales',
companyNumber: '17407266',
id: 'https://cogniprep.app/about#cogniprep-ltd',
} as const;
That constant is the single source of truth for who publishes the app, it is duplicated deliberately across five repositories, and the structured data on every page reads it. Its own header comment says that each app's terms and privacy notice name the same company.
Two files import it: the root layout and the about page. Section 18 of the terms is not one of them:
The Software is provided by CogniPrep Ltd, a company registered
in England and Wales (company number 17407266). CogniPrep Ltd is
...
Hardcoded. Same for the price, which appears as the literal string £20 three times in the terms, in the payment section, the upgrade section and the liability cap, while a Price component exists and is used on the pages where the number is a sales number. I wrote about why the site has both at Our price is a component six times and a hardcoded string fifty-two times, and one sentence decides which.
The rule that post lands on is that a price the reader is being asked to agree to must not be able to change under them, so prose gets a literal and a sales page gets a component. I still believe that. The company number is a weaker case: it is not a number anybody negotiates, and interpolating {PUBLISHER.companyNumber} into section 18 would cost nothing and would mean a company detail could never be right in the structured data and stale in the document that legally matters.
The honest reason it is hardcoded is that the legal pages were written before publisher.ts existed, and nobody went back. Writing this post is how I found that out, which is most of why I write them.
What the ratio actually means
A 567 line terms page on a site whose entire production database schema is one table and 33 lines looks like a project with its priorities inverted. I do not think it is. The schema is small because the product deliberately keeps almost nothing. The terms are long for exactly the same reason: an app that runs on your machine, replays a form you recorded yourself, and talks to rental sites as you has a genuinely unusual set of things to be clear about, and section 4 of the terms exists to say them.
If you want to see what the pages look like rendered, they are at notifio.app/terms and notifio.app/privacy, and the company they both name is on the about page, which is the one page that gets it from the constant.
Top comments (0)