Skip to Content
Quick Start

Quick Start

Get up and running with Gluonic in 5 minutes. Build a simple task app with offline support and real-time sync.

What You’ll Build

A task list app with:

  • âś… Synchronous data access (no loading states!)
  • âś… Offline support (works without network)
  • âś… Real-time sync (instant updates)
  • âś… Optimistic UI (instant feedback)

Prerequisites

  • Node.js 18+
  • PostgreSQL (or any supported database)
  • Basic knowledge of React and TypeScript

Step 1: Install Gluonic

# Server packages pnpm add @gluonic/server @gluonic/server-prisma @gluonic/auth-jwt # Client packages pnpm add @gluonic/client @gluonic/client-drizzle

Step 2: Server Setup

Create a sync server:

server.ts
import { SyncServer } from '@gluonic/server' import { PrismaAdapter } from '@gluonic/server-prisma' import { JWTAuth } from '@gluonic/auth-jwt' import { prisma } from './db' // Create database adapter (auto-discovers models from Prisma schema) const database = PrismaAdapter({ prisma }) const server = SyncServer({ database, auth: JWTAuth({ secret: process.env.JWT_SECRET }) }) await server.listen({ port: 3000 })

Note: Gluonic works with any database through adapters. See Server Setup for other database options.

Step 3: Define Models

models.ts
import { Model, ClientModel, Property, ManyToOne, Collection } from '@gluonic/client' @ClientModel('user') export class User extends Model { @Property() id = crypto.randomUUID() @Property() name!: string @Property() email!: string } @ClientModel('task') export class Task extends Model { @Property() id = crypto.randomUUID() @Property() title!: string @Property() done = false @Property() assigneeId?: string @ManyToOne('user', 'assigneeId') assignee?: User }

Notice:

  • IDs use initializers (auto-generated, type inferred)
  • Required fields use ! (name, email, title)
  • Optional fields use ? (assigneeId)
  • Clean relationship types (User, not LazyReference)

Step 4: Client Setup

Create a sync client in your React app:

sync.ts
import { SyncClient } from '@gluonic/client' import { DrizzleAdapter } from '@gluonic/client-drizzle' import { db } from './db' import { Task, User } from './models' // Create storage adapter (local database for offline support) const storage = DrizzleAdapter({ db }) export const client = SyncClient({ server: 'http://localhost:3000/sync/v1', storage, models: [Task, User] })

Note: Drizzle adapter is currently the supported client storage adapter. See Client Setup for configuration details.

Step 5: Wrap Your App

App.tsx
import { SyncProvider } from '@gluonic/client' import { client } from './sync' import { useAuth } from './auth' function App() { const { token } = useAuth() useEffect(() => { client.store.init() }, []) return ( <SyncProvider client={client} token={token}> <TaskList /> </SyncProvider> ) }

Note: When token prop changes, SyncProvider automatically re-syncs. When token is null, sync is paused.

Step 6: Use in Components

OOP Creation Style (v1.1+)

Create and manipulate models as instances:

TaskList.tsx
import { observer, useCollectionModels } from '@gluonic/client' import { Task } from './models' const TaskList = observer(() => { const tasks = useCollectionModels<Task>('task') const addTask = async () => { const task = new Task({ title: 'New task' }) await task.save() // ID auto-generated } const toggleTask = async (task: Task) => { task.done = !task.done await task.save() // updatedAt auto-updated } return ( <div> <button onClick={addTask}>Add Task</button> {tasks.map(task => ( <div key={task.id}> <input type='checkbox' checked={task.done} onChange={() => toggleTask(task)} /> <span>{task.title}</span> <span className='text-sm'> {task.assignee?.value?.name ?? 'Unassigned'} </span> </div> ))} </div> ) })

v1.1+ Feature: OOP pattern shown above.

Current: Use functional style below.

Functional Creation Style (Current)

Create through store methods:

TaskList.tsx
import { observer, useCollectionModels, useStore } from '@gluonic/client' import { Task } from './models' const TaskList = observer(() => { const tasks = useCollectionModels<Task>('task') const store = useStore() const addTask = async () => { const id = crypto.randomUUID() await store.create('task', id, { title: 'New task', done: false }) } const toggleTask = async (task: Task) => { await store.save('task', task.id, { done: !task.done }) } return ( <div> <button onClick={addTask}>Add Task</button> {tasks.map(task => ( <div key={task.id}> <input type='checkbox' checked={task.done} onChange={() => toggleTask(task)} /> <span>{task.title}</span> <span className='text-sm'> {task.assignee?.value?.name ?? 'Unassigned'} </span> </div> ))} </div> ) })

Data Access Patterns

Gluonic provides two patterns for handling loading states:

Handle loading inline - data appears as it loads:

TaskList.tsx
import { observer, useCollectionModels, useStore } from '@gluonic/client' import { Task } from './models' const TaskList = observer(() => { const tasks = useCollectionModels<Task>('task') const store = useStore() const addTask = async () => { const id = crypto.randomUUID() await store.create('task', id, { title: 'New task', done: false }) } const toggleTask = async (task: Task) => { await store.save('task', task.id, { done: !task.done }) } return ( <div> <button onClick={addTask}>Add Task</button> {tasks.length === 0 ? ( <div className='opacity-50'>No tasks yet</div> ) : ( tasks.map(task => ( <div key={task.id}> <input type='checkbox' checked={task.done} onChange={() => toggleTask(task)} /> <span>{task.title}</span> {/* Optional chaining - assignee may be undefined initially */} <span className='text-sm'> {task.assignee.value?.name ?? 'Unassigned'} </span> </div> )) )} </div> ) })

How it works:

  • First render: tasks is [], assignees are undefined
  • Background: Data loads from storage/network
  • MobX: Re-renders when data arrives
  • Second render: Shows loaded data

Pattern 2: Guaranteed Data with Suspense

Use Suspense for cleaner code when you need guaranteed data:

TaskList.tsx
import { Suspense } from 'react' import { observer, useCollectionModels } from '@gluonic/client' import { Task } from './models' export default function TaskListWrapper() { return ( <Suspense fallback={<div>Loading tasks...</div>}> <TaskList /> </Suspense> ) } const TaskList = observer(() => { const tasks = useCollectionModels<Task>('task') const store = useStore() const addTask = async () => { const id = crypto.randomUUID() await store.create('task', id, { title: 'New task', done: false }) } const toggleTask = async (task: Task) => { await store.save('task', task.id, { done: !task.done }) } return ( <div> <button onClick={addTask}>Add Task</button> {tasks.map(task => ( <div key={task.id}> <input type='checkbox' checked={task.done} onChange={() => toggleTask(task)} /> <span>{task.title}</span> {/* No optional chaining - guaranteed loaded with .suspense */} <span className='text-sm'> {task.assignee.suspense.name} </span> </div> ))} </div> ) })

How it works:

  • Suspense boundary shows spinner while loading
  • Component only renders when all data ready
  • Inside: Use .suspense accessors for guaranteed data
  • No optional chaining needed

🎉 That’s It!

You now have a working sync app! Notice:

  • âś… No useState for tasks
  • âś… No useEffect to load data
  • âś… No manual loading states
  • âś… No async/await in render logic
  • âś… Works offline automatically
  • âś… Real-time updates when online

Next Steps

Choose your learning path:

Learn the Concepts

Configure for Production

See Examples

Last updated on