# Calm App Structure for the Vyriy Ecosystem

A practical project structure for Vyriy applications: shared configs, small packages, thin workspaces, Storybook docs, tests, and deployable entry points.

Published: 2026-05-19
Tags: vyriy, calm-architecture, cli, monorepo, typescript, storybook
Source: https://vyriy.dev/blog/vyriy-calm-app-structure/

---

# Calm App Structure for the Vyriy Ecosystem

A project structure is not just a folder convention.

It is a contract between developers, tools, CI/CD, documentation, tests, deployment, and AI assistants. When the structure is predictable, the project becomes easier to understand, easier to change, and easier to generate.

This is one of the reasons why the Vyriy CLI should not only create files. It should create a calm starting point.

A calm application structure should answer simple questions quickly:

- Where is the reusable code?
- Where are the runtime entry points?
- Where are the configs?
- Where are the tests?
- Where is the documentation?
- What can be reused later as a package?
- What exists only to start, build, deploy, or expose the app?

Vyriy proposes a structure based on two main ideas:

1. **Packages contain real code.**
2. **Workspaces contain thin entry points.**

Around that structure, every app starts with a shared config layer: TypeScript, ESLint, Prettier, Jest, Stylelint, Storybook, EditorConfig, Git ignore rules, Yarn settings, and project documentation.

## Why structure matters

Many projects start simple and then slowly become harder to reason about. Code moves into random folders. Runtime files start to contain business logic. Configs drift between repositories. Tests are sometimes close to the code and sometimes not. Documentation exists only in memory.

Vyriy tries to prevent this by making the boring parts explicit from the beginning.

The goal is not to invent a complex framework. The goal is to make the project readable by default.

A developer should be able to open a repository and quickly understand:

- configs live at the root;
- reusable code lives in `packages`;
- runnable entry points live in `workspaces`;
- each package has its own README;
- Storybook can render docs, not only UI components;
- tests live close to the code they cover;
- `index.ts` is a public re-export surface, not an implementation file;
- shared public types live in `types.ts`;
- deployment code is isolated from business logic.

This structure is also useful for AI coding tools. A predictable repository gives tools like Codex, Claude Code, Copilot, or local agents a clearer map of the project.

## The root config layer

The core of every Vyriy app is the config system.

Configs are not secondary files. They define how the project is developed, formatted, linted, tested, documented, built, and deployed.

A Vyriy app can start with these base features:

- TypeScript
- ESLint
- Prettier
- Jest
- Storybook

Storybook is intentionally included in the base layer. It is not only a playground for React components. In a calm project, Storybook can also be a documentation surface for packages, examples, APIs, utilities, and architecture notes.

## TypeScript

```bash
yarn add @vyriy/typescript-config typescript
```

A minimal `tsconfig.json` can extend the shared Vyriy config:

```json
{
  "extends": "@vyriy/typescript-config/index.json",
  "compilerOptions": {
    "noEmit": false
  },
  "include": [
    "index.ts"
  ]
}
```

For a package or workspace, the config can override output folders:

```json
{
  "extends": "@vyriy/typescript-config/index.json",
  "compilerOptions": {
    "noEmit": false,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": [
    "packages/**/*.ts",
    "packages/**/*.tsx"
  ],
  "exclude": [
    "**/*.stories.ts",
    "**/*.stories.tsx",
    "**/*.test.ts",
    "**/*.test.tsx"
  ]
}
```

The important part is that the project does not copy a large TypeScript config every time. It extends a shared baseline and overrides only what is needed.

## Prettier

```bash
yarn add @vyriy/prettier-config prettier
```

Base config:

```js
export { default } from '@vyriy/prettier-config';
```

Override example:

```js
import baseConfig, { type Config } from '@vyriy/prettier-config';

const config: Config = {
  ...baseConfig,
  printWidth: 100,
};

export default config;
```

Recommended `.prettierignore`:

