Project structure

VextJS follows the design philosophy of convention over configuration and implements automatic scanning, loading and registration through a fixed directory structure, without the need to manually configure route mapping or service injection.

Standard directory structure

my-app/
├── preload/ # Project-level preload script (optional)
 ├── 01-otel.ts # Execute before process starts
 └── README.md # Scaffolding space instructions

├── public/ # Static assets copied into the frontend build
 └── favicon.svg

├── src/
 ├── frontend/ # Frontend source (default full-stack template)
 ├── pages/ # Pages, layouts, error pages, and document template
 ├── index.tsx
 ├── layout.tsx
 ├── _document.html
 └── error/
   └── default.tsx
 ├── components/ # Shared components
 ├── styles/ # CSS / JSCSS / tokens
 └── index.css
 ├── assets/ # Assets imported from TSX/CSS into the bundle graph
 └── locales/ # Frontend page messages
   ├── zh-CN.ts
   └── en-US.ts

 ├── config/ # Configuration file (required)
 ├── default.ts #Default configuration (must exist)
 ├── bootstrap.ts # Startup provider (optional)
 ├── bootstrap.example.ts # Provider example generated by scaffolding
 ├── development.ts # Development environment coverage (optional)
 ├── production.ts # Production environment coverage (optional)
 ├── local.ts # Local coverage, not submitted to Git (optional)
 └── local.example.ts # Example of local coverage generated by scaffolding

 ├── routes/ # Route definition (conventional, automatic scanning)
 ├── index.ts # → /
 ├── users.ts # → /users
 ├── users/
 ├── index.ts # → /users (choose one from users.ts)
 └── [id].ts # → /users/:id (dynamic parameter)
 └── admin/
 ├── index.ts # → /admin
 └── settings.ts # → /admin/settings

 ├── services/ # Service layer (conventional, automatic scanning + injection)
 ├── user.ts # → app.services.user
 ├── order.ts # → app.services.order
 └── payment/
 └── stripe.ts # → app.services.payment.stripe

 ├── middlewares/ # Middleware definition (convention, automatic scanning)
 ├── auth.ts # → referenced by name 'auth'
 └── check-role.ts # → referenced by name 'check-role'

 ├── plugins/ # Plug-ins (conventional, automatic scanning)
 ├── redis.ts # Custom plug-in
 └── sentry.ts # Custom plug-in

 ├── locales/ # International language pack (optional)
 ├── zh-CN.ts # Chinese language pack
 └── en-US.ts # English language pack

 └── types/
 └── generated/ # typegen reference shim (TS project)
 └── index.d.ts

├── .vext/
 ├── client/ # development frontend build output
 ├── types/ # hidden generated declarations
 └── manifest/ # tooling manifests

├── dist/ # Build product (generated by vext build)
 └── client/ # production frontend assets when frontend.enabled is true
├── package.json
└── tsconfig.json # TypeScript configuration

Detailed explanation of each directory

src/config/ — Configuration directory

When the framework starts, config-loader loads configuration files and merges them deeply in the following order:

Framework built-in defaults → default.ts → {profile}.ts → local.ts → bootstrap provider patch → CLI override
FilePurposeIs it necessary
default.tsBasic configuration for all profiles✅ Required
bootstrap.tsRemote configuration provider registration entrance during startupOptional
bootstrap.example.tsProvider example generated by scaffolding, copied to bootstrap.ts and enabledExample
development.tsDefault development profile coverageOptional
production.tsDefault production profile coverageOptional
test.tsDefault test profile coverageOptional
sg-sit.tsCustom profile coverageOptional
local.tsLocal development coverage (should be added to .gitignore)Optional
local.example.tsLocal coverage example generated by scaffolding, copied to local.ts and enabledExample

Config profiles can be selected with vext start --config <name> or VEXT_CONFIG=<name>. For example, vext start --config sg-sit loads sg-sit.ts.

Scaffolding Convention

vext create generates local.example.ts / bootstrap.example.ts by default instead of directly generating local.ts / bootstrap.ts. This not only tells the user the agreed path, but also prevents local coverage or remote configuration entries from being mistakenly submitted to the warehouse.

// src/config/default.ts
export default {
  port: 3000,
  host: "0.0.0.0",
  logger: { level: "info" },
  openapi: { enabled: true },
};
// src/config/production.ts — only overwrite the fields that need to be changed
export default {
  logger: { level: "warn" },
  openapi: { enabled: false },
};
merge strategy

