Build from Scratch

Set up a full-stack FuryStack monorepo step by step — shared models, a backend service, and a Shades frontend — learning every piece along the way.


1. Workspace setup

Create a new directory and initialize a Yarn 4 monorepo with three workspaces: common, service, and frontend.

mkdir my-furystack-app && cd my-furystack-app
yarn init -p
mkdir common service frontend

Edit the root package.json to configure workspaces, ESM, and a few convenience scripts:

{
  "name": "my-furystack-app",
  "private": true,
  "type": "module",
  "workspaces": {
    "packages": ["common", "service", "frontend"]
  },
  "scripts": {
    "build": "tsc -b common service frontend && yarn workspace frontend build",
    "start:service": "yarn workspace service start",
    "start:frontend": "yarn workspace frontend start"
  },
  "devDependencies": {
    "typescript": "^5.9.0"
  }
}

Create a root tsconfig.json with project references so tsc -b builds everything in the right order:

{
  "compilerOptions": {
    "composite": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "references": [
    { "path": "common" },
    { "path": "service" },
    { "path": "frontend" }
  ],
  "files": []
}

2. Common — shared models & API

The common workspace holds models and the REST API type definition. Both service and frontend depend on it, so API changes are instantly visible on both sides.

common/package.json

{
  "name": "common",
  "private": true,
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "dependencies": {
    "@furystack/rest": "^9.0.0"
  }
}

common/tsconfig.json

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

common/src/index.ts

Define a TodoItem model and a REST API contract:

import type { RestApi } from '@furystack/rest'

export interface TodoItem {
  id: string
  title: string
  completed: boolean
}

export interface MyApi extends RestApi {
  GET: {
    '/todos': { result: TodoItem[] }
    '/todos/:id': { url: { id: string }; result: TodoItem }
  }
  POST: {
    '/todos': {
      body: { title: string }
      result: TodoItem
    }
  }
}

3. Service — the backend

The service is a Node.js process that uses FuryStack to set up an HTTP API driven by the shared MyApi type.

service/package.json

{
  "name": "service",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "yarn node ./dist/index.js"
  },
  "dependencies": {
    "@furystack/core": "^16.0.0",
    "@furystack/inject": "^12.0.0",
    "@furystack/logging": "^8.1.0",
    "@furystack/repository": "^10.1.0",
    "@furystack/rest-service": "^13.0.0",
    "common": "workspace:^"
  }
}

service/tsconfig.json

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "references": [
    { "path": "../common" }
  ]
}

service/src/todo-dataset.ts

Declare the store and the dataset as module-scoped tokens. The runtime model is a class so the framework has a stable identity for the entity type.

import { defineStore, InMemoryStore } from '@furystack/core'
import { defineDataSet } from '@furystack/repository'
import type { TodoItem } from 'common'

class TodoModel implements TodoItem {
  declare id: string
  declare title: string
  declare completed: boolean
}

export const TodoStore = defineStore<TodoItem, 'id'>({
  name: 'my-app/TodoStore',
  model: TodoModel,
  primaryKey: 'id',
  factory: () => new InMemoryStore({ model: TodoModel, primaryKey: 'id' }),
})

export const TodoDataSet = defineDataSet({
  name: 'my-app/TodoDataSet',
  store: TodoStore,
})

service/src/index.ts

Wire everything together in a root injector and start the REST server. The endpoint generators take the DataSetToken directly — no need to repeat the model and primary key.

import { createInjector } from '@furystack/inject'
import { useLogging, VerboseConsoleLogger } from '@furystack/logging'
import {
  createGetCollectionEndpoint,
  createGetEntityEndpoint,
  createPostEndpoint,
  JsonResult,
  useRestService,
} from '@furystack/rest-service'
import type { MyApi } from 'common'
import { TodoDataSet } from './todo-dataset.js'

const injector = createInjector()
useLogging(injector, VerboseConsoleLogger)

let nextId = 1

await useRestService<MyApi>({
  injector,
  port: 3000,
  root: 'api',
  api: {
    GET: {
      '/todos': createGetCollectionEndpoint(TodoDataSet),
      '/todos/:id': createGetEntityEndpoint(TodoDataSet),
    },
    POST: {
      '/todos': async ({ injector: i, getBody }) => {
        const body = await getBody()
        const dataSet = i.get(TodoDataSet)
        const todo = { id: String(nextId++), title: body.title, completed: false }
        await dataSet.add(i, todo)
        return JsonResult(todo)
      },
    },
  },
})

