Comment je démarre un projet client .NET / React en 5 étape

Démarrer un projet client .NET / React correctement, c’est éviter des semaines de dette technique plus tard. Mon objectif au kick-off : avoir en quelques jours un socle clair, sécurisé, observable et déjà branché à une CI/CD.

Voici comment je démarre un projet client en 5 étapes, avec scripts, exemples de code et petits choix d’architecture.


1) Squelette & outils

Avant d’écrire la moindre feature, je pose un squelette simple, compréhensible par toute l’équipe :

MyApp.sln
  /api     // ASP.NET Core 8 Minimal API
  /web     // React + TypeScript (Vite)
  /shared  // (optionnel) contrats/DTO partagés via package

Initialiser l’API (.NET 8)

Je pars généralement sur une Minimal API avec Swagger, validation, et déjà prête pour l’observabilité.

dotnet new web -n api
cd api

dotnet add package Swashbuckle.AspNetCore
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package Serilog.AspNetCore

Dans Program.cs, je pose tout de suite un endpoint de santé et une route de test :

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.MapGet("/api/info", () => new
{
    Name = "MyApp",
    Version = "0.0.1",
    Utc = DateTime.UtcNow
});

app.Run();

Initialiser le front (React + Vite + TS)

Côté front, j’utilise Vite pour avoir un setup léger et rapide, avec React Router et React Query.

npm create vite@latest web -- --template react-ts
cd web

npm i @tanstack/react-query react-router-dom zod
npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

Je crée ensuite une structure minimale :

web/src
  /components
  /pages
  /routes
  /api
  main.tsx

Le tout premier écran se contente souvent d’appeler l’endpoint /api/info pour vérifier que le pipe front <-> back fonctionne bien.

// web/src/api/http.ts
export async function get<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, {
    ...init,
    headers: {
      "Accept": "application/json",
      ...(init?.headers || {})
    }
  });

  if (!res.ok) {
    throw new Error(res.statusText);
  }

  return res.json() as Promise<T>;
}
// web/src/pages/Home.tsx
import { useEffect, useState } from "react";
import { get } from "../api/http";

type Info = {
  name: string;
  version: string;
  utc: string;
};

export function Home() {
  const [info, setInfo] = useState<Info | null>(null);

  useEffect(() => {
    get<Info>("/api/info").then(setInfo).catch(console.error);
  }, []);

  if (!info) return <p>Chargement…</p>;

  return (
    <div>
      <h1>{info.name}</h1>
      <p>Version: {info.version}</p>
      <p>Serveur UTC: {new Date(info.utc).toLocaleString()}</p>
    </div>
  );
}

2) Sécurité de base (CORS, rate-limit, headers)

Même en tout début de projet, je pose un minimum de sécurité et d’hygiène HTTP : CORS strict, limite de requêtes, headers de protection.

CORS & rate limiting

Je branche une policy CORS pour le front et un rate limit global simple :

// Program.cs
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

builder.Services.AddCors(o => o.AddPolicy("spa", p =>
  p.WithOrigins("http://localhost:5173")
   .AllowAnyHeader()
   .AllowAnyMethod()
   .AllowCredentials()));

builder.Services.AddRateLimiter(o =>
{
    o.GlobalLimiter = PartitionedRateLimiter.CreateHttpContextLimiter(_ =>
        RateLimitPartition.GetFixedWindowLimiter("global", _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 120,
            Window = TimeSpan.FromMinutes(1),
            QueueLimit = 0
        }));

    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
var app = builder.Build();

app.UseCors("spa");
app.UseRateLimiter();

Headers de protection

J’ajoute aussi quelques headers de sécurité de base (à ajuster selon les besoins) :

app.Use(async (ctx, next) =>
{
    ctx.Response.Headers["X-Frame-Options"] = "DENY";
    ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
    ctx.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
    await next();
});

L’authentification (JWT / OAuth2) arrive en général juste après ce socle, dès qu’une première feature sensible arrive. Mais même avant ça, ces protections évitent déjà beaucoup de mauvaises surprises.


3) Observabilité : logs, traces, métriques

