Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@ jobs:
- name: Run tests
run: npm run test -- --exclude tests/setup/base.test.ts
env:
APP_BASE_URL: dummy_url_for_testing
APP_BASE_URL: http://dummy_url_for_testing
APP_SECRET: dummy_secret_for_testing
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
coverage
data
dist
node_modules
src/database/data
src/mailpit/data
.DS_Store
.env
*.http
47 changes: 34 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

- **Backend**: Node.js + Express 5, TypeScript, Zod (validation), `node:sqlite` (sync API)
- **Frontend**: React 19, React Router (with SSR/hydration), Vite, Pico CSS
- **Database**: SQLite — zero-config, synchronous, file at `src/database/data/database.sqlite`
- **Database**: SQLite — zero-config, synchronous, file at `data/sqlite/database.sqlite`
- **Tooling**: Biome (lint + format), Vitest (tests), tsx (runtime), Docker (optional)

---
Expand All @@ -20,14 +20,16 @@
.
├── server.ts # Single entry point — bridges Express + Vite
├── index.html # Vite root — contains <!--ssr-outlet-->
├── data/
│ ├── mailpit/ # Mailpit persistence (Docker)
│ └── sqlite/
│ └── database.sqlite # Generated locally — NOT committed to git
├── src/
│ ├── entry-client.tsx # Client-side hydration (hydrateRoot)
│ ├── entry-server.tsx # SSR rendering (renderToPipeableStream)
│ ├── database/
│ │ ├── schema.sql # SQLite schema — source of truth for DB structure
│ │ ├── seeder.sql # Test/seed data
│ │ └── data/
│ │ └── database.sqlite # Generated locally — NOT committed to git
│ │ └── seeder.sql # Test/seed data
│ ├── express/
│ │ ├── routes.ts # Registers all Express modules via importAndUse()
│ │ ├── helpers/ # Infrastructure: cache, validation, converters
Expand All @@ -45,7 +47,7 @@
│ └── types/
│ └── index.d.ts # Shared TypeScript types (Item, User, etc.)
├── tests/
│ └── contracts.ts # API contract definitions — declarative source of truth
│ └── contracts # API contract definitions — declarative source of truth
└── biome.json # Lint + format config
```

Expand Down Expand Up @@ -169,31 +171,50 @@ const browse: RequestHandler = (req, res) => {
};
```

### Explicit runtime casting — never use `as Type`
### Zod Output Parsing — never use `as Type`

SQLite returns raw SQL primitives (`string | number | bigint | null`). Always reconstruct objects with explicit primitive converters:
SQLite returns raw SQL primitives (`string | number | bigint | null`). Always reconstruct objects by parsing them through a Zod schema bound to your TypeScript type (`z.ZodType<Item>`).

> **⚠️ CRITICAL**: Do NOT confuse the **Output Schema** (in `*Repository.ts`) with the **Input Schema** (in `*Validator.ts`). The Repository output schema only casts raw primitives to match the TypeScript type. It does NOT enforce business constraints (like `.email()` or `.min()`).

```ts
// In your Repository file
import { z } from "zod";

const itemSchema: z.ZodType<Item> = z.object({
id: z.number(),
title: z.string(),
user_id: z.number()
});

// ✅ Correct
return { id: Number(id), title: String(title), user_id: Number(user_id) };
return itemSchema.parse(row);

// ❌ Wrong — hides runtime errors
return row as Item;
```

### Synchronous SQLite — no async/await in repositories
### Synchronous SQLite — no async/await for database calls

`node:sqlite` is synchronous by design. Repositories must not use `async`/`await`. Actions can remain `async` if they need to interact with other async concerns (e.g., `req.body`, external calls).
`node:sqlite` is synchronous by design. Repository methods must execute SQL queries synchronously and must not wrap database calls in `async`/`await`.

However, a repository method *may* be marked `async` if it strictly requires interaction with genuinely asynchronous external resources (e.g., third-party APIs, asynchronous file system reads). Actions (in `*Actions.ts`) generally remain `async` to handle `req.body` and orchestrate these calls.