console.log('Service listening on http://localhost:3000')

Each request gets its own scoped injector. Resolving TodoDataSet inside a handler walks back to the root, where the dataset's singleton instance lives, while any per-request services (like HttpUserContext) stay isolated to that request.


4. Frontend — Shades UI

The frontend uses Shades, FuryStack’s JSX-based UI library, bundled with Vite.

frontend/package.json

{
  "name": "frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc -b && yarn vite build",
    "start": "yarn vite --port 8080"
  },
  "dependencies": {
    "@furystack/inject": "^12.0.0",
    "@furystack/shades": "^14.0.0",
    "@furystack/rest-client-fetch": "^8.1.0",
    "@furystack/utils": "^4.0.0",
    "common": "workspace:^"
  },
  "devDependencies": {
    "vite": "^7.3.0"
  }
}

frontend/tsconfig.json

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "jsx": "react-jsx",
    "jsxImportSource": "@furystack/shades"
  },
  "include": ["src"],
  "references": [
    { "path": "../common" }
  ]
}

frontend/index.html

Vite needs an HTML entry point in the workspace root:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My FuryStack App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>

frontend/src/index.tsx

Initialize the Shades root and render a simple todo list that fetches data from the service:

import { createInjector } from '@furystack/inject'
import { initializeShadeRoot } from '@furystack/shades'
import { TodoApp } from './components/todo-app.js'

const shadesInjector = createInjector()

const rootElement = document.getElementById('root') as HTMLDivElement

initializeShadeRoot({
  injector: shadesInjector,
  rootElement,
  jsxElement: <TodoApp />,
})

frontend/src/components/todo-app.tsx

A Shade component using useObservable for reactive state and @furystack/rest-client-fetch for type-safe API calls:

import { createComponent, Shade } from '@furystack/shades'
import { createClient } from '@furystack/rest-client-fetch'
import { ObservableValue } from '@furystack/utils'
import type { MyApi, TodoItem } from 'common'

const client = createClient<MyApi>({
  endpointUrl: 'http://localhost:3000/api',
})

export const TodoApp = Shade({
  customElementName: 'todo-app',
  render: ({ useObservable }) => {
    const todos$ = new ObservableValue<TodoItem[]>([])
    const input$ = new ObservableValue('')

    const todos = useObservable('todos', todos$)
    const input = useObservable('input', input$)

    const loadTodos = async () => {
      const result = await client({ method: 'GET', action: '/todos' })
      todos$.setValue(result.result)
    }

    void loadTodos()

    const addTodo = async () => {
      if (!input) return
      await client({
        method: 'POST',
        action: '/todos',
        body: { title: input },
      })
      input$.setValue('')
      void loadTodos()
    }

    return (
      <div>
        <h1>Todos</h1>
        <div>
          <input
            value={input}
            oninput={(e: Event) =>
              input$.setValue((e.target as HTMLInputElement).value)}
            placeholder="What needs to be done?"
          />
          <button onclick={addTodo}>Add</button>
        </div>
        <ul>
          {todos.map((todo) => (
            <li>{todo.title}</li>
          ))}
        </ul>
      </div>
    )
  },
})

5. Build and run

From the monorepo root, install dependencies and build:

yarn install
yarn build

Then start the service and the frontend in separate terminals:

# Terminal 1
yarn start:service

# Terminal 2
yarn start:frontend

Open http://localhost:8080/ — you should see the todo app. Adding items calls the service API, which stores them in memory via InMemoryStore.


What’s next?

You now have a working full-stack FuryStack application. From here you can:

  • Swap the InMemoryStore factory for defineFileSystemStore, defineMongoDbStore, defineRedisStore, or defineSequelizeStore to persist data — see Data Stores
  • Add authentication with @furystack/auth-jwt and @furystack/security
  • Layer authorization, modifiers, and change events on your dataset — see Repository
  • Add runtime validation to your endpoints — see Data Validation
  • Explore the Getting Started hub for the full set of guides