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

yarn add @vyriy/typescript-config typescript

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

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

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

{
  "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

yarn add @vyriy/prettier-config prettier

Base config:

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

Override example:

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

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

export default config;

Recommended .prettierignore:

node_modules
dist
coverage
storybook-static

ESLint

yarn add @vyriy/eslint-config eslint

Base config:

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

Override example:

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

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

export default config;

Jest

yarn add @vyriy/jest-config jest

Base config:

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

Override example:

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

yarn add @vyriy/stylelint-config stylelint

Base config:

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

Override example:

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

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.

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:

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:

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

Override example:

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:

# 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:

.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:

strict-ssl=false
engine-strict=true

.nvmrc:

lts/krypton

.yarnrc.yml:

nodeLinker: node-modules

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

A root AGENTS.md 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:

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

package.json:

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

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

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

types.ts should contain shared public types:

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:

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

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

Tests stay close to the files they cover:

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:

├── 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:

├── 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;
  • 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:

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

bin/build.sh:

#!/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:

#!/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:

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:

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:

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:

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

Example index.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(async (event) => router.route(event)));

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

CSR workspace

A CSR workspace can stay thin as well:

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

bin/build.sh:

#!/usr/bin/env sh

set -e

scriptdir="$PWD/workspaces/ui";

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

bin/start.sh:

#!/usr/bin/env sh

set -e

scriptdir="$PWD/workspaces/ui";

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

index.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:

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 can also be a workspace. For example, an AWS CDK stack can live in workspaces/stack:

├── 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:

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.