This takes a new directory to a running API and a typed frontend call. It builds a NestJS server and a separate frontend. To serve the API from a Next.js app instead, with no separate server, see With Next.js below.
1. Create the server
You need Node ^22.23.4 || >=24.22.1 (see Installation; on Node 22 use npm 11 or newer).
npx @aventara/cli@rc new my-apiThe command asks for the ORM, the database and the package manager. Each has a flag, and --yes takes the defaults (Prisma 7, SQLite, the package manager you launched it with). It runs nest new for you, adds Aventara, installs everything and, last, commits the result:
npx @aventara/cli@rc new my-api --yes
npx @aventara/cli@rc new my-api --orm prisma7 --db postgresql --package-manager pnpm--orm takes an adapter name: prisma7 today (Prisma 7). prisma and prisma@7 are refused, and the message names the choice. See the CLI reference for every flag.
While it works it shows each step ([3/4] Installing dependencies (npm install)), so a long install never looks hung; add --verbose to see the output of nest new and of the install as it runs. A successful run ends with the commit, a closing line with the total time, and the next steps, which are the last thing printed:
aventara: committed the scaffold: "Initial Aventara Scaffold".
aventara: created my-api in 1m 53s.
Next:
1. Enter the project: cd my-api
2. Create the database tables: npx prisma db push
3. Start the server: npm run start:dev
4. In your frontend: npx @aventara/client@rc init
5. Call the API with curl: see "Calling the API over HTTP" in .claude/skills/aventara/SKILL.mdWith PostgreSQL the list has one more step, after entering the project: Set DATABASE_URL in .env to your PostgreSQL database. The later steps move down by one. SQLite needs no such step.
That commit is skipped, with a one-line reason, when git is not installed or has no user.name and user.email, when the new directory is inside a repository already, or with --skip-git. .env is never committed (the scaffold's .gitignore lists it).
What it creates
A normal NestJS 12 project, plus:
| File | What it is |
|---|---|
prisma/schema.prisma | Your schema, with a starter User model. Yours to edit. |
prisma.config.ts | Prisma configuration; loads .env for you. |
src/prisma.service.ts | PrismaService (the Prisma client with its driver adapter) and PrismaModule. |
src/aventara.config.ts | Your Aventara configuration, a plain value with no adapter in it: the api mount path /api, the resources tree and a diagnostics logger, plus the exemptions, pipelines and per-Resource settings you add. Yours to edit. |
src/aventara.adapter.ts | The adapter's description: describePrismaAdapter({ discovery, provider, driver }). app.module.ts builds the adapter from it and the Prisma client. |
src/app.module.ts | Edited once to import PrismaModule and AventaraModule.forRootAsync(...). |
src/main.ts | Edited once to enable CORS for the origins in CORS_ORIGINS, and to hold a commented-out rate limiter (below). |
.env | DATABASE_URL="file:./dev.db" and CORS_ORIGINS. Not committed. |
src/generated/ | Generated on every install by npm run aventara:prepare: the Prisma client, the discovery artifact, and the three files aventara-build writes (resources.generated.ts, computed-fields.generated.ts, contracts.generated.ts). Never edit. |
.claude/skills/aventara/ | The agent skill for your AI coding agent: SKILL.md and its reference/ files, for the Aventara version installed. aventara skill refreshes it (AI agents). |
AGENTS.md | A short Aventara section between <!-- aventara:start --> and <!-- aventara:end -->; an existing AGENTS.md keeps everything else. |
test/app.e2e-spec.ts | Edited once to add a GET /api/_contract test after Nest's GET / test. |
The starter schema:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
name String?
}And src/aventara.config.ts, the configuration as a plain value with no adapter in it:
import type { AvDiagnostic } from '@aventara/core';
import { Logger } from '@nestjs/common';
import { aventaraResources } from './generated/aventara/resources.generated.js';
const logger = new Logger('Aventara');
/**
* Aventara's configuration: where the API is mounted (`api`), and what you add
* to it (`exposed` and `exemptions`, `pipelines`, and the per-Resource
* `resources` tree).
* AventaraModule (app.module.ts) builds the framework from it and the adapter
* (src/aventara.adapter.ts). aventara-build, in the aventara:prepare script,
* runs this file to compile its Contracts, so keep its top level free of side
* effects: no connection, no request.
*/
export const aventaraConfig = {
api: '/api',
// Everything the adapter offers is exposed. List what to hide under
// `exemptions`, or set `exposed: false` and list what to expose instead.
// See: https://aventara.razor34.dev/framework/docs/exposure
// Every Resource you define with defineResource() in its own file under
// src/resources/ (named <name>.resource.ts), gathered by aventara-build in
// the aventara:prepare script: a new Resource file needs no line here. When
// a Resource gives a layer a block, that layer takes its tree once, e.g.
// client: { resources: aventaraResources.client },
resources: aventaraResources.root,
// When something fails inside the server, the caller gets a plain 500 and
// Aventara logs one line naming the request. This logs the error itself,
// with its stack, through Nest's logger.
diagnostics: (diagnostic: AvDiagnostic) => {
const { error } = diagnostic;
logger.error(
`${diagnostic.code} (request ${diagnostic.requestId}): raw error`,
(error instanceof Error && error.stack) || String(error),
);
},
} as const;src/aventara.adapter.ts describes the database side. aventara-build and app.module.ts both read it:
import { describePrismaAdapter } from '@aventara/prisma7-adapter';
import { discovery } from './generated/aventara/discovery.artifact.js';
/**
* The Prisma adapter's description: your schema's model, as the aventara:prepare
* script discovered it (`discovery`), and the database it runs on. aventara-build
* compiles the Contracts from it; app.module.ts builds the adapter from it with
* the Prisma client.
*/
export default describePrismaAdapter({
discovery,
provider: 'sqlite',
driver: '@prisma/adapter-better-sqlite3',
});src/app.module.ts joins the two and builds the adapter with the Prisma client (Nest's own controllers and providers stay as they were):
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { AventaraModule } from '@aventara/nest';
import { createPrismaAdapter } from '@aventara/prisma7-adapter';
import adapter from './aventara.adapter.js';
import { aventaraConfig } from './aventara.config.js';
import { PrismaModule, PrismaService } from './prisma.service.js';
@Module({
imports: [
PrismaModule,
AventaraModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: async (prisma: PrismaService) => ({
config: {
...aventaraConfig,
adapter: await createPrismaAdapter({
...adapter,
client: prisma,
}),
},
}),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}A fresh NestJS 12 project is an ES module ("type": "module", nodenext module resolution), so its own imports end in .js, as above, even though the files are .ts. Your own files follow the same rule.
And src/main.ts:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// The browser origins allowed to call this server: CORS_ORIGINS in .env,
// comma-separated. Unset or empty, CORS stays off.
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({ origin: corsOrigins.length > 0 ? corsOrigins : false });
// Rate limiting: @nestjs/throttler is a Nest guard and does not run on Aventara's
// routes; a platform-level limiter does. `npm i express-rate-limit`, import
// { rateLimit } from 'express-rate-limit', pick your limits and uncomment below.
// See: https://aventara.razor34.dev/framework/docs/limits-and-safety#rate-limiting
// app.use(rateLimit({ windowMs: 60_000, limit: 100 }));
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();Two details of main.ts:
- CORS: a browser on another origin (a Vite dev server on
5173, a Next.js one on3001) can call the API only if its origin is inCORS_ORIGINS, a comma-separated list in.env. The scaffold listshttp://localhost:5173,http://localhost:3001. Unset or empty, CORS is off. Add your deployed frontend's origin before you deploy. CORS is a browser rule, not access control: servers and bots ignore it, so guards decide who may call. - Rate limiting: the commented lines are a platform-level limiter. Read Rate limiting before you switch it on.
2. Create the tables and start the server
aventara new never touches a database. Create the tables, then start:
cd my-api
npx prisma db push
npm run start:devOn startup the server logs one line naming what it mounted:
Aventara mounted 15 operations at /api (+ GET /api/_contract, POST /api/_transactions, POST /api/_all)(The operation count depends on your schema and configuration.) With pnpm, use pnpm exec prisma db push. With PostgreSQL, set DATABASE_URL in .env first.
Check that the server answers. GET /api/_contract returns the contract the frontend client is generated from:
curl -i http://localhost:3000/api/_contractHTTP/1.1 200 OK
Content-Type: application/json
ETag: "sha256:0b2afa72…"
Cache-Control: no-cache
Aventara-Request-Id: 5d3f71a4-1f2e-4639-9600-98571495484b
{"enums":{},"limits":{…},"protocol":{"hash":"sha256:0b2afa72…","version":1},"referenceWrites":"uniform","resources":{"User":{…}},…}Every operation is a POST that carries the contract's hash. Fetch the hash, then list the users (none yet):
HASH=$(curl -s http://localhost:3000/api/_contract | node -pe 'JSON.parse(require("fs").readFileSync(0)).protocol.hash')
curl -s -X POST http://localhost:3000/api/_resources/User/find/many \
-H 'Content-Type: application/json' \
-H 'Aventara-Protocol-Version: 1' \
-H "Aventara-Contract-Hash: $HASH" \
-d '{}'{"data":[],"code":"A1000","cause":null}You will normally use the generated client instead of curl; the protocol is documented in HTTP protocol.
Changing the schema
When you change prisma/schema.prisma, run:
npx prisma db push # or your migration workflow
npx prisma generate
npm run aventara:prepare # regenerates src/generated/aventara/ (the discovery artifact, then aventara-build's files)aventara:prepare also runs on every npm install, and in a project made by aventara new or aventara init before build, start, start:dev and start:debug (the prebuild and prestart* scripts), so restarting npm run start:dev is enough there. Yarn Berry, and pnpm with enable-pre-post-scripts=false, skip those hooks: run aventara:prepare yourself. Restart the server afterwards. Because the contract changes, you must also regenerate the frontend client.
3. Set up the frontend client
In your frontend project (any TypeScript project with a tsconfig.json; plain JavaScript projects are not supported yet; see Troubleshooting for known cosmetic warnings on the first run):
npx @aventara/client@rc initIt asks four questions, in this order: the server's entrypoint (default http://localhost:3000/api), the environment variable that holds the entrypoint, where to put the client (default ./src/api) and the package manager. Then it writes framework.client.ts, adds the .env entry and an avclient:generate script, installs @aventara/client as an exact dev dependency, adds .env to the frontend's .gitignore, and generates the client from the running server. A frontend served from another origin (Vite on 5173) can call the server because the scaffold's CORS_ORIGINS lists it; see Browsers and CORS.
Later, regenerate any time with:
npm run avclient:generateThe generated client lives in src/api/: AvClient.ts and generated/. Commit it. It is TypeScript source, and it imports its own files the way your project's tsconfig.json says, so you import it like any of your own files.
4. Make a typed call
Resource keys are your Prisma model names: User, Post, and so on. The import path follows your project (the spelling for each kind of project); in a Vite or Next.js project it is extensionless:
import avClient from "./api/AvClient";
const ada = await avClient.User.create.one({
data: { email: "ada@example.com", name: "Ada" },
select: ["id", "email"],
});
// { id: "cmhb3k7q00000a8f2x9d1e4tz", email: "ada@example.com" } (a cuid)
const users = await avClient.User.find.many({
where: { email: { contains: "example.com" } },
orderBy: { id: "desc" },
limit: 5,
});
const nobody = await avClient.User.find.first({ where: { email: "nobody@example.com" } });
// null: a first-style miss is null, not an errorEverything is typed from the contract: a misspelled field, a filter the field does not support, or an operation the server does not advertise is a compile error.
5. Grow the schema
Add a related model and an enum to prisma/schema.prisma:
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
passwordHash String @default("")
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
views Int @default(0)
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
}Then make passwordHash write-only for remote callers in src/aventara.config.ts, by adding a client block next to resources (Exposure):
client: {
exposed: true,
exemptions: {
resources: {
User: { fields: { passwordHash: ["select", "filter", "order"] } },
},
},
},Apply the schema, regenerate the discovery artifact, restart the server, and regenerate the client:
npx prisma db push
npm run aventara:prepare
npm run start:dev # in the server project
npm run avclient:generate # in the frontend projectNow relations, enums and the exemption are all typed on the client:
import avClient from "./api/AvClient";
const grace = await avClient.User.create.one({
data: { email: "grace@example.com", name: "Grace", passwordHash: "hash-from-your-auth-code" },
select: ["id", "email", "role"],
});
// { id: "cmhb3k9w20001a8f2c6p0v7qm", email: "grace@example.com", role: "USER" }
await avClient.Post.create.one({
data: { title: "Hello", published: true, author: { $connect: { id: grace.id } } },
});
const posts = await avClient.Post.find.many({
where: { published: true },
select: ["id", "title", { author: { select: ["id", "name"] } }],
orderBy: { id: "desc" },
limit: 10,
});
// [{ id: "cmhb3kb1x0002a8f2j5n8r0wy", title: "Hello", author: { id: "cmhb3k9w20001a8f2c6p0v7qm", name: "Grace" } }]
const users = await avClient.User.find.many({
select: ["id", { posts: { select: ["id", "title", "$count"] } }],
});
// [
// { id: "cmhb3k7q00000a8f2x9d1e4tz", posts: { data: [], count: 0 } },
// { id: "cmhb3k9w20001a8f2c6p0v7qm", posts: { data: [{ id: "cmhb3kb1x0002a8f2j5n8r0wy", title: "Hello" }], count: 1 } },
// ]
await avClient.Post.update.many({ where: { published: true }, data: { views: { $increment: 1 } } });Asking for the hidden-from-reads field is refused at compile time, and, if you bypass the types, at runtime:
await avClient.User.find.many({ select: ["passwordHash"] });
// compile error; at runtime: AvValidationError A2004 / V1008, Field "passwordHash" is not selectable.With Next.js
--host next builds a Next.js app that serves the API itself. There is no separate server, no CORS and no frontend client to regenerate:
npx @aventara/cli@rc new my-app --host next
cd my-app
npx prisma db push
npm run devIt runs create-next-app (TypeScript, the App Router under src/, the @/* alias), then adds Aventara in src/aventara/. create-next-app asks its own questions; --yes takes its defaults. The API answers at http://localhost:3000/api, the same protocol as above.
Server Components and Server Actions call avClient from @/aventara/server, in process. Client Components call avClient from @/aventara/client, which npm run aventara:prepare generates with the rest (it runs on install and before next build). Both take and return Date, bigint and AvDecimal values. Next.js host has the files, the three clients and a first page. To add Aventara to an app you already have, run npx @aventara/cli@rc init in it.
Next steps
- Why Aventara for the same feature in plain NestJS, GraphQL, tRPC and Aventara.
- Concepts for layers, operations and result typing.
- Add more models and relations, then read Querying.
- Hide a field or add authentication in Configuration.
- Group writes in Transactions.
See also: Exposing and hiding fields, Authentication and guards, Filtering, sorting and paging.