```ts
// ✅ Correct repository method
// ✅ Correct SQLite query
find(byId: number): Item | null {
const query = database.prepare("select id, title from item where id = ?");
const row = query.get(byId);
// ...
}

// ❌ Wrong
// ✅ Correct (Mixed resource query)
async fetchAndSave(id: number): Promise<Item> {
const data = await fetch(`https://api.example.com/data/${id}`);
database.prepare("insert into item (title) values (?)").run(data.title);
}

// ❌ Wrong (Wrapping SQLite in async for no reason)
async find(byId: number): Promise<Item | null> { ... }
```

Expand Down Expand Up @@ -262,7 +283,7 @@ StartER has no file upload handling. If adding one, store files outside the docu

### Environment variables

Never commit `.env`. Never commit `src/database/data/database.sqlite`. Both are in `.gitignore`. Generate `APP_SECRET` with `openssl rand -hex 32`.
Never commit `.env`. Never commit `data/sqlite/database.sqlite`. Both are in `.gitignore`. Generate `APP_SECRET` with `openssl rand -hex 32`.

---

Expand Down
22 changes: 11 additions & 11 deletions README.fr-FR.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,6 @@

</div>

## 🧠 Starter, le framework idéal pour l'IA

La plupart des frameworks sont trop complexes pour l'IA. Ils dissimulent la logique derrière des abstractions complexes et opaques, ce qui peut entraîner des dysfonctionnements et des erreurs de conception chez les agents.

**Nous avons conçu StartER pour nous démarquer.** Il s'agit d'une plateforme "sans magie" conçue pour la **co-création humain-IA**. En conservant un code lisible et explicite, nous fournissons aux agents IA un modèle mental optimal. StartER devient ainsi le terrain de jeu idéal pour le prototypage et l'apprentissage rapides.

![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png)

## 📚 Exemple de structure de projet Express + React simple et lisible

Ce projet présente une méthode simple et lisible pour structurer une application fullstack avec :
Expand All @@ -38,7 +30,15 @@ Ce projet présente une méthode simple et lisible pour structurer une applicati
* Frontend React
* Contrats partagés pour l'API

Si vous recherchez un "starter Express + React" ou un "boilerplate Node React", ce dépôt est un exemple pratique.
Si vous recherchez un "starter Express + React" ou un "boilerplate Node React", ce dépôt est un template pratique.

## 🧠 Starter, le framework idéal pour l'IA

La plupart des frameworks sont trop complexes pour l'IA. Ils dissimulent la logique derrière des abstractions complexes et opaques, ce qui peut entraîner des dysfonctionnements et des erreurs de conception chez les agents.

**Nous avons conçu StartER pour nous démarquer.** Il s'agit d'une plateforme "sans magie" conçue pour la **co-création humain-IA**. En conservant un code lisible et explicite, nous fournissons aux agents IA un modèle mental optimal. StartER devient ainsi le terrain de jeu idéal pour le prototypage et l'apprentissage rapides.

![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png)

## ⚡ Démarrage Rapide

Expand Down Expand Up @@ -70,7 +70,7 @@ Cela garantit la cohérence en clonant vos modèles de code *réels*. Votre agen

### 🧪 Vérification basée sur un contrat

Vous définissez le comportement de l'API dans `tests/contracts.ts` : une source de vérité centrale et déclarative.
Vous définissez le comportement de l'API dans le dossier `tests/contracts/` : une source de vérité centrale et déclarative.

* **Pour vous :** une documentation claire et évolutive.

Expand All @@ -82,7 +82,7 @@ Vous définissez le comportement de l'API dans `tests/contracts.ts` : une sourc

* **SQLite synchrone :** accès direct aux données que l'IA peut lire et écrire sans confusion avec `async`/`await`.

* **Conversion explicite :** typage des données aux emplacements clés. Ceci évite les bugs silencieux souvent introduits par l'IA.
* **Validation de sortie avec Zod :** typage des données aux emplacements clés à l'aide de schémas Zod. Ceci évite les bugs silencieux souvent introduits par l'IA.

* **Stack transparente :** Express 5 + React 19. Aucune boîte noire. Vous comprenez chaque ligne.

Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,22 @@

</div>

## 🧠 The AI-era starter

Most frameworks are too complex for AI. They hide logic behind "magic" and deep abstractions. This causes AI agents to hallucinate and break things.

**We built StartER to stand out.** It is a "Zero-Magic" foundation designed for **Human-AI co-creation**. By keeping the code readable and explicit, we provide AI agents with a perfect mental model. This makes it the ultimate playground for rapid prototyping and learning.

![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png)

## 📚 Simple and readable Express + React project structure example

This project shows a simple and readable way to structure a fullstack app with:
- Express backend
- React frontend
- shared contracts for API

If you are looking for a "Express + React starter" or "Node React boilerplate", this repository is a practical example.
If you are looking for a "Express + React starter" or "Node React boilerplate", this repository is a practical template.

## 🧠 The AI-era starter

Most frameworks are too complex for AI. They hide logic behind "magic" and deep abstractions. This causes AI agents to hallucinate and break things.

**We built StartER to stand out.** It is a "Zero-Magic" foundation designed for **Human-AI co-creation**. By keeping the code readable and explicit, we provide AI agents with a perfect mental model. This makes it the ultimate playground for rapid prototyping and learning.

![](https://raw.githubusercontent.com/rocambille/start-express-react/refs/heads/main/src/react/assets/images/architecture.png)

## ⚡ Quick start

Expand Down Expand Up @@ -66,14 +66,14 @@ npm run make:clone -- src/express/modules/item src/express/modules/task item tas
This enforces consistency by cloning your *actual* code patterns. This keeps your AI agent focused and accurate.

### 🧪 Contract-driven verification
You define API behavior in `tests/contracts.ts`: a central, declarative source of truth.
You define API behavior in the `tests/contracts/` directory: a central, declarative source of truth.
* **For you:** clear, living documentation.
* **For AI:** a strict "contract" it must follow when generating endpoints.
* **For the app:** instant verification that the AI didn't miss a scenario.

### 🔍 Zero-magic simplicity
* **Sync SQLite:** direct data access that AI can read and write without `async`/`await` confusion.
* **Explicit casting:** we verify data at the edge. This prevents the silent bugs AI often introduces.
* **Zod Output Parsing:** we verify data at the edge using Zod schemas. This prevents the silent bugs AI often introduces.
* **Transparent stack:** Express 5 + React 19. No black boxes. You understand every line.

## 💻 Tech stack
Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
restart: unless-stopped
env_file: ./.env
volumes:
- ./src/mailpit/data:/data
- ./data/mailpit:/data
ports:
- ${MP_UI_PORT-8025}:8025
- ${MP_SMTP_PORT-1025}:1025
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server",
"database:schema:load": "tsx --env-file=.env bin/database-sync schema",
"database:seeder:load": "tsx --env-file=.env bin/database-sync seeder",
"database:sync": "tsx --env-file=.env bin/database-sync both",
"database:schema:load": "tsx --env-file=.env scripts/database-sync schema",
"database:seeder:load": "tsx --env-file=.env scripts/database-sync seeder",
"database:sync": "tsx --env-file=.env scripts/database-sync both",
"dev": "tsx watch --include .env --env-file=.env server",
"install:check": "vitest run tests/install",
"make:clone": "tsx --env-file=.env bin/make-clone",
"make:purge": "tsx --env-file=.env bin/make-purge",
"make:clone": "tsx --env-file=.env scripts/make-clone",
"make:purge": "tsx --env-file=.env scripts/make-purge",
"start": "tsx --env-file=.env server",
"test": "vitest run --coverage --exclude tests/install",
"types:check": "tsc --noEmit"
Expand Down
File renamed without changes.
File renamed without changes.
8 changes: 4 additions & 4 deletions bin/make-purge.ts → scripts/make-purge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async function purgeAuth(rootDir: string) {
await updateFile(rootDir, "src/react/routes.tsx", (content) =>
content
// Remove auth-related imports
.replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "")
.replace(`import AccountPage from "./components/auth/AccountPage";\n`, "")
.replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "")
.replace(
`import { AuthProvider } from "./components/auth/AuthContext";\n`,
Expand All @@ -151,9 +151,9 @@ async function purgeAuth(rootDir: string) {
)
// Remove the loader
.replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "")
// Remove logout and verify routes
// Remove account and verify routes
.replace(
/ {6}\{\n {8}path: "logout",\n {8}element: <LogoutForm \/>,\n {6}\},\n/m,
/ {6}\{\n {8}path: "account",\n {8}element: <AccountPage \/>,\n {6}\},\n/m,
"",
)
.replace(
Expand Down Expand Up @@ -187,7 +187,7 @@ async function purgeAuth(rootDir: string) {
content
.replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "")
.replace(` const { check } = useAuth();\n`, "")
// After purgeItems, only the logout link remains in the auth block.
// After purgeItems, only the account link remains in the auth block.
// Remove the whole conditional block.
.replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, ""),
);
Expand Down
61 changes: 34 additions & 27 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,24 @@
* - https://vitejs.dev/guide/ssr
*/

import { AsyncLocalStorage } from "node:async_hooks";
import fs from "node:fs";
import http from "node:http";
import express, { type ErrorRequestHandler, type Express } from "express";
import { rateLimit } from "express-rate-limit";
import helmet from "helmet";
import { createServer as createViteServer } from "vite";
/* ************************************************************************ */
/* Startup */
/* ************************************************************************ */

const port = Number(process.env.APP_PORT ?? 5173);

const indexHtml = readIndexHtml();

// Server creation is async because it may initialize Vite in dev mode
createServerWith("./src/express/routes").then((server) => {
server.listen(port, () => {
console.info(`Listening on http://localhost:${port}`);
});
});