```ini
node_modules
dist
coverage
storybook-static
```

## ESLint

```bash
yarn add @vyriy/eslint-config eslint
```

Base config:

```js
export { default } from '@vyriy/eslint-config';
```

Override example:

```js
import baseConfig, { type Linter } from '@vyriy/eslint-config';

const config: Linter.Config[] = [
  ...baseConfig,
  {
    rules: {
      'no-console': 'warn',
    },
  },
];

export default config;
```

## Jest

```bash
yarn add @vyriy/jest-config jest
```

Base config:

```js
export { default } from '@vyriy/jest-config';
```

Override example:

```js
import baseConfig, { type Config } from '@vyriy/jest-config';

const config: Config = {
  ...baseConfig,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;
```

## Stylelint

```bash
yarn add @vyriy/stylelint-config stylelint
```

Base config:

```js
export { default } from '@vyriy/stylelint-config';
```

Override example:

```js
import baseConfig from '@vyriy/stylelint-config';

export default {
  ...baseConfig,
  ignoreFiles: [
    ...baseConfig.ignoreFiles,
    'coverage/**',
  ],
  rules: {
    ...baseConfig.rules,
    'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$',
  },
};
```

## Storybook as documentation

```bash
yarn add @vyriy/storybook-config storybook
```

Storybook is often treated only as a component playground. In Vyriy projects, it can do more.

Each package can have its own `README.md`, and Storybook can render that README through `doc.mdx`. This gives every package a small documentation page close to the code.

```mdx
import { Meta, Markdown } from '@storybook/addon-docs/blocks';
import ReadMe from './README.md?raw';

<Meta title="Path/Package" />

<Markdown>{ReadMe}</Markdown>
```

This pattern helps humans and AI tools. A package with a README is easier to understand. A package with a Storybook docs page is easier to browse.

Base `.storybook/main.ts`:

```js
import config, { type StorybookConfig } from '@vyriy/storybook-config/main';
import { path } from '@vyriy/path';

const mainConfig: StorybookConfig = {
  ...config,
  stories: [
    path('packages', '**/*.mdx'),
    path('packages', '**/*.stories.@(js|jsx|mjs|ts|tsx)'),
  ],
};

export default mainConfig;
```

Base `.storybook/preview.ts`:

```js
export { default } from '@vyriy/storybook-config/preview';
```

Override example:

```js
import config, { type Preview } from '@vyriy/storybook-config/preview';

const preview: Preview = {
  ...config,
  tags: ['autodocs'],
};

export default preview;
```

## Other root files

A generated project should also include common root files.

`.editorconfig`:

```ini
# https://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

indent_style = space
indent_size = 2

max_line_length = 100

[*.md]
trim_trailing_whitespace = false
max_line_length = off

[*.{yml,yaml}]
indent_size = 2

[*.json]
indent_size = 2

[*.{ts,tsx,js,jsx}]
indent_size = 2

[*.sh]
indent_size = 2
```

`.gitignore`:

```ini
.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

.DS_Store
.idea
node_modules
coverage
dist
storybook-static
*storybook.log
consumer

cdk.out
cdk.context.json

!/**/.gitkeep
```

`.npmrc`:

```ini
strict-ssl=false
engine-strict=true
```

`.nvmrc`:

```ini
lts/krypton
```

`.yarnrc.yml`:

```yml
nodeLinker: node-modules

yarnPath: .yarn/releases/yarn-4.14.1.cjs
```

