DEV Community

Cover image for A modern marketing site without a modern front-end stack
NessFlow
NessFlow

Posted on Originally published at nessflow.com

A modern marketing site without a modern front-end stack

Laravel 13, Blade, Tailwind 4, Vite 8.

Astro, Hugo, Eleventy and Next are all good at building fast marketing sites. This is not a comparison, and there is no version of this article where one of them loses. Every one of those tools carries real advantages and real costs, and choosing between them is a question of context.

This is our context, and these are our numbers.

4,323 bytes. Gzipped JavaScript on a public page, including navigation, the theme switcher and audience measurement. Four modules, hand written, no framework.

The entry number, and where it comes from

A PageSpeed run on 17 August 2026 at 19:51, mobile, returns 99 for performance and 100 for accessibility, best practices and SEO. Those four numbers come from a lab pass on a single URL. The field data section reads "no data", because the site is too young and too lightly trafficked to appear in the user experience report.

Lab scores are cheap. They are also the only thing most teams publish, which is why the rest of this article is about the decisions underneath them, including one that consists of refusing to trust one of those four numbers.

What we did not install

Four absences, all verifiable from outside in a few seconds.

No CDN. The apex domain and its www subdomain resolve straight to an OVH server. No intermediate proxy, no edge network, no distributed cache rules to reason about when a page looks stale.

No asset domain. There is no ASSET_URL. Stylesheets, scripts and fonts come from the same origin as the HTML, through the same web server, under the same cache policy and the same certificate.

No front-end framework for the site. The 26 public views are Blade views. The 28 public routes go through no single page application layer. There is no second repository, no second pipeline, no second deployment model, and no second place where content can drift.

No flat-file CMS and no headless CMS. This is the absence people find most surprising, so it gets its own section.

Two build entries, and why that is the load-bearing decision

One application holds a React product and a Blade public site. Nothing prevents the public homepage from shipping the application bundle. That is in fact what happens by default the moment a shared entry point looks convenient.

So the Vite config declares two separate entries, and the comment next to them states the reason rather than the rule.

// A marketing page must ship neither React, nor Inertia,
// nor the product bundle. Putting them in app.css/app.tsx
// would make the public homepage pay for the whole app.
input: [
    'resources/css/app.css',
    'resources/js/app.tsx',
    'resources/css/marketing.css',
    'resources/js/marketing.ts',
],
Enter fullscreen mode Exit fullscreen mode

Walking the production manifest, following the imports of the public entry and compressing each file, gives the payload of a page.

Resource Files Raw Gzipped
JavaScript 4 10,833 B 4,323 B
CSS 1 73,931 B 13,110 B
Total 5 84,764 B 17,433 B

A complete public page, navigation and interactions included, is 17.4 KB of compressed script and style. The JavaScript is compiled TypeScript with no framework, written for what those pages actually do.

The separation is structural, not a convention someone has to remember. That distinction is the whole point: a rule written in a document decays, a rule written in the build does not.

Content lives in Postgres, not in Markdown

The usual path for a static site is Markdown files in the repository, rebuilt and redeployed on every edit, or a headless CMS: another service, another bill, another API to keep in sync, another authentication story.

Our content hub is a set of Eloquent models in PostgreSQL, administered through a Filament panel. A non developer edits an article and it is live. No rebuild, no deploy, no content API, no webhook to invalidate anything. Laravel already has the model layer, the admin panel, the validation, the authorization and the localization; using them for content costs close to nothing.

Putting content in the database rather than in files also has a consequence that took us a while to appreciate, and it is the most strategically interesting part of this architecture.

The public site can read the session

The marketing site runs inside the same Laravel application as the product. Same database, same session, same authentication, same authorization. Which means a public Blade page can know, server side, before a single byte of HTML is rendered: whether this visitor is signed in, which team they belong to, which plan they are on, whether they have ever run an audit, whether they abandoned onboarding halfway through.

We do not do this yet. Our public views currently read no session state at all. But the capability is sitting there at zero additional infrastructure cost, and it is worth naming what category it belongs to.

Personalizing a marketing site against product state is normally the territory of Adobe Experience Manager, Optimizely, and the personalization tier of a headless CMS. Those products largely exist to reconnect a static marketing site to state that lives somewhere else.

When the site and the product are the same application, that reconnection is a function call. A static build plus a headless CMS cannot do it without standing up an API, shipping a client side fetch, and accepting a flash of the generic version before the personalized one arrives. We get the server rendered, no flicker, no extra request version for free, and we have not spent it yet.

One design system, two entries, no drift

The two Vite entries are separate, but they are not independent. Both import the same token sheet.

// app.css
@import './theme.css';

// marketing.css: tokens come from theme.css, shared with app.css
@import './theme.css';
Enter fullscreen mode Exit fullscreen mode

Change a color, a radius or a spacing step, and the product and the public site both move, in the same build, in the same commit, verified by the same tests. There is no design system package to version and publish, no synchronization step between two repositories, no pair of Tailwind configs slowly drifting apart, and no marketing site that quietly starts looking like last year's product.

This is not a claim that a separate front-end stack does this badly. It is a claim that it cannot do it this way. Two codebases means a publish step between them, and a publish step means a version, a changelog and a lag. One codebase means the lag is zero because there is nowhere for it to accumulate.

Tailwind with tokens

The public stylesheet is 73,931 bytes raw and 13,110 compressed, covering all 26 views. That is simply what Tailwind gives when you do not work against it.

In practice that means design tokens instead of arbitrary values. The palette lives in CSS variables, declared once, and the utilities consume them. A color hard coded into a view is a color that escapes the dark theme, escapes contrast auditing and escapes every future adjustment. Once the same surface has to exist in light and dark, the difference between the two approaches stops being aesthetic and becomes structural.

