blog
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"mcp__magic__21st_magic_component_inspiration",
|
"mcp__magic__21st_magic_component_inspiration",
|
||||||
"Bash(npm run *)",
|
"Bash(npm run *)",
|
||||||
"WebFetch(domain:localhost)",
|
"WebFetch(domain:localhost)",
|
||||||
"Bash(curl -s http://localhost:3000/fr)"
|
"Bash(curl -s http://localhost:3000/fr)",
|
||||||
|
"Bash(npx tsc *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
|
import { getArticleBySlug, getArticleSlugs } from "@/lib/blog/get-articles";
|
||||||
|
import { ArticleHero } from "@/components/sections/ArticleHero";
|
||||||
|
import { locales } from "@/middleware";
|
||||||
|
|
||||||
|
type props = {
|
||||||
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
const paths: { locale: string; slug: string }[] = [];
|
||||||
|
|
||||||
|
locales.forEach((locale) => {
|
||||||
|
getArticleSlugs(locale).forEach((slug) => {
|
||||||
|
paths.push({ locale, slug });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ArticlePage({ params }: props) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
const article = getArticleBySlug(slug, locale);
|
||||||
|
|
||||||
|
if (!article || !article.ready) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-brand-beige">
|
||||||
|
<ArticleHero
|
||||||
|
locale={locale}
|
||||||
|
title={article.title}
|
||||||
|
description={article.description}
|
||||||
|
category={article.category}
|
||||||
|
date={article.date}
|
||||||
|
readTime={article.readTime}
|
||||||
|
cover={article.cover}
|
||||||
|
/>
|
||||||
|
<div className="mx-auto max-w-[720px] px-8 md:px-14 lg:px-6 py-20">
|
||||||
|
{article.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-14">
|
||||||
|
{article.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="font-sans text-[10px] uppercase tracking-[0.18em] text-brand-brown/50 border border-brand-brown/15 rounded-full px-3 py-1 hover:border-brand-brown/35 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<article
|
||||||
|
className="
|
||||||
|
flex flex-col gap-12
|
||||||
|
[&_h2]:font-serif [&_h2]:text-[clamp(40px,5vw,64px)] [&_h2]:text-brand-brown [&_h2]:leading-none [&_h2]:mb-6 [&_h2]:mt-2
|
||||||
|
[&_p]:font-sans [&_p]:text-sm [&_p]:text-brand-brown/70 [&_p]:leading-[1.9]
|
||||||
|
[&_strong]:font-semibold [&_strong]:text-brand-brown/85
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<MDXRemote
|
||||||
|
source={article.content}
|
||||||
|
options={{ blockJS: false, blockDangerousJS: true }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getAllArticles } from "@/lib/blog/get-articles";
|
||||||
|
import { BlogHero } from "@/components/sections/BlogHero";
|
||||||
|
import { ArticleList } from "@/components/sections/ArticleList";
|
||||||
|
|
||||||
|
type props = {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BlogPage({ params }: props) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const articles = getAllArticles(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-brand-beige">
|
||||||
|
<BlogHero />
|
||||||
|
<ArticleList locale={locale} articles={articles} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
type props = {
|
||||||
|
locale: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
date: string;
|
||||||
|
readTime?: string;
|
||||||
|
cover?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArticleHero({ locale, title, description, category, date, readTime, cover }: props) {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const t = useTranslations('blogPage');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const section = ref.current;
|
||||||
|
if (!section) return;
|
||||||
|
section.querySelectorAll<HTMLElement>('.hero-reveal').forEach((el, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.transform = 'translateY(0)';
|
||||||
|
}, 80 + i * 100);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatted = new Date(date).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
className="w-full min-h-[calc(100vh-(2*11px))] rounded-[10px] bg-brand-brown text-brand-beige flex flex-col overflow-hidden relative"
|
||||||
|
>
|
||||||
|
{cover && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-full h-full object-cover opacity-[0.08]"
|
||||||
|
style={{ filter: 'grayscale(100%)' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-brand-brown/60 via-brand-brown/40 to-brand-brown/90" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative z-10 shrink-0 flex items-end px-8 md:px-14 lg:px-20 pb-4"
|
||||||
|
style={{ height: 'clamp(120px, 15vh, 160px)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="hero-reveal"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(20px)', transition: 'opacity 500ms ease, transform 500ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog`}
|
||||||
|
className="group inline-flex items-center gap-2 font-sans text-xs uppercase tracking-[0.18em] text-brand-beige/50 hover:text-brand-beige transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
||||||
|
</svg>
|
||||||
|
{t('backToBlog')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col flex-1 items-center justify-center px-8 md:px-14 lg:px-20 text-center">
|
||||||
|
<div
|
||||||
|
className="hero-reveal mb-6"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(20px)', transition: 'opacity 600ms ease, transform 600ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-3 font-sans text-[11px] uppercase tracking-[0.2em] text-brand-beige/45">
|
||||||
|
<span className="w-5 h-px bg-brand-beige/30" />
|
||||||
|
{category}
|
||||||
|
{readTime && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-brand-beige/20" />
|
||||||
|
{readTime}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="w-5 h-px bg-brand-beige/30" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hero-reveal"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(28px)', transition: 'opacity 700ms ease, transform 700ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<h1 className="font-serif text-[clamp(36px,5.5vw,88px)] leading-[0.92] text-brand-beige max-w-4xl uppercase">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hero-reveal mt-8"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(24px)', transition: 'opacity 700ms ease, transform 700ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<p className="font-sans text-base font-light text-brand-beige/55 max-w-xl leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hero-reveal mt-6"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(20px)', transition: 'opacity 700ms ease, transform 700ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<span className="font-sans text-xs text-brand-beige/30 tracking-wide">
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex justify-center pb-8 text-brand-beige/30">
|
||||||
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
|
<span className="font-sans text-[10px] uppercase tracking-[0.15em]">{t('scrollPrompt')}</span>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
style={{ animation: 'arrowBounce 1.8s ease-in-out infinite' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Article } from '@/lib/blog/types';
|
||||||
|
|
||||||
|
type props = {
|
||||||
|
locale: string;
|
||||||
|
articles: Article[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArticleList({ locale, articles }: props) {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const t = useTranslations('blogPage');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
el.querySelectorAll('.reveal').forEach((node, i) => {
|
||||||
|
setTimeout(() => node.classList.add('visible'), i * 100);
|
||||||
|
});
|
||||||
|
observer.unobserve(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.05 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!articles.length) {
|
||||||
|
return (
|
||||||
|
<section className="py-24 px-8 md:px-14 lg:px-20 text-center">
|
||||||
|
<p className="font-sans text-sm text-brand-brown/40">{t('noArticles')}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="py-20 px-8 md:px-14 lg:px-20 max-w-[900px] mx-auto">
|
||||||
|
<div className="flex flex-col divide-y divide-brand-brown/10">
|
||||||
|
{articles.map((article, i) => {
|
||||||
|
const formatted = new Date(article.date).toLocaleDateString(
|
||||||
|
locale === 'fr' ? 'fr-FR' : 'en-US',
|
||||||
|
{ year: 'numeric', month: 'long', day: 'numeric' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={article.slug}
|
||||||
|
href={`/${locale}/blog/${article.slug}`}
|
||||||
|
className="reveal group flex flex-col md:flex-row md:items-center gap-6 py-10 cursor-pointer"
|
||||||
|
style={{ transitionDelay: `${i * 80}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<span className="font-sans text-[10px] uppercase tracking-[0.2em] text-brand-brown/40">
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
{article.readTime && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-brand-brown/20" />
|
||||||
|
<span className="font-sans text-[10px] text-brand-brown/30">
|
||||||
|
{article.readTime}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-serif text-[clamp(24px,3vw,36px)] leading-tight text-brand-brown uppercase mb-3 group-hover:opacity-70 transition-opacity duration-200">
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
<p className="font-sans text-sm text-brand-brown/55 leading-relaxed line-clamp-2">
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
<span className="font-sans text-xs text-brand-brown/30 mt-3 block">
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 font-sans text-xs uppercase tracking-[0.15em] text-brand-brown/40 group-hover:text-brand-brown transition-colors duration-200 flex-shrink-0">
|
||||||
|
{t('readArticle')}
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 group-hover:translate-x-1 transition-transform duration-200"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export function BlogHero() {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const t = useTranslations('blogPage');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const section = ref.current;
|
||||||
|
if (!section) return;
|
||||||
|
section.querySelectorAll<HTMLElement>('.hero-reveal').forEach((el, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.transform = 'translateY(0)';
|
||||||
|
}, 80 + i * 120);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
className="w-full rounded-[10px] bg-brand-brown text-brand-beige flex flex-col overflow-hidden"
|
||||||
|
style={{ minHeight: 'clamp(320px, 45vh, 480px)' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col flex-1 items-center justify-center px-8 md:px-14 lg:px-20 text-center py-24">
|
||||||
|
<div
|
||||||
|
className="hero-reveal mb-6"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(20px)', transition: 'opacity 600ms ease, transform 600ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-3 font-sans text-[11px] uppercase tracking-[0.2em] text-brand-beige/45">
|
||||||
|
<span className="w-5 h-px bg-brand-beige/30" />
|
||||||
|
{t('hero.sectionLabel')}
|
||||||
|
<span className="w-5 h-px bg-brand-beige/30" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hero-reveal"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(28px)', transition: 'opacity 700ms ease, transform 700ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<h1 className="font-serif text-[clamp(56px,8vw,110px)] leading-none text-brand-beige uppercase">
|
||||||
|
{t('hero.title')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hero-reveal mt-8"
|
||||||
|
style={{ opacity: 0, transform: 'translateY(24px)', transition: 'opacity 700ms ease, transform 700ms cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
>
|
||||||
|
<p className="font-sans text-base font-light text-brand-beige/55 max-w-lg leading-relaxed">
|
||||||
|
{t('hero.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
ready: true
|
||||||
|
title: "The Architecture of Modern Web Applications"
|
||||||
|
description: "How to think about and build scalable, maintainable, and lasting systems in the age of microservices and cloud-native infrastructure."
|
||||||
|
date: "2026-04-10"
|
||||||
|
category: "Architecture"
|
||||||
|
tags:
|
||||||
|
- "Architecture"
|
||||||
|
- "Web"
|
||||||
|
- "Scalability"
|
||||||
|
- "Cloud"
|
||||||
|
readTime: "8 min"
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Laying the right foundations</h2>
|
||||||
|
|
||||||
|
Software architecture is the art of making structural decisions before writing the first line of code. A poor decision at this stage can cost months of refactoring later. Yet too many projects start without giving it the time it deserves.
|
||||||
|
|
||||||
|
The first question to ask isn't "what technology should we use?" but "what are the real constraints of the system?" Data volume, update frequency, number of concurrent users, availability requirements — these parameters dictate technical choices long before the programming language comes into play.
|
||||||
|
|
||||||
|
A well-architected system is not necessarily complex. Complexity is often a sign of a poor understanding of the problem, not a good solution. The goal is to achieve appropriate simplicity: robust enough to meet the needs, simple enough to remain maintainable.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Monolith or microservices?</h2>
|
||||||
|
|
||||||
|
This debate comes up endlessly. In reality, there is no universal answer — only different contexts.
|
||||||
|
|
||||||
|
A well-structured monolith is often the best option for starting a product. It is simpler to develop, deploy, and debug. The boundaries between modules remain malleable as long as the functional scope is rapidly evolving. Microservices add significant operational complexity: managing network failures, distributed data consistency, and orchestrating deployments.
|
||||||
|
|
||||||
|
The move to microservices is justified when distinct teams need to evolve independently, when certain components require different scaling strategies, or when security constraints impose strong isolation. It's not a question of project size, but of organizational maturity.
|
||||||
|
|
||||||
|
Progressive migration remains the best approach: identify natural bounded contexts in the monolith, extract them one by one, and validate each split before moving on.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Scalability: a property to design from the start</h2>
|
||||||
|
|
||||||
|
Scalability cannot be added as an afterthought. It must be designed upfront, through data modeling choices, the way services communicate, and state management.
|
||||||
|
|
||||||
|
A few guiding principles:
|
||||||
|
|
||||||
|
**Stateless by default.** Stateless services are intrinsically easier to scale horizontally. State must be externalized to dedicated systems — databases, distributed cache, message queues — and not stored in process memory.
|
||||||
|
|
||||||
|
**Event-driven decoupling.** Event-driven architectures reduce temporal coupling between services. A service publishes an event; consumers react at their own pace. This approach improves resilience and makes it easier to evolve components independently.
|
||||||
|
|
||||||
|
**Multi-level caching.** CDN for static assets, application cache for frequently read data, query cache on the database side. Each cache layer reduces load on the downstream system and improves response times.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Technical debt: the silent enemy</h2>
|
||||||
|
|
||||||
|
Technical debt is inevitable. What is not inevitable is letting it accumulate without a repayment strategy. Every conscious simplification decision — a shortcut taken to meet a deadline — must be documented and scheduled for correction.
|
||||||
|
|
||||||
|
Warning signs are often subtle: increasing onboarding time for new developers, multiplying regression bugs, a general feeling that "changing anything is risky." These signals typically precede crises by several months.
|
||||||
|
|
||||||
|
Systematically allocating part of each sprint to technical health is not a luxury — it is a medium-term survival condition for any ambitious product.
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
ready: true
|
||||||
|
title: "L'architecture des applications web modernes"
|
||||||
|
description: "Comment penser et construire des systèmes scalables, maintenables et pérennes à l'ère des microservices et du cloud-native."
|
||||||
|
date: "2026-04-10"
|
||||||
|
category: "Architecture"
|
||||||
|
tags:
|
||||||
|
- "Architecture"
|
||||||
|
- "Web"
|
||||||
|
- "Scalabilité"
|
||||||
|
- "Cloud"
|
||||||
|
readTime: "8 min"
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Poser les bonnes fondations</h2>
|
||||||
|
|
||||||
|
L'architecture logicielle, c'est l'art de prendre des décisions structurantes avant même d'écrire la première ligne de code. Une mauvaise décision à ce stade peut coûter des mois de refactoring plus tard. Pourtant, trop de projets démarrent sans y consacrer le temps nécessaire.
|
||||||
|
|
||||||
|
La première question à se poser n'est pas « quelle technologie utiliser ? » mais « quelles sont les contraintes réelles du système ? ». Volume de données, fréquence des mises à jour, nombre d'utilisateurs simultanés, exigences de disponibilité — ces paramètres dictent les choix techniques bien avant que le langage de programmation entre en jeu.
|
||||||
|
|
||||||
|
Un système bien architecturé n'est pas forcément complexe. La complexité est souvent le signe d'une mauvaise compréhension du problème, pas d'une bonne solution. L'objectif est d'atteindre la simplicité adéquate : assez robuste pour répondre aux besoins, assez simple pour rester maintenable.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Monolithe ou microservices ?</h2>
|
||||||
|
|
||||||
|
Ce débat revient inlassablement. En réalité, il n'existe pas de réponse universelle — seulement des contextes différents.
|
||||||
|
|
||||||
|
Un monolithe bien structuré est souvent la meilleure option pour démarrer un produit. Il est plus simple à développer, déployer et déboguer. La frontière entre les modules reste malléable tant que le périmètre fonctionnel évolue rapidement. Les microservices ajoutent une complexité opérationnelle significative : gestion des erreurs réseau, cohérence des données distribuées, orchestration des déploiements.
|
||||||
|
|
||||||
|
Le passage aux microservices se justifie quand des équipes distinctes doivent évoluer indépendamment, quand certains composants nécessitent des stratégies de scalabilité différentes, ou quand des contraintes de sécurité imposent une isolation forte. Ce n'est pas une question de taille de projet, mais de maturité organisationnelle.
|
||||||
|
|
||||||
|
La migration progressive reste la meilleure approche : identifier les bounded contexts naturels dans le monolithe, les extraire un à un, et valider chaque découpe avant de continuer.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>La scalabilité, une propriété à concevoir dès le départ</h2>
|
||||||
|
|
||||||
|
La scalabilité ne s'ajoute pas après coup. Elle se conçoit en amont, dans les choix de modélisation des données, dans la façon dont les services communiquent, et dans la gestion de l'état.
|
||||||
|
|
||||||
|
Quelques principes structurants :
|
||||||
|
|
||||||
|
**Stateless par défaut.** Les services sans état sont intrinsèquement plus faciles à scaler horizontalement. L'état doit être externalisé vers des systèmes dédiés — base de données, cache distribué, file de messages — et non stocké en mémoire du processus.
|
||||||
|
|
||||||
|
**Découplage par les événements.** Les architectures event-driven réduisent le couplage temporel entre les services. Un service publie un événement ; les consommateurs réagissent à leur rythme. Cette approche améliore la résilience et facilite l'évolution indépendante des composants.
|
||||||
|
|
||||||
|
**Cache à plusieurs niveaux.** CDN pour les assets statiques, cache applicatif pour les données fréquemment lues, cache de requêtes côté base de données. Chaque niveau de cache réduit la charge sur le système aval et améliore les temps de réponse.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>La dette technique : l'ennemi silencieux</h2>
|
||||||
|
|
||||||
|
La dette technique est inévitable. Ce qui ne l'est pas, c'est de la laisser s'accumuler sans stratégie de remboursement. Toute décision de simplification consciente — un raccourci pris pour tenir une deadline — doit être documentée et planifiée pour être corrigée.
|
||||||
|
|
||||||
|
Les indicateurs d'alarme sont souvent subtils : augmentation du temps de onboarding des nouveaux développeurs, multiplication des bugs régressifs, sentiment général que « modifier quoi que ce soit est risqué ». Ces signaux précèdent généralement les crises de plusieurs mois.
|
||||||
|
|
||||||
|
Allouer systématiquement une partie du temps de chaque sprint à la santé technique n'est pas un luxe — c'est une condition de survie à moyen terme pour tout produit ambitieux.
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { Article, ArticleFrontmatter } from "./types";
|
||||||
|
|
||||||
|
const CONTENT_DIR = path.join(process.cwd(), "content/blog");
|
||||||
|
|
||||||
|
export function getArticleSlugs(locale: string): string[] {
|
||||||
|
const localeDirectory = path.join(CONTENT_DIR, locale);
|
||||||
|
|
||||||
|
if (!fs.existsSync(localeDirectory)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(localeDirectory)
|
||||||
|
.filter((file) => file.endsWith(".mdx"))
|
||||||
|
.map((fileName) => fileName.replace(/\.mdx$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArticleBySlug(slug: string, locale: string): Article | null {
|
||||||
|
const realSlug = slug.replace(/\.mdx$/, "");
|
||||||
|
const fullPath = path.join(CONTENT_DIR, locale, `${realSlug}.mdx`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileContents = fs.readFileSync(fullPath, "utf8");
|
||||||
|
const { data, content } = matter(fileContents);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: realSlug,
|
||||||
|
...(data as ArticleFrontmatter),
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllArticles(locale: string): Article[] {
|
||||||
|
return getArticleSlugs(locale)
|
||||||
|
.map((slug) => getArticleBySlug(slug, locale))
|
||||||
|
.filter((article): article is Article => article !== null && article.ready)
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type ArticleFrontmatter = {
|
||||||
|
ready: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
cover?: string;
|
||||||
|
readTime?: string;
|
||||||
|
summary?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Article = ArticleFrontmatter & {
|
||||||
|
slug: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
@@ -59,6 +59,17 @@
|
|||||||
},
|
},
|
||||||
"copyright": "© 2026 M. Guilbert. All rights reserved."
|
"copyright": "© 2026 M. Guilbert. All rights reserved."
|
||||||
},
|
},
|
||||||
|
"blogPage": {
|
||||||
|
"hero": {
|
||||||
|
"sectionLabel": "Thoughts & articles",
|
||||||
|
"title": "Blog",
|
||||||
|
"subtitle": "Thoughts on architecture, web development, and the practices that make products last."
|
||||||
|
},
|
||||||
|
"backToBlog": "Back to blog",
|
||||||
|
"scrollPrompt": "Scroll",
|
||||||
|
"readArticle": "Read article",
|
||||||
|
"noArticles": "No articles yet."
|
||||||
|
},
|
||||||
"projectPage": {
|
"projectPage": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"backToProjects": "Back to projects",
|
"backToProjects": "Back to projects",
|
||||||
|
|||||||
@@ -59,6 +59,17 @@
|
|||||||
},
|
},
|
||||||
"copyright": "© 2026 M. Guilbert. Tous droits réservés."
|
"copyright": "© 2026 M. Guilbert. Tous droits réservés."
|
||||||
},
|
},
|
||||||
|
"blogPage": {
|
||||||
|
"hero": {
|
||||||
|
"sectionLabel": "Réflexions & articles",
|
||||||
|
"title": "Blog",
|
||||||
|
"subtitle": "Pensées sur l'architecture, le développement web et les pratiques qui font durer les produits."
|
||||||
|
},
|
||||||
|
"backToBlog": "Retour au blog",
|
||||||
|
"scrollPrompt": "Défiler",
|
||||||
|
"readArticle": "Lire l'article",
|
||||||
|
"noArticles": "Aucun article pour le moment."
|
||||||
|
},
|
||||||
"projectPage": {
|
"projectPage": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"backToProjects": "Retour aux projets",
|
"backToProjects": "Retour aux projets",
|
||||||
|
|||||||
Reference in New Issue
Block a user