La plupart des applications web traitent encore le réseau comme une dépendance impérative : on clique sur un bouton, on attend un aller-retour, on espère que le serveur répond. Le logiciel local-first inverse ce modèle. Vos données vivent d'abord dans le navigateur, les lectures et écritures se résolvent instantanément face à un cache local, et un processus d'arrière-plan synchronise tout avec le serveur et avec les autres clients en temps réel. Quand le réseau tombe, l'application continue de fonctionner. Quand il revient, les changements se réconcilient automatiquement.
Triplit est une base de données open source full-stack conçue spécifiquement pour ce modèle. Elle vous offre un schéma typé, un moteur de requêtes relationnelles qui s'exécute dans le navigateur, des écritures optimistes, une synchronisation temps réel et des permissions d'accès fines — le tout depuis un seul paquet TypeScript. Dans ce tutoriel, vous construirez un tableau de tâches d'équipe collaboratif avec Next.js et Triplit, et vous verrez les changements se propager en direct entre les onglets du navigateur, même hors ligne.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Une connaissance pratique de React et du App Router de Next.js
- Une familiarité avec TypeScript (tout le schéma est typé)
- Un éditeur de code (VS Code recommandé)
Aucune expérience back-end n'est requise. Triplit embarque son propre serveur de synchronisation, que vous lancerez localement avec une seule commande.
Ce que vous allez construire
Un tableau de tâches temps réel où :
- Les tâches appartiennent à des projets (un lien relationnel
RelationMany) - La création, l'édition, l'achèvement et la suppression se font de façon optimiste — l'interface se met à jour avant la confirmation du serveur
- Les changements se synchronisent en direct entre les onglets ouverts et survivent à une session hors ligne complète
- Une couche de permissions garantit que les utilisateurs ne modifient que leurs propres tâches
À la fin, vous comprendrez les quatre piliers de Triplit : schéma, client, requêtes et permissions.
Étape 1 : configuration du projet
Partez d'une nouvelle application Next.js avec l'App Router et TypeScript :
npx create-next-app@latest triplit-board \
--typescript --app --tailwind --eslint --src-dir=false
cd triplit-boardInstallez les paquets Triplit. @triplit/client est le moteur central, @triplit/react fournit les hooks, et @triplit/cli lance le serveur de développement local et gère votre schéma :
npm install @triplit/client @triplit/react
npm install --save-dev @triplit/cliInitialisez l'ossature du projet Triplit. Cela crée un dossier triplit/ contenant un fichier schema.ts :
npx triplit initÉtape 2 : concevoir le schéma
Un schéma Triplit est du TypeScript pur. Vous déclarez les collections, leurs champs et les relations entre elles à l'aide de l'assistant Schema (importé par convention sous le nom S). Ouvrez triplit/schema.ts et remplacez son contenu :
// triplit/schema.ts
import { Schema as S } from '@triplit/client';
export const schema = S.Collections({
projects: {
schema: S.Schema({
id: S.Id(),
name: S.String(),
color: S.String({ default: 'indigo' }),
ownerId: S.String(),
createdAt: S.Date({ default: S.Default.now() }),
}),
relationships: {
// Un projet a plusieurs tâches dont le projectId pointe vers lui
tasks: S.RelationMany('tasks', {
where: [['projectId', '=', '$id']],
}),
},
},
tasks: {
schema: S.Schema({
id: S.Id(),
title: S.String(),
completed: S.Boolean({ default: false }),
priority: S.String({ default: 'medium' }), // low | medium | high
projectId: S.String(),
authorId: S.String(),
tags: S.Set(S.String()),
createdAt: S.Date({ default: S.Default.now() }),
}),
relationships: {
// L'unique projet auquel cette tâche appartient
project: S.RelationById('projects', '$projectId'),
},
},
});Quelques points à noter :
S.Id()génère automatiquement un identifiant texte résistant aux collisions lorsqu'il est omis à l'insertion.S.Set(S.String())est un type ensemble (set) de première classe — parfait pour les étiquettes, et il fusionne proprement entre éditions concurrentes.S.Date({ default: S.Default.now() })horodate la création sur une horloge indépendante du serveur.S.RelationManyetS.RelationByIddéclarent des relations que vous pourrez ensuite inclure avec.Include()dans une requête : le moteur effectue la jointure côté client.
Les jetons $id et $projectId sont des variables de requête : ils renvoient aux champs de l'entité courante lors de la résolution de la relation.
Étape 3 : lancer le serveur de synchronisation local
Le serveur de développement de Triplit réunit le moteur de synchronisation et une console d'administration. Lancez-le :
npx triplit devCela affiche deux valeurs importantes : une serverUrl (par défaut http://localhost:6543) et un jeton de service de développement (un JWT). Copiez-les dans .env.local. Comme le client s'exécute dans le navigateur, les variables doivent être préfixées par NEXT_PUBLIC_ :
# .env.local
NEXT_PUBLIC_TRIPLIT_SERVER_URL=http://localhost:6543
NEXT_PUBLIC_TRIPLIT_TOKEN=eyJhbGciOi... # le jeton de dev issu de `triplit dev`Laissez triplit dev tourner dans son propre terminal. Il recharge votre schéma à chaud à mesure que vous l'éditez.
Étape 4 : créer le client
Le TriplitClient est le cœur de votre application. Il possède le cache local, gère la connexion de synchronisation websocket et expose l'API de requêtes et de mutations. Créez une unique instance partagée :
// triplit/client.ts
import { TriplitClient } from '@triplit/client';
import { schema } from './schema';
export const triplit = new TriplitClient({
schema,
serverUrl: process.env.NEXT_PUBLIC_TRIPLIT_SERVER_URL,
token: process.env.NEXT_PUBLIC_TRIPLIT_TOKEN,
storage: 'indexeddb', // persiste le cache pour survivre aux rechargements et au hors ligne
autoConnect: true,
});Définir storage: 'indexeddb' est ce qui rend l'application véritablement local-first : le cache vit dans l'IndexedDB du navigateur, donc un rechargement de page — ou une session hors ligne complète — ne perd jamais de données. Le stockage 'memory' par défaut convient aux tests mais s'évapore au rafraîchissement.
Important : créez le client exactement une fois et importez la même instance partout. L'instancier dans un composant React engendrerait un nouveau cache et une nouvelle connexion de synchronisation à chaque rendu.
Étape 5 : afficher des données en direct avec useQuery
Voici la partie amusante. Le hook useQuery abonne un composant à une requête. Lorsque des données correspondantes changent — localement ou depuis un pair distant — le composant se re-rend automatiquement. Pas de refetch manuel, pas d'invalidation de cache.
Créez un composant client pour la liste des tâches :
// app/components/TaskList.tsx
'use client';
import { triplit } from '@/triplit/client';
import { useQuery } from '@triplit/react';
export function TaskList({ projectId }: { projectId: string }) {
// Construire une requête : tâches de ce projet, les plus récentes d'abord
const tasksQuery = triplit
.query('tasks')
.Where('projectId', '=', projectId)
.Order('createdAt', 'DESC');
const { results: tasks, fetching, error } = useQuery(triplit, tasksQuery);
if (fetching) return <p className="text-slate-400">Chargement des tâches...</p>;
if (error) return <p className="text-red-500">Erreur : {error.message}</p>;
return (
<ul className="space-y-2">
{tasks?.map((task) => (
<li key={task.id} className="flex items-center gap-3 rounded-lg border p-3">
<input
type="checkbox"
checked={task.completed}
onChange={() =>
triplit.update('tasks', task.id, (t) => {
t.completed = !t.completed;
})
}
/>
<span className={task.completed ? 'line-through text-slate-400' : ''}>
{task.title}
</span>
<span className="ml-auto text-xs uppercase text-slate-500">
{task.priority}
</span>
</li>
))}
</ul>
);
}Le constructeur de requêtes est chaînable et paresseux — il décrit ce que vous voulez mais ne fait rien tant qu'il n'est pas remis à un hook, à fetch ou à subscribe. Les opérateurs de filtre disponibles incluent =, !=, <, >, like, in et has (pour les ensembles).
Étape 6 : insérer et mettre à jour de façon optimiste
Les écritures dans Triplit sont optimistes par défaut. Lorsque vous appelez insert ou update, le changement est appliqué immédiatement au cache local et l'interface le reflète dans la même image ; l'écriture est ensuite mise en file d'attente et synchronisée avec le serveur en arrière-plan. Si le serveur la rejette, Triplit annule l'état local.
Ajoutez un formulaire pour créer des tâches :
// app/components/NewTaskForm.tsx
'use client';
import { useState } from 'react';
import { triplit } from '@/triplit/client';
export function NewTaskForm({
projectId,
authorId,
}: {
projectId: string;
authorId: string;
}) {
const [title, setTitle] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
// Appliqué instantanément au cache local, synchronisé en arrière-plan
await triplit.insert('tasks', {
title: title.trim(),
projectId,
authorId,
priority: 'medium',
tags: new Set<string>(),
});
setTitle('');
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
className="flex-1 rounded-lg border px-3 py-2"
placeholder="Que faut-il faire ?"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type="submit"
disabled={!title.trim()}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
>
Ajouter
</button>
</form>
);
}Remarquez que vous ne spécifiez jamais d'id — S.Id() en génère un. Vous n'attendez pas non plus d'aller-retour réseau avant de vider le champ : l'insertion se résout face au cache local, donc la nouvelle tâche apparaît instantanément.
Pour des changements en plusieurs étapes qui doivent réussir ou échouer ensemble, enveloppez-les dans transact. Le bloc entier est atomique — si une opération lève une erreur, toute la transaction est annulée :
// Déplacer chaque tâche d'un projet à un autre, de façon atomique
await triplit.transact(async (tx) => {
const stale = await tx.fetch(
triplit.query('tasks').Where('projectId', '=', 'archived')
);
for (const task of stale) {
await tx.update('tasks', task.id, (t) => {
t.projectId = 'inbox';
});
}
});Étape 7 : joindre les données liées avec Include
Comme les relations sont déclarées dans le schéma, vous pouvez tirer les entités liées dans une seule requête avec .Include(). Le moteur résout la jointure dans le cache local — pas de requête supplémentaire, pas de cascade.
// app/components/ProjectBoard.tsx
'use client';
import { triplit } from '@/triplit/client';
import { useQuery } from '@triplit/react';
export function ProjectBoard() {
// Récupérer chaque projet ET ses tâches dans une seule requête réactive
const query = triplit
.query('projects')
.Order('createdAt', 'ASC')
.Include('tasks');
const { results: projects } = useQuery(triplit, query);
return (
<div className="grid gap-6 md:grid-cols-2">
{projects?.map((project) => (
<section key={project.id} className="rounded-xl border p-4">
<h2 className="mb-2 font-semibold">{project.name}</h2>
<p className="text-sm text-slate-500">
{project.tasks?.length ?? 0} tâches
</p>
</section>
))}
</div>
);
}project.tasks est entièrement typé grâce au schéma, donc votre éditeur complète automatiquement task.title, task.completed et le reste.
Étape 8 : refléter l'état de la connexion
Une application local-first devrait indiquer à l'utilisateur si elle synchronise ou fonctionne hors ligne. Le hook useConnectionStatus vous donne une valeur réactive que vous pouvez afficher directement :
// app/components/ConnectionBadge.tsx
'use client';
import { triplit } from '@/triplit/client';
import { useConnectionStatus } from '@triplit/react';
export function ConnectionBadge() {
const status = useConnectionStatus(triplit);
const online = status === 'OPEN';
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
online ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
}`}
>
<span
className={`h-2 w-2 rounded-full ${online ? 'bg-emerald-500' : 'bg-amber-500'}`}
/>
{online ? 'Synchronisé' : 'Hors ligne — modifications enregistrées localement'}
</span>
);
}Hors ligne, chaque mutation atterrit quand même dans IndexedDB et se met en file d'attente pour la synchronisation. Reconnectez-vous, et Triplit vide la file et fusionne les changements distants — aucun code de résolution de conflits de votre côté.
Étape 9 : verrouiller avec les permissions
Jusqu'ici, tout client muni du jeton de dev peut tout lire et écrire. En production, vous attachez des permissions à chaque collection. Ce sont des clauses de filtre évaluées face aux revendications (claims) du JWT de l'utilisateur authentifié, afin que le serveur applique les règles d'accès et ne fasse jamais aveuglément confiance au client.
Ajoutez un bloc permissions à la collection tasks. La variable $token.sub est la revendication de sujet (l'identifiant utilisateur) du JWT émis par votre fournisseur d'authentification :
// triplit/schema.ts (collection tasks, avec permissions)
tasks: {
schema: S.Schema({
id: S.Id(),
title: S.String(),
completed: S.Boolean({ default: false }),
priority: S.String({ default: 'medium' }),
projectId: S.String(),
authorId: S.String(),
tags: S.Set(S.String()),
createdAt: S.Date({ default: S.Default.now() }),
}),
relationships: {
project: S.RelationById('projects', '$projectId'),
},
permissions: {
authenticated: {
// Tout utilisateur connecté peut lire les tâches
read: { filter: [true] },
// Vous ne pouvez créer que des tâches dont vous êtes l'auteur
insert: { filter: [['authorId', '=', '$token.sub']] },
// Vous ne pouvez modifier ou supprimer que vos propres tâches
update: { filter: [['authorId', '=', '$token.sub']] },
delete: { filter: [['authorId', '=', '$token.sub']] },
},
},
},En production, vous remplacez le jeton de dev par un vrai JWT frappé par votre fournisseur d'authentification (Clerk, Supabase Auth, Auth.js ou un signataire personnalisé). Triplit vérifie la signature face à une clé publique configurée et expose ses revendications sous $token.* dans les filtres de permission. Les permissions de lecture sont elles aussi appliquées en tant que requêtes — un client ne peut littéralement pas s'abonner à des données qu'il n'a pas le droit de voir.
Étape 10 : tout assembler
Assemblez une page. Comme les hooks Triplit s'exécutent dans le navigateur, chaque consommateur est un composant client, mais la coquille de la page elle-même peut rester un composant serveur :
// app/page.tsx
import { ProjectBoard } from './components/ProjectBoard';
import { ConnectionBadge } from './components/ConnectionBadge';
export default function HomePage() {
return (
<main className="mx-auto max-w-3xl p-8">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Tableau d'équipe</h1>
<ConnectionBadge />
</header>
<ProjectBoard />
</main>
);
}Semez un projet une seule fois depuis la console du navigateur ou un script d'initialisation pour que le tableau ait quelque chose à afficher :
await triplit.insert('projects', {
name: 'Semaine de lancement',
color: 'indigo',
ownerId: 'user-1',
});Tester votre implémentation
Vérifiez le comportement local-first — c'est la partie qu'une application REST traditionnelle ne peut pas offrir :
- Synchronisation temps réel. Ouvrez l'application dans deux onglets côte à côte. Ajoutez une tâche dans l'un ; elle apparaît dans l'autre en quelques millisecondes, sans rafraîchissement.
- Écritures optimistes. Bridez votre réseau à « Slow 3G » dans les DevTools et ajoutez une tâche. Elle apparaît quand même instantanément — l'interface n'attend jamais le serveur.
- Résilience hors ligne. Ouvrez les DevTools, allez dans l'onglet Network et basculez sur Offline. Ajoutez, achevez et supprimez plusieurs tâches. Le badge passe à « Hors ligne — modifications enregistrées localement ». Repassez en ligne : chaque changement se synchronise automatiquement avec l'autre onglet.
- Persistance. Hors ligne, rechargez la page de force. Vos tâches sont toujours là, servies depuis IndexedDB.
Si les quatre passent, vous avez une application véritablement local-first.
Dépannage
Le client se connecte mais aucune donnée ne se synchronise. Confirmez que triplit dev tourne toujours et que NEXT_PUBLIC_TRIPLIT_SERVER_URL et NEXT_PUBLIC_TRIPLIT_TOKEN correspondent exactement à sa sortie. Un jeton périmé d'une session triplit dev précédente est la cause la plus fréquente.
Les changements de schéma ne prennent pas effet. Le serveur de dev surveille triplit/schema.ts, mais le client met en cache l'ancien schéma dans IndexedDB. Effacez le stockage IndexedDB du site dans les DevTools, ou changez le stockage pour forcer un cache neuf pendant le développement.
Permission denied à l'insertion. La revendication sub de votre JWT doit être égale à l'authorId que vous écrivez. En développement local, le jeton de service de dev contourne les permissions ; l'erreur n'apparaît qu'une fois passé à un vrai jeton utilisateur.
Avertissements de désynchronisation d'hydratation. Les composants Triplit lisent depuis le cache du navigateur, vide pendant le SSR. Marquez 'use client' sur chaque composant appelant un hook Triplit, et conditionnez le premier rendu sur le drapeau fetching.
Étapes suivantes
- Ajoutez la pagination avec
usePaginatedQueryou une liste « charger plus » infinie avecuseInfiniteQuery— les deux requièrent un.Limit()sur la requête. - Intégrez une vraie authentification : consultez nos guides sur Clerk avec Next.js ou Auth.js v5 pour frapper le JWT que Triplit vérifie.
- Déployez le serveur de synchronisation sur Triplit Cloud ou auto-hébergez-le, puis pointez
serverUrlvers le point de terminaison de production. - Comparez les approches : si vous pesez les options local-first, lisez nos tutoriels sur Zero de Rocicorp et ElectricSQL.
Conclusion
Triplit condense une pile qui exige habituellement une base de données, une couche API, un serveur websocket, un cache client et une bibliothèque de synchronisation hors ligne en un seul paquet typé. Vous avez défini un schéma en TypeScript, l'avez interrogé de façon relationnelle depuis le navigateur, écrit de façon optimiste, vu les changements se synchroniser en direct entre les onglets, continué à travailler hors ligne et appliqué le contrôle d'accès avec des filtres de permission déclaratifs.
Le modèle local-first n'est pas qu'une astuce de performance — il change ce que les utilisateurs peuvent faire. Les applications restent réactives sur des réseaux instables, survivent aux zones mortes et paraissent instantanées parce que les lectures et écritures ne quittent jamais l'appareil sur le chemin critique. Avec Triplit, cette capacité tient à un npm install et à un schéma.