It is also what makes the next section possible.

The score that lied

Our first real Lighthouse campaign returned 100 for accessibility. It was wrong.

The token used for every piece of secondary text on the site produced a contrast ratio of 4.47 to 1 against the page canvas. The AA minimum is 4.5. We were three hundredths short, across every subtitle and every piece of metadata, on pages that discuss compliance, inside a product whose accessibility module grades our customers' sites.

Three hundred and twenty feature tests could not see it, for a reason worth stating as it is.

A CSS file has no type checking. The TypeScript compiler does not read it, the static analyser does not read it, the formatter does not read it. Its tests are its only verification.

The fix was not the color. Fixing a color does nothing about the next one. The fix was a test that reads the shipped stylesheet from disk, follows variable aliases, composites semi transparent colors over their real background, then redoes the WCAG math.

$muted  = themeToken('--sp-ink-500');
$canvas = themeToken('--sp-canvas');

expect(contrastRatio($muted, $canvas))->toBeGreaterThanOrEqual(4.5)
    ->and(contrastRatio($muted, '#ffffff'))->toBeGreaterThanOrEqual(4.5);
Enter fullscreen mode Exit fullscreen mode

Two properties of that test matter more than the test itself.

It reads the shipped baseline, never an injected value. A test that hands itself the color it is about to measure only tests arithmetic. This one opens the file the browser will receive.

It composites before measuring. A semi transparent color has no contrast of its own; it only has one once it sits on a background. Measuring the nominal value would certify white at 20 percent opacity. That is the class of mistake that produces a green test and an unreadable page.

The current ratio is 4.753. The threshold is a pinned floor rather than a target, so if someone lightens the canvas in six months, the test fails before production does.

The same math settled a design argument

On our deep blue surface, the green check mark that marks features included in a plan measures 2.921 to 1. On the dark theme accent it measures 4.29. Below the threshold on both.

So the check mark is not green. It takes the text color, and a test pins that refusal, so nobody restores the green believing they are improving the screen. A brand decision settled by a calculation instead of an opinion.

What CI asserts, and what it refuses to assert

The Lighthouse CI budget for our public pages is three lines.

"categories:accessibility": ["error", { "minScore": 1 }],
"categories:seo":           ["error", { "minScore": 1 }],
"categories:performance":   "off"
Enter fullscreen mode Exit fullscreen mode

Accessibility and SEO are blocking assertions, required at 1.00. Performance is explicitly off.

The budget runs a single pass, and performance is non deterministic: it depends on the load of the CI machine, on the network, on luck. A threshold on it produces red builds with no cause and green builds with no merit, and within three weeks the team learns to ignore the color. A guard that gets ignored is worse than no guard, because it leaves people believing they are covered.

Accessibility and SEO are deterministic. The same files yield the same verdict, so they can be demanded at perfection, and they are.

The number that reframes the whole exercise

Here is a performance trace of the site taken with a 20 times CPU slowdown and Slow 4G throttling, which is a deliberately brutal profile. The interesting column is main thread time by party.

Party Main thread
Wappalyzer (browser extension) 690.2 ms
AdBlock (browser extension) 629.9 ms
Fake Filler (browser extension) 364.3 ms
React DevTools (browser extension) 213.4 ms
The site itself, first party 206.7 ms
Cookieless analytics, our only third party 23.5 ms

Every single browser extension in that profile costs more main thread time than the entire website. Wappalyzer alone costs 3.3 times more. Our one third party script, cookieless analytics, costs 23.5 milliseconds.

We find this genuinely useful rather than flattering. Once first party work is down in the low hundreds of milliseconds, the dominant term in a real visitor's experience is no longer your framework choice. It is their extensions, their device and their network. The engineering value of going from 4 KB to 2 KB is close to zero. The engineering value of the accessibility guard, which fixes something a visitor actually experiences, is real.

The trade-offs we accepted

  • Page transitions are full loads. No instant navigation, no hover prefetching unless we write it. Correct for a content site, wrong for an application, which is exactly why our product does not run this way.
  • Every interactive behaviour is hand written. Menu, theme switcher, scroll reveals: TypeScript nobody maintains for us. A 4 KB budget is a constraint as much as a result.
  • Without a CDN, geography is real. A visitor far from the origin pays the distance. Our audience is concentrated, so we take that trade, and it is a trade rather than a superiority.
  • The two worlds have to stay watertight, and that boundary has a cost we paid in production. It is the subject of the next article.

How we decide

Three questions, asked of every piece we consider adding.

  • Does this solve a problem we have, or a problem we might have?
  • How many surfaces will we maintain because of it, and who maintains them?
  • If it disappears during a version upgrade, is that a degradation or an outage?

The answers gave us a public site in the same application as the product, with no intermediate layer and 4,323 bytes of JavaScript. Different answers give different architectures, and that is fine. What transfers is not the stack. It is measuring before deciding, then writing a guard so the decision outlives the people who made it.

Next. Three rendering worlds share this application: Blade for the public site, Inertia and React for the product, Filament for the admin console. We did not have to choose, and that was the right call. But the boundaries between those worlds cost something, and we shipped a defect to production that no feature test could ever have caught. That is the next article.


Measurements: production manifest, 18 August 2026, gzip level 6 applied per file. PageSpeed mobile, 17 August 2026 19:51, lab, single URL, no field data. Chrome performance trace, 20x CPU throttling, Slow 4G. Contrast ratios computed with the WCAG 2.2 formula.

Originally published at nessflow.com.
I write about how NessFlow is built at nessflow.com/en/engineering.

Top comments (0)