Configuration uses deep merge, you only need to declare the fields that need to be covered in the environment file. The middlewares array uses a smart patch strategy (match by name and overwrite) rather than simple array replacement. The provider patch returned by bootstrap.ts will participate in the same merge / validate / freeze process after local.ts and before CLI override.

What does bootstrap.ts do?

When the configuration must be pulled from the remote end before the application is started, src/config/bootstrap.ts can be added:

import { defineBootstrapConfig } from "vextjs";

export default defineBootstrapConfig({
  providers: [
    {
      name: "remote-config",
      async load({ env, signal }) {
        const response = await fetch(`https://config.example.com/${env}.json`, {
          signal,
        });
        return await response.json();
      },
    },
  ],
});

Common uses:

  • Database connection information
  • Nacos/Configuration Center startup patch
  • Requires infrastructure configuration that is visible before built-in plugins are initialized

src/frontend/ — Frontend directory

The default full-stack scaffold creates src/frontend/ for React page source. URL entry still lives in src/routes/**, and a route handler renders a page with res.render(page, props, options). Vext generates the browser entry and registries under .vext/generated/frontend/.

PathPurpose
pages/index.tsx / pages/index.jsxDefault page, with page id index
pages/layout.tsx / pages/layout.jsxDirectory layout, nestable and reusable
pages/error/default.tsx / pages/error/default.jsxDefault error page
pages/_document.htmlHTML document using {vext.root}, {vext.data}, {vext.entry}, and {vext.styles}
components/Shared components, importable through @components/...
styles/index.cssGlobal style entry
assets/Images, fonts, and other assets imported from TSX/CSS
locales/Frontend page copy used with useVextI18n()

When config.frontend.enabled is true:

  • vext dev builds the client into .vext/client/
  • vext build writes production assets to dist/client/
  • vext start serves the production client, SSR renderer, and static assets; unmatched HTML fallback depends on frontend.spaFallback.scopes[]

public/ — Frontend static assets

Files in public/ are copied into the frontend output directory. Use it for favicons, robots files, and static images that should be served without going through the JavaScript bundle. Images or fonts imported from TSX/CSS and emitted with hashes should usually live under src/frontend/assets/.

src/routes/ — Routing directory

Route files are automatically scanned by router-loader, and file paths are directly mapped to URL prefixes. Each file uses defineRoutes() to export route definitions.

Path mapping rules

File pathURL prefixDescription
routes/index.ts/Root route
routes/users.ts/usersFirst-level routing
routes/users/index.ts/usersEquivalent to users.ts
routes/users/[id].ts/users/:idDynamic parameters
routes/admin/settings.ts/admin/settingsNested routes

Dynamic parameters

Use [paramName] syntax to represent dynamic routing parameters, which are automatically converted to :paramName when loading:

routes/users/[id].ts → /users/:id
routes/posts/[slug]/comments.ts → /posts/:slug/comments

Sub-routes within the file

Multiple sub-routes can be registered inside each file. The path will automatically be spliced with file-level prefixes:

// src/routes/users.ts → prefix /users
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  // GET /users/list
  app.get("/list", async (req, res) => {
    const users = await app.services.user.findAll();
    res.json(users);
  });

  // POST /users (merge with prefix when subpath is /)
  app.post("/", async (req, res) => {
    const user = await app.services.user.create(req.body);
    res.json(user, 201);
  });

  // GET /users/:id
  app.get("/:id", async (req, res) => {
    const user = await app.services.user.findById(req.params.id);
    res.json(user);
  });
});

Exclusion rules

The following files will be automatically skipped and will not be loaded as routes:

  • Test files: *.test.ts, *.spec.ts
  • Files/directories starting with _ or .
  • node_modules directory

src/services/ — Services directory

Service files are automatically scanned by service-loader, each file exports a class, and the constructor receives the app parameter. Automatically mounted to app.services after instantiation.

Name mapping rules

File pathAccess methodDescription
services/user.tsapp.services.userFlat naming
services/order.tsapp.services.orderFlat naming
services/payment/stripe.tsapp.services.payment.stripeNested naming
services/user-profile.tsapp.services.userProfilekebab → camelCase

File names are automatically converted from kebab-case to camelCase. Subdirectories are mapped as nested objects.

Service class writing method

// src/services/user.ts
import type { VextApp } from "vextjs";

export default class UserService {
  private app: VextApp;

  constructor(app: VextApp) {
    this.app = app;
  }

  async findAll() {
    //Business logic...
    return [];
  }

  async findById(id: string) {
    // Can access other services
    // const profile = await this.app.services.userProfile.get(id);
    return { id, name: "Alice" };
  }

  async create(data: unknown) {
    this.app.logger.info({ data }, "Creating user");
    return { id: "1", ...(data as object) };
  }
}
circular dependency detection

service-loader has built-in circular dependency detection mechanism. If ServiceA directly accesses app.services.b in the constructor, and ServiceB also accesses app.services.a, the framework will detect it at startup and report an error.Recommended practice: Only save the app reference in the constructor, and access other services on demand in the method (delayed access).

src/middlewares/ — middleware directory

Middleware files are automatically scanned by middleware-loader. Each file exports a middleware tagged via defineMiddleware or defineMiddlewareFactory.

The file name is the middleware name and is referenced by name in configuration and routing:

// src/middlewares/auth.ts
import { defineMiddleware } from "vextjs";

export default defineMiddleware(async (req, res, next) => {
  const token = req.headers["authorization"];
  if (!token) req.app.throw(401, "Unauthorized");
  // ...verify token
  await next();
});

When using it, first declare the whitelist in the configuration and then reference it in the routing:

// src/config/default.ts
export default {
  middlewares: [
    "auth", // Ordinary middleware
    { name: "check-role", options: { roles: ["admin"] } }, // Factory middleware + default parameters
  ],
};
// src/routes/admin.ts — referenced in routing
app.get(
  "/dashboard",
  {
    middlewares: ["auth", "check-role"],
  },
  handler,
);

See the Middleware chapter for details.

src/plugins/ — plugin directory

Plug-in files are automatically scanned by plugin-loader, topologically sorted according to dependencies statement, and then setup() is executed in sequence.

// src/plugins/redis.ts
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "redis",
  async setup(app) {
    const redis = createRedisClient(app.config.redis);
    app.extend("redis", redis);
    app.onClose(() => redis.quit());
  },
});

See the Plugins chapter for details.

src/locales/ — Internationalization directory

Language pack files are automatically scanned by i18n-loader, and the file name is the language code. After loading, it is registered to the i18n system of schema-dsl and linked with app.throw().

// src/locales/zh-CN.ts
export default {
  "user.not_found": { code: 40001, message: "The user does not exist" },
  "balance.insufficient": {
    code: 20001,
    message: "Insufficient balance, current balance {{balance}}",
  },
};
// src/locales/en-US.ts
export default {
  "user.not_found": { code: 40001, message: "User not found" },
  "balance.insufficient": {
    code: 20001,
    message: "Insufficient balance, current: {{balance}}",
  },
};

See the Internationalization (i18n) chapter for details.

Automatically scan the loading sequence

When the framework starts (bootstrap), each directory is loaded in the following order:

1. config/ → load and merge configuration (loadConfig)
2. locales/ → Load language pack (loadI18n)
3. plugins/ → topological sort + execute setup() (loadPlugins)
4. middlewares/ → Scan middleware definition (loadMiddlewares)
5. services/ → Instantiate and inject into app.services(loadServices)
6. routes/ → scan routes + register to adapter (loadRoutes)
7. frontend → build/serve client assets when `frontend.enabled` is true
8. Start HTTP listening

This order ensures:

  • Configuration is ready before all modules
  • Plugins can extend the app object (e.g. inject database connections)
  • Middleware is ready before route registration
  • Services are injected before routing, and app.services can be safely accessed in the routing handler

package.json requirements

VextJS projects must be declared as ESM modules:

{
  "type": "module",
  "scripts": {
    "start": "vext start",
    "dev": "vext dev",
    "build": "vext build"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Build product dist/

After executing vext build, the TypeScript files under src/ will be compiled into the dist/ directory, maintaining the same directory structure. In production mode (vext start) load directly from dist/.

dist/
├── config/
│ └── default.js
├── routes/
│ └── index.js
├── services/
│ └── user.js
├── client/
│ ├── assets/
│ ├── index.html
│ ├── manifest.json
│ └── size-report.json
└──...

:::tip development vs production

  • vext dev: Load .ts files directly from src/ (compiled on-the-fly through esbuild), supporting hot reloading
  • vext start: Load .js file from dist/, you need to execute vext build first
    • When frontend is enabled, production start also requires dist/client/index.html :::

Next step