/* ************************************************************************ */
/* Server creation */
/* ************************************************************************ */

/**
* Patch globalThis.fetch to support relative URLs during SSR.
Expand All @@ -41,6 +52,8 @@ import { createServer as createViteServer } from "vite";
* - Create a storage that holds the base URL for the current request
* - Patch fetch to resolve relative URLs against this base URL
*/
import { AsyncLocalStorage } from "node:async_hooks";

const fetchBaseStorage = new AsyncLocalStorage<{
base: string;
cookie?: string;
Expand Down Expand Up @@ -73,28 +86,17 @@ globalThis.fetch = (resource, init) => {
return nodeFetch(url, init);
};

/* ************************************************************************ */
/* Startup */
/* ************************************************************************ */
/**
* Express / Vite integration
*/
import http from "node:http";
import express, { type ErrorRequestHandler } from "express";
import { rateLimit } from "express-rate-limit";
import helmet from "helmet";

const isProduction = process.env.NODE_ENV === "production";

const port = +(process.env.APP_PORT ?? 5173);

const indexHtml = readIndexHtml();

// Server creation is async because it may initialize Vite in dev mode
createServer().then((server) => {
server.listen(port, () => {
console.info(`Listening on http://localhost:${port}`);
});
});

/* ************************************************************************ */
/* Server creation */
/* ************************************************************************ */

export async function createServer() {
export async function createServerWith(routesPath: string) {
const app = express();
const httpServer = http.createServer(app);

Expand Down Expand Up @@ -138,7 +140,7 @@ export async function createServer() {

// All API routes are mounted here.
// They are isolated, stateless, and independently testable.
app.use((await import("./src/express/routes")).default);
app.use((await import(routesPath)).default);

/* ********************************************************************** */
/* Frontend / SSR configuration */
Expand Down Expand Up @@ -252,6 +254,8 @@ export async function createServer() {
* - Development: unbuilt index.html
* - Production: generated dist/client/index.html
*/
import fs from "node:fs";

function readIndexHtml() {
return fs.readFileSync(
isProduction ? "dist/client/index.html" : "index.html",
Expand All @@ -270,6 +274,9 @@ function readIndexHtml() {
* - Create a Vite dev server in middleware mode
* - Let Express control routing
*/
import type { Express } from "express";
import { createServer as createViteServer } from "vite";

async function configure(app: Express, httpServer: http.Server) {
if (isProduction) {
const compression = (await import("compression")).default;
Expand Down
5 changes: 4 additions & 1 deletion src/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import fs from "fs-extra";

const dbPath = path.join(import.meta.dirname, "data/database.sqlite");
const dbPath = path.join(
import.meta.dirname,
"../../data/sqlite/database.sqlite",
);

// Ensure the parent directory exists
await fs.ensureDir(path.dirname(dbPath));
Expand Down
Loading
Loading