Je veux pouvoir répondre à la question : « que s’est-il passé pour ce client à 10h32 ? ». Pour ça, je branche immédiatement OpenTelemetry et des logs corrélés.

OpenTelemetry minimal

using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using OpenTelemetry.Resources;

builder.Services.AddOpenTelemetry()
  .ConfigureResource(r => r.AddService("MyApp.Api"))
  .WithTracing(t => t
      .AddAspNetCoreInstrumentation()
      .AddHttpClientInstrumentation()
      .AddSqlClientInstrumentation())
  .WithMetrics(m => m
      .AddAspNetCoreInstrumentation()
      .AddRuntimeInstrumentation());

En environnement cloud, je configure un export OTLP vers Application Insights, Grafana, Datadog… En local, je reste parfois sur un simple logging console + dotnet-counters pour les premiers tests.

Logs corrélés par requête

app.MapGet("/api/orders/{id:int}", (int id, ILogger<Program> logger) =>
{
    logger.LogInformation("Fetching order {OrderId}", id);

    // ... appel BDD / service ...
    var order = new { Id = id, Total = 120.50m };

    logger.LogInformation("Order {OrderId} found with total {Total}", id, order.Total);
    return Results.Ok(order);
});

Avec ça, dès le premier bug remonté par le métier, j’ai déjà de quoi investiguer sans « deviner » ce qui se passe.


4) Front prêt prod : routing, fetch, cache & UX

Côté front, l’objectif est d’avoir rapidement une base propre : routing clair, gestion de l’état côté serveur (React Query), et un minimum de UX (loading, erreurs).

Routing de base

// web/src/routes/AppRoutes.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Home } from "../pages/Home";
import { NotFound } from "../pages/NotFound";

export function AppRoutes() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

React Query pour les appels API

// web/src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppRoutes } from "./routes/AppRoutes";

const client = new QueryClient();

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={client}>
      <AppRoutes />
    </QueryClientProvider>
  </React.StrictMode>
);
// web/src/api/hooks.ts
import { useQuery } from "@tanstack/react-query";
import { get } from "./http";

type Info = {
  name: string;
  version: string;
  utc: string;
};

export function useAppInfo() {
  return useQuery({
    queryKey: ["info"],
    queryFn: () => get<Info>("/api/info"),
    staleTime: 60_000
  });
}
// web/src/pages/Home.tsx (version React Query)
import { useAppInfo } from "../api/hooks";

export function Home() {
  const { data, isLoading, error } = useAppInfo();

  if (isLoading) return <p>Chargement…</p>;
  if (error) return <p>Une erreur est survenue.</p>;

  return (
    <div>
      <h1>{data?.name}</h1>
      <p>Version: {data?.version}</p>
    </div>
  );
}

Avec ça, le front est déjà structuré comme en prod, et il sera très facile d’ajouter de nouvelles pages / features.


5) CI/CD minimale (GitHub Actions)

Dès le début du projet, je branche une CI simple : build + tests API, build front. C’est ce qui évite les « ça marche sur ma machine ».

# .github/workflows/ci.yml
name: ci

on:
  push:
    branches: [ main, develop ]
  pull_request:

jobs:
  api:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "8.0.x"

      - name: Restore
        run: dotnet restore api

      - name: Build
        run: dotnet build api -c Release --no-restore

      - name: Tests
        run: dotnet test api -c Release --no-build

  web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install & Build
        run: |
          cd web
          npm ci
          npm run build

Plus tard, je rajoute les jobs de déploiement (Azure Web App, containers, static hosting pour le front…), mais cette première CI suffit à garder le projet sain dès le début.


Bilan : ce qu’on a après quelques jours

  • Un squelette .NET / React propre, versionné et reproductible.
  • Une API déjà protégée (CORS, rate-limit, headers) et observable.
  • Un front React organisé (routing, React Query, helpers API).
  • Une CI GitHub Actions qui valide le build à chaque push.

À partir de là, je peux attaquer les vraies features métier sereinement : le socle technique est en place, et chaque nouvelle brique viendra s’y brancher proprement, sans bricolage.

← Retour au blog