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:
- Packages contain real code.
- 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.tsis 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.mdexplains why the package exists;doc.mdxexposes the README in Storybook;types.tsexposes shared public types;- implementation files do the work;
- test files prove the behavior;
index.tsexposes 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:
librarycan generate onlypackages/*and shared configs;apican generatepackages/apiandworkspaces/api;react-csrcan generatepackages/uiandworkspaces/ui;react-ssrcan generatepackages/uiand an SSR workspace;react-ssgcan generate content, UI, static build, and deployable output;aws-serverlesscan generate handler packages and stack workspace;fullstackcan combine API, UI, and stack;mfeandopenmfecan 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.tsas 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.