A root [AGENTS.md](https://vyriy.dev/blog/agents-md-calm-contract/) is also useful. It can describe coding rules, package boundaries, testing rules, public API rules, and AI assistant expectations.

## Packages: where real code lives

Vyriy proposes keeping real reusable code in `packages`.

A package can be a utility, service, UI module, handler collection, config wrapper, domain module, or infrastructure helper.

The point is simple: if the code has value beyond one runtime entry point, it should probably live in a package.

This keeps the app calm:

- packages are reusable;
- packages can be tested independently;
- packages can have small public APIs;
- packages can have their own README;
- packages can later become published libraries;
- workspaces stay thin.

## A small package example

A simple utility package can look like this:

```bash
.
├── README.md
├── doc.mdx
├── cn.test.ts
├── cn.ts
├── index.test.ts
├── index.ts
├── package.json
└── types.ts
```

`package.json`:

```json
{
  "name": "@p/cn",
  "type": "module",
  "main": "index.js",
  "private": true
}
```

`index.ts` should be a re-export surface only:

```ts
export * from './cn.js';
export type * from './types.js';
```

`types.ts` should contain shared public types:

```ts
export type ClassDictionary = Record<string, boolean | undefined | null>;
export type ClassItem = string | ClassDictionary | ClassItem[] | null | undefined | false;
export type ClassNames = (...items: ClassItem[]) => string;
```

`cn.ts` contains implementation:

```ts
import type { ClassNames } from './types.js';

export const cn: ClassNames = (...items) => {
  // code
};
```

Tests stay close to the files they cover:

```bash
cn.test.ts
index.test.ts
```

The rule is simple: every runtime file should have a matching test file when possible. `types.ts` can be an exception because it contains only types.

This file shape keeps the package easy to scan:

- `README.md` explains why the package exists;
- `doc.mdx` exposes the README in Storybook;
- `types.ts` exposes shared public types;
- implementation files do the work;
- test files prove the behavior;
- `index.ts` exposes the public API.

## Larger package examples

A package does not have to be only one function. It can represent a calm app layer.

For example, `packages/api` can keep API handlers:

```bash
├── README.md
├── doc.mdx
├── package.json
├── index.test.ts
├── index.ts
├── message.test.ts
├── message.ts
├── status.test.ts
└── status.ts
```

`packages/ui` can keep React UI, hooks, and styles:

```bash
├── README.md
├── doc.mdx
├── hooks
│   ├── use-api.test.ts
│   └── use-api.ts
├── index.test.ts
├── index.ts
├── package.json
├── styles
│   ├── components.scss
│   ├── reset.scss
│   ├── shared.scss
│   └── theme.scss
├── styles.scss
├── ui.test.tsx
└── ui.tsx
```

The same idea still applies: packages hold code that can be understood, tested, reused, and documented.

## Workspaces: thin runtime entry points

`workspaces` are different.

A workspace should usually be a thin entry point. It can start an API, render SSR, run CSR, build a static site, or deploy a stack. But it should not become the place where business logic grows.

A workspace is responsible for things like:

- local development start scripts;
- build scripts;
- runtime wiring;
- Dockerfile;
- [webpack config](https://vyriy.dev/examples/vyriy-webpack-config/);
- deployment entry point;
- importing code from packages.

This separation is important. It keeps app logic reusable and keeps runtime-specific code small.

## API workspace

An API workspace can look like this:

```bash
├── bin
│   ├── build.sh
│   └── start.sh
├── README.md
├── doc.mdx
├── index.test.ts
├── index.ts
├── package.json
├── Dockerfile
└── webpack.config.ts
```

`bin/build.sh`:

```bash
#!/usr/bin/env sh

set -e

scriptdir="$PWD/workspaces/api";

NODE_ENV=production npx webpack --config $scriptdir/webpack.config.ts

cp $scriptdir/package.json dist/api/package.json
npm pkg delete "type" --prefix dist/api
npm pkg delete "private" --prefix dist/api
```

`bin/start.sh`:

```bash
#!/usr/bin/env sh

set -e

scriptdir="$PWD/workspaces/api";

LOG_LEVEL=info tsx $scriptdir/index.ts
```

`index.ts` wires the runtime, handler, router, and package-level handlers:

```ts
import { server } from '@vyriy/server';
import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';
import { status, message } from '@p/api';

server(api(async (event) => createRouter().get('/', status).post('/message', message).route(event)));
```

The Dockerfile is also part of the workspace because it describes how this entry point runs:

```Dockerfile
FROM node:krypton-alpine

WORKDIR /app

ENV PORT=3000
ENV LOG_LEVEL=info
ENV NODE_ENV=production

COPY package.json .

RUN npm install

COPY --chown=node:node . .

USER node

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null "http://127.0.0.1:${PORT}/healthcheck" || exit 1

CMD ["node", "index.js"]
```

`webpack.config.ts` describes the server build:

```ts
import 'webpack';
import nodeExternals from 'webpack-node-externals';

import { path } from '@vyriy/path';
import { ssr } from '@vyriy/webpack-config';

export default ssr(
  '@w/api',
  {
    path: path('dist', 'api'),
    filename: 'index.js',
    library: { type: 'commonjs2' },
  },
  (config) => ({
    ...config,
    externals: [nodeExternals({ allowlist: [/^@p/, /^@w/, /^@vyriy/] })],
  }),
);
```

## SSR workspace

An SSR workspace has almost the same shape as an API workspace, but the entry point renders React:

```bash
├── bin
│   ├── build.sh
│   └── start.sh
├── README.md
├── doc.mdx
├── index.test.tsx
├── index.tsx
├── package.json
├── Dockerfile
└── webpack.config.ts
```

Example `index.tsx`:

```tsx
import { readFileSync } from 'node:fs';

import { server } from '@vyriy/server';
import { api } from '@vyriy/handler';
import { html } from '@vyriy/html';
import { path } from '@vyriy/path';
import { getEnv } from '@vyriy/env';
import { createRouter } from '@vyriy/router';
import { renderToString } from 'react-dom/server';

import { Dashboard } from '@p/ui';

const dashboardStyles = readFileSync(path('styles.css'), 'utf8');

const router = createRouter().get('/', async () => ({
  headers: {
    'content-type': 'text/html; charset=utf-8',
  },
  body: html({
    htmlAttributes: 'lang="en"',
    title: `<title>${getEnv('APP', 'Status dashboard')}</title>`,
    meta: '<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><meta http-equiv="refresh" content="30" />',
    style: `<style>${dashboardStyles}</style>`,
    body: renderToString(<Dashboard app={getEnv('APP', 'System health')} />),
  }),
}));

server(api(router.handle()));
```

The workspace owns runtime composition. The UI still lives in `packages/ui`.

## CSR workspace

A CSR workspace can stay thin as well:

```bash
├── bin
│   ├── build.sh
│   └── start.sh
├── README.md
├── doc.mdx
├── index.test.tsx
├── index.tsx
├── package.json
└── webpack.config.ts
```

`bin/build.sh`:

```bash
#!/usr/bin/env sh

set -e

scriptdir="$PWD/workspaces/ui";

NODE_ENV=production npx webpack --config $scriptdir/webpack.config.ts
```

`bin/start.sh`:

```bash
#!/usr/bin/env sh

set -e

scriptdir="$PWD/workspaces/ui";

npx webpack serve --open --config $scriptdir/webpack.config.ts
```

`index.tsx`:

```tsx
import { createRoot } from 'react-dom/client';
import { UI } from '@p/ui';
import '@p/ui/styles.scss';

createRoot(document.getElementById('root')!).render(<UI />);
```

`webpack.config.ts`:

```ts
import 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';

import { csr } from '@vyriy/webpack-config';
import { path } from '@vyriy/path';
import { html } from '@vyriy/html';

export default csr(
  '@w/ui',
  {
    path: path('dist', 'ui'),
    filename: 'index.js',
  },
  (config) => {
    return {
      ...config,
      plugins: [
        ...(config.plugins ?? []),
        new HtmlWebpackPlugin({
          templateContent: html({
            title: '<title>App</title>',
            body: '<div id="root"></div>',
          }),
          publicPath: '/',
          hash: true,
          inject: 'body',
          minify: {
            removeComments: true,
            collapseWhitespace: true,
            removeAttributeQuotes: false,
            minifyJS: true,
            minifyCSS: true,
          },
        }),
      ],
    };
  },
);
```

## Stack workspace

[Infrastructure](https://vyriy.dev/examples/vyriy-calm-serverless-static-site/) can also be a workspace. For example, an AWS CDK stack can live in `workspaces/stack`:

```bash
├── index.test.ts
├── index.ts
└── package.json
```

The stack imports reusable infrastructure helpers from packages and keeps deployment composition in one place.

Example static site deployment flow:

```ts
const mutableFiles = [
  'index.html',
  '404.html',
  '**/index.html',
  'storybook/**',
  'sitemap.xml',
  'robots.txt',
];

const assetDeployment = deployment.createBucketDeployment(this, 'DeploySiteAssets', {
  cacheControl: deployment.createImmutableCacheControl(),
  destinationBucket: siteBucket,
  distribution: siteDistribution,
  exclude: mutableFiles,
  distributionPaths: ['/*'],
  sources: [deployment.Source.asset(path('dist'))],
});

const htmlDeployment = deployment.createBucketDeployment(this, 'DeploySiteHtml', {
  cacheControl: deployment.createHtmlCacheControl(),
  destinationBucket: siteBucket,
  distribution: siteDistribution,
  distributionPaths: ['/*'],
  exclude: ['*'],
  include: mutableFiles,
  prune: false,
  sources: [deployment.Source.asset(path('dist'))],
});

htmlDeployment.node.addDependency(assetDeployment);
```

This split keeps immutable assets and mutable HTML-like files under different cache rules. The stack remains explicit and deployable, while the site code remains outside of infrastructure concerns.

## What the Vyriy CLI can generate

This structure maps naturally to the Vyriy CLI.

A generated project can start with the root config layer and then add packages and workspaces based on the selected preset.

For example:

- `library` can generate only `packages/*` and shared configs;
- `api` can generate `packages/api` and `workspaces/api`;
- `react-csr` can generate `packages/ui` and `workspaces/ui`;
- `react-ssr` can generate `packages/ui` and an SSR workspace;
- `react-ssg` can generate content, UI, static build, and deployable output;
- `aws-serverless` can generate handler packages and stack workspace;
- `fullstack` can combine API, UI, and stack;
- `mfe` and `openmfe` can extend the same structure later.

The CLI should not hide the architecture. It should create files that teach the architecture.

That means generated projects should include:

- root configs;
- `AGENTS.md`;
- `README.md`;
- Storybook docs wiring;
- tests next to implementation files;
- clear package boundaries;
- thin runtime workspaces;
- safe behavior for existing files: overwrite, skip, or fail explicitly.

## Calm rules

The structure can be summarized as a small checklist:

- Keep reusable code in `packages`.
- Keep runtime entry points in `workspaces`.
- Keep `index.ts` as re-export only.
- Keep shared public types in `types.ts`.
- Keep tests next to the files they cover.
- Keep README files close to packages and workspaces.
- Render package documentation through Storybook with `doc.mdx`.
- Keep configs shared and override only what is needed.
- Keep infrastructure explicit and isolated.
- Keep generated files predictable.

These rules are intentionally boring. That is the point.

Boring structure is easy to inspect. Easy structure is easier to test. Tested structure is easier to change.

## Conclusion

Vyriy app structure is not about forcing every project into a heavy framework.

It is about creating a calm baseline:

- shared configs;
- small packages;
- thin workspaces;
- local documentation;
- co-located tests;
- explicit runtime wiring;
- deployable entry points;
- clear rules for humans and AI tools.

This is why the Vyriy CLI can become more than a generator. It can become a project master that creates not only files, but a calm architecture contract from the first commit.
