Prisma ORM integration

This example shows how to integrate Prisma into a VextJS project to implement type-safe database operations by writing a database plug-in.

Why choose Prisma?

FeaturesDescription
Type SafetyPrisma Client for code generation, automatic type inference for queries and results
Intuitive APIAdvanced query API, supporting nested creation, related queries, transactions, etc.
Database MigrationBuilt-in prisma migrate for declarative schema migration
Multiple DatabasesSupports PostgreSQL, MySQL, SQLite, MongoDB, CockroachDB
Visualization ToolsPrisma Studio provides a database graphical management interface
Rich EcologyExtensive community plug-ins and third-party integrations
Tip

Prisma vs Drizzle: Prisma provides a higher level of abstraction and richer query API (such as include, select, nested writing), which is suitable for projects that require rapid development and complex association operations. Drizzle is closer to the native semantics of SQL and is suitable for scenarios that require higher SQL control. VextJS supports both, the choice depends on team preference.

Project structure

prisma-orm/
  ├── prisma/
  │ ├── schema.prisma ← Prisma schema definition
  │ ├── seed.ts ← Seed data script
  │ └── migrations/ ← Migration file (generated by prisma migrate)
  ├── src/
  │ ├── config/
  │ │ ├── default.ts
  │ │ └── production.ts
  │ ├── plugins/
  │ │ └── database.ts ← Database plug-in
  │ ├── routes/
  │ │ ├── index.ts
  │ │ ├── users.ts
  │ │ └── posts.ts
  │ ├── services/
  │ │ ├── user.ts
  │ │ └── post.ts
  │ └── index.ts
  ├── types/
  │ └── vext.d.ts ← Type declaration extension
  ├── package.json
  └── tsconfig.json

1. Install dependencies

npx vextjs create prisma-orm-demo
cd prisma-orm-demo

#Prisma
pnpm add @prisma/client
pnpm add -D prisma

2. Initialize Prisma

npx prisma init --datasource-provider sqlite

This creates the prisma/schema.prisma file and the .env file.

3. Define Prisma Schema

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url = env("DATABASE_URL")
}

// ── User model ─────────────────────────────────────────────

model User {
  id Int @id @default(autoincrement())
  name String
  email String @unique
  ageInt?
  role String @default("user")
  bioString?
  avatarUrl String? @map("avatar_url")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // association
  posts Post[]
  comments Comment[]

  @@map("users")
}

// ── Article model ─────────────────────────────────────────────

model Post {
  id Int @id @default(autoincrement())
  title String
  content String
  published Boolean @default(false)
  viewCount Int @default(0) @map("view_count")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // association
  author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId Int @map("author_id")
  comments Comment[]
  tags Tag[]

  @@map("posts")
}

// ── Comment model ────────────────────────────────────────────

model Comment {
  id Int @id @default(autoincrement())
  content String
  createdAt DateTime @default(now()) @map("created_at")

  author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId Int @map("author_id")
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId Int @map("post_id")

  @@map("comments")
}

//── Tag model (many-to-many)─────────────────────────────────────

model Tag {
  id Int @id @default(autoincrement())
  name String @unique
  posts Post[]

  @@map("tags")
}

Generate Prisma Client

# Create migration and generate Client
npx prisma migrate dev --name init# Or just generate Client (no migration)
npx prisma generate

Environment variables

# .env
DATABASE_URL="file:./data/app.db"

4. Seed data

// prisma/seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  console.log("Filling seed data...");

  //Clear data (in dependency order)
  await prisma.comment.deleteMany();
  await prisma.post.deleteMany();
  await prisma.tag.deleteMany();
  await prisma.user.deleteMany();

  //Create label
  const tagBackend = await prisma.tag.create({ data: { name: "Backend" } });
  const tagFrontend = await prisma.tag.create({ data: { name: "Frontend" } });
  const tagDevOps = await prisma.tag.create({ data: { name: "DevOps" } });
  const tagTypeScript = await prisma.tag.create({
    data: { name: "TypeScript" },
  });

  //Create user + article (nested creation)
  const alice = await prisma.user.create({
    data: {
      name: "Alice",
      email: "alice@example.com",
      age: 28,
      role: "admin",
      bio: "Full stack developer, loves open source",
      posts: {
        create: [
          {
            title: "VextJS Getting Started Guide",
            content:
              "This article describes how to use VextJS to build a high-performance RESTful API. VextJS provides enterprise-level features such as Adapter architecture, plug-in system, and conventional routing...",
            published: true,
            tags: {
              connect: [{ id: tagBackend.id }, { id: tagTypeScript.id }],
            },
          },
          {
            title: "Prisma ORM Best Practices",
            content:
              "In-depth exploration of Prisma's advanced query techniques, including related queries, transactions, middleware, and more...",
            published: true,
            tags: { connect: [{ id: tagBackend.id }] },
          },
          {
            title: "Unpublished draft",
            content: "This is an article that is still being written...",
            published: false,
          },
        ],
      },
    },
  });

  const bob = await prisma.user.create({
    data: {
      name: "Bob",
      email: "bob@example.com",
      age: 32,
      role: "user",
      bio: "front-end engineer",
      posts: {
        create: [
          {
            title: "React 18 new features",
            content:
              "Explore concurrency features, Suspense improvements, and automatic batch updates in React 18...",
            published: true,
            tags: {
              connect: [{ id: tagFrontend.id }, { id: tagTypeScript.id }],
            },
          },
        ],
      },
    },
  });

  const charlie = await prisma.user.create({
    data: {
      name: "Charlie",
      email: "charlie@example.com",
      age: 25,
      role: "editor",
      bio: "Technology Blogger",
    },
  });

  //Create comment
  const alicePosts = await prisma.post.findMany({
    where: { authorId: alice.id, published: true },
  });

  if (alicePosts.length > 0) {
    await prisma.comment.createMany({
      data: [
        {
          content: "Great writing! Collected 👍",
          authorId: bob.id,
          postId: alicePosts[0].id,
        },
        {
          content: "Is there a supporting video tutorial?",
          authorId: charlie.id,
          postId: alicePosts[0].id,
        },
        {
          content: "A very practical Prisma tutorial",
          authorId: charlie.id,
          postId: alicePosts[1].id,
        },
      ],
    });
  }

  console.log("Seed data filling completed ✅");
  console.log(`User: ${await prisma.user.count()}`);
  console.log(` Post: ${await prisma.post.count()}`);
  console.log(`Comment: ${await prisma.comment.count()}`);
  console.log(` tag: ${await prisma.tag.count()}`);
}

main()
  .catch((e) => {
    console.error("Seed data filling failed:", e);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

Configure the seed command in package.json:

{
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  },
  "scripts": {
    "db:seed": "npx prisma db seed",
    "db:studio": "npx prisma studio",
    "db:migrate": "npx prisma migrate dev",
    "db:reset": "npx prisma migrate reset"
  }
}

Run the seed:

pnpm db:seed

5. Database plug-in

// src/plugins/database.ts
import { definePlugin } from "vextjs";
import { PrismaClient } from "@prisma/client";

export default definePlugin({
  name: "database",
  async setup(app) {
    app.logger.info("Initializing Prisma Client...");

    const prisma = new PrismaClient({
      log:
        app.config.logger.level === "debug"
          ?[
              { emit: "event", level: "query" },
              { emit: "stdout", level: "info" },
              { emit: "stdout", level: "warn" },
              { emit: "stdout", level: "error" },
            ]
          :[
              { emit: "stdout", level: "warn" },
              { emit: "stdout", level: "error" },
            ],
    });

    // Development environment prints SQL query
    if (app.config.logger.level === "debug") {
      prisma.$on("query" as any, (e: any) => {
        app.logger.debug(
          { query: e.query, params: e.params, duration: `${e.duration}ms` },
          "Prisma SQL",
        );
      });
    }

    //Connect to database
    await prisma.$connect();
    app.logger.info("Prisma Client connection successful");

    //Mount to app
    app.extend("prisma", prisma);

    //Ready hook: verify connection
    app.onReady(async () => {
      try {
        await prisma.$queryRaw`SELECT 1`;
        const userCount = await prisma.user.count();
        app.logger.info({ userCount }, "Database connection verification successful");
      } catch (err) {
        app.logger.error({ error: err }, "Database connection verification failed");
        throw err;
      }
    });

    //Close hook: disconnect
    app.onClose(async () => {
      await prisma.$disconnect();
      app.logger.info("Prisma Client has been disconnected");
    });
  },
});

Type declaration extension

// types/vext.d.ts
import type { PrismaClient } from "@prisma/client";

declare module "vextjs" {
  interface VextApp {
    prisma: PrismaClient;
  }

  interface VextRequest {
    user?: {
      id: string;
      role: string;
    };
  }
}

6. Configuration

// src/config/default.ts
export default {
  port: 3000,
  adapter: "native",
  logger: {
    level: "debug",
    pretty: true,
  },
  cors: {
    enabled: true,
    origins: ["*"],
  },
  response: {
    wrap: true,
    hideInternalErrors: false,
  },
  openapi: {
    enabled: true,
    title: "Prisma ORM Example",
    version: "1.0.0",
    description: "VextJS RESTful API integrated with Prisma ORM",
    tags: [
      { name: "Basic", description: "Basic interface" },
      { name: "User", description: "User management interface" },
      { name: "Article", description: "Article Management Interface" },
    ],
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
    guardSecurityMap: {
      auth: "bearerAuth",
    },
  },
  middlewares: [{ name: "auth" }],
};
// src/config/production.ts
export default {
  logger: {
    level: "info",
    pretty: false,
  },
  response: {
    hideInternalErrors: true,
  },
  openapi: {
    enabled: false,
  },
};

7. Service layer

User Services

// src/services/user.ts
import type { VextApp, VextLogger } from "vextjs";
import type { User, Prisma } from "@prisma/client";

export default class UserService {
  private logger: VextLogger;

  constructor(private app: VextApp) {
    this.logger = app.logger.child({ service: "UserService" });
  }/**
   * Query user list by page
   *
   * Support keyword search, role filtering and sorting.
   */
  async findAll(options: {
    page: number;
    limit: number;
    keyword?: string;
    role?: string;
    sortBy?: string;
    sortOrder?: "asc" | "desc";
  }) {
    this.logger.debug(options, "Query user list");

    const prisma = this.app.prisma;

    //Construct WHERE condition
    const where: Prisma.UserWhereInput = {};

    if (options.keyword) {
      where.OR = [
        { name: { contains: options.keyword } },
        { email: { contains: options.keyword } },
        { bio: { contains: options.keyword } },
      ];
    }

    if (options.role) {
      where.role = options.role;
    }

    // Query data and totals in parallel
    const [items, total] = await Promise.all([
      prisma.user.findMany({
        where,
        skip: (options.page - 1) * options.limit,
        take: options.limit,
        orderBy: {
          [options.sortBy ?? "createdAt"]: options.sortOrder ?? "desc",
        },
        select: {
          id: true,
          name: true,
          email: true,
          age: true,
          role: true,
          bio: true,
          avatarUrl: true,
          createdAt: true,
          updatedAt: true,
          _count: {
            select: { posts: true, comments: true },
          },
        },
      }),
      prisma.user.count({ where }),
    ]);

    return {
      items,
      total,
      page: options.page,
      limit: options.limit,
      totalPages: Math.ceil(total/options.limit),
    };
  }

  /**
   * Query user details based on ID
   *
   * Contains a list of published articles and statistical information.
   */
  async findById(id: number) {
    this.logger.debug({ userId: id }, "Query user details");

    const user = await this.app.prisma.user.findUnique({
      where: { id },
      include: {
        posts: {
          where: { published: true },
          orderBy: { createdAt: "desc" },
          select: {
            id: true,
            title: true,
            published: true,
            viewCount: true,
            createdAt: true,
            tags: {
              select: { id: true, name: true },
            },
            _count: {
              select: { comments: true },
            },
          },
        },
        _count: {
          select: {
            posts: true,
            comments: true,
          },
        },
      },
    });

    return user;
  }

  /**
   * Create user
   */
  async create(data: {
    name: string;
    email: string;
    age?: number;
    role?: string;
    bio?: string;
  }): Promise<User> {
    this.logger.info({ email: data.email }, "Create user");

    // Prisma's unique constraint will automatically detect duplicate mailboxes
    // But we check in advance to return a friendly business error code
    const existing = await this.app.prisma.user.findUnique({
      where: { email: data.email },
      select: { id: true },
    });

    if (existing) {
      this.app.throw(409, "Email has been registered", 10001);
    }

    const user = await this.app.prisma.user.create({
      data: {
        name: data.name,
        email: data.email,
        age: data.age,
        role: data.role ?? "user",
        bio: data.bio,
      },
    });

    this.logger.info({ userId: user.id, email: user.email }, "User created successfully");
    return user;
  }

  /**
   * Update user
   */
  async update(
    ID: number,
    data: {
      name?: string;
      email?: string;
      age?: number;
      bio?: string;
      avatarUrl?: string;
    },
  ): Promise<User> {
    this.logger.info({ userId: id }, "Update user");

    // Check if the user exists
    const existing = await this.app.prisma.user.findUnique({
      where: { id },
      select: { id: true, email: true },
    });

    if (!existing) {
      this.app.throw(404, "User does not exist");
    }// Email uniqueness check
    if (data.email && data.email !== existing.email) {
      const emailTaken = await this.app.prisma.user.findUnique({
        where: { email: data.email },
        select: { id: true },
      });

      if (emailTaken) {
        this.app.throw(409, "The mailbox is already used by another user", 10002);
      }
    }

    const user = await this.app.prisma.user.update({
      where: { id },
      data,
    });

    this.logger.info({ userId: id }, "User updated successfully");
    return user;
  }

  /**
   * Delete user
   *
   * Since the schema is set with onDelete: Cascade, the associated articles and comments will be automatically deleted.
   */
  async delete(id: number): Promise<void> {
    this.logger.info({ userId: id }, "Delete user");

    const existing = await this.app.prisma.user.findUnique({
      where: { id },
      select: { id: true },
    });

    if (!existing) {
      this.app.throw(404, "User does not exist");
    }

    await this.app.prisma.user.delete({ where: { id } });

    this.logger.info({ userId: id }, "User deleted successfully (associated data has been cascade deleted)");
  }

  /**
   * Count the number of users
   */
  async count(): Promise<number> {
    return this.app.prisma.user.count();
  }
}

Article Service

// src/services/post.ts
import type { VextApp, VextLogger } from "vextjs";
import type { Post, Prisma } from "@prisma/client";

export default class PostService {
  private logger: VextLogger;

  constructor(private app: VextApp) {
    this.logger = app.logger.child({ service: "PostService" });
  }

  /**
   * Query the list of published articles
   */
  async findPublished(options: {
    page: number;
    limit: number;
    keyword?: string;
    tag?: string;
  }) {
    this.logger.debug(options, "Query article list");

    const prisma = this.app.prisma;

    const where: Prisma.PostWhereInput = {
      published: true,
    };

    if (options.keyword) {
      where.OR = [
        { title: { contains: options.keyword } },
        { content: { contains: options.keyword } },
      ];
    }

    if (options.tag) {
      where.tags = {
        some: { name: options.tag },
      };
    }

    const [items, total] = await Promise.all([
      prisma.post.findMany({
        where,
        skip: (options.page - 1) * options.limit,
        take: options.limit,
        orderBy: { createdAt: "desc" },
        include: {
          author: {
            select: { id: true, name: true, avatarUrl: true },
          },
          tags: {
            select: { id: true, name: true },
          },
          _count: {
            select: { comments: true },
          },
        },
      }),
      prisma.post.count({ where }),
    ]);

    return {
      items,
      total,
      page: options.page,
      limit: options.limit,
      totalPages: Math.ceil(total/options.limit),
    };
  }

  /**
   * Query article details (including comments)
   */
  async findById(id: number) {
    this.logger.debug({ postId: id }, "Query article details");

    const prisma = this.app.prisma;

    const post = await prisma.post.findUnique({
      where: { id },
      include: {
        author: {
          select: { id: true, name: true, avatarUrl: true, bio: true },
        },
        tags: {
          select: { id: true, name: true },
        },
        comments: {
          orderBy: { createdAt: "desc" },
          include: {
            author: {
              select: { id: true, name: true, avatarUrl: true },
            },
          },
        },
        _count: {
          select: { comments: true },
        },
      },
    });

    if (!post) return null;

    //Increase view count
    await prisma.post.update({
      where: { id },
      data: { viewCount: { increment: 1 } },
    });

    return post;
  }/**
   *Create article
   */
  async create(data: {
    title: string;
    content: string;
    authorId: number;
    published?: boolean;
    tags?: string[];
  }): Promise<Post> {
    this.logger.info(
      { authorId: data.authorId, title: data.title },
      "Create article",
    );

    const prisma = this.app.prisma;

    // Verify if the author exists
    const author = await prisma.user.findUnique({
      where: { id: data.authorId },
      select: { id: true },
    });

    if (!author) {
      this.app.throw(404, "The author does not exist");
    }

    // Processing tag: connectOrCreate (associate if existing, create if not)
    const tagConnections = data.tags?.map((tagName) => ({
      where: { name: tagName },
      create: { name: tagName },
    }));

    const post = await prisma.post.create({
      data: {
        title: data.title,
        content: data.content,
        authorId: data.authorId,
        published: data.published ?? false,
        tags: tagConnections ? { connectOrCreate: tagConnections } : undefined,
      },
      include: {
        author: { select: { id: true, name: true } },
        tags: { select: { id: true, name: true } },
      },
    });

    this.logger.info({ postId: post.id }, "Post created successfully");
    return post;
  }

  /**
   * Update article
   */
  async update(
    ID: number,
    data: {
      title?: string;
      content?: string;
      published?: boolean;
      tags?: string[];
    },
  ) {
    this.logger.info({ postId: id }, "Update article");

    const prisma = this.app.prisma;

    const existing = await prisma.post.findUnique({
      where: { id },
      select: { id: true },
    });

    if (!existing) {
      this.app.throw(404, "Article does not exist");
    }

    // If tags are provided, disconnect all existing tags before associating new tags
    const updateData: Prisma.PostUpdateInput = {};

    if (data.title !== undefined) updateData.title = data.title;
    if (data.content !== undefined) updateData.content = data.content;
    if (data.published !== undefined) updateData.published = data.published;

    if (data.tags !== undefined) {
      updateData.tags = {
        set: [], // Clear existing tags
        connectOrCreate: data.tags.map((name) => ({
          where: { name },
          create: { name },
        })),
      };
    }

    const post = await prisma.post.update({
      where: { id },
      data: updateData,
      include: {
        author: { select: { id: true, name: true } },
        tags: { select: { id: true, name: true } },
      },
    });

    this.logger.info({ postId: id }, "Post updated successfully");
    return post;
  }

  /**
   * Delete article
   */
  async delete(id: number): Promise<void> {
    this.logger.info({ postId: id }, "Delete post");

    const existing = await this.app.prisma.post.findUnique({
      where: { id },
      select: { id: true },
    });

    if (!existing) {
      this.app.throw(404, "Article does not exist");
    }

    await this.app.prisma.post.delete({ where: { id } });

    this.logger.info({ postId: id }, "Post deleted successfully");
  }

  /**
   *Add comment
   */
  async addComment(data: {
    postId: number;
    authorId: number;
    content: string;
  }) {
    this.logger.info(
      { postId: data.postId, authorId: data.authorId },
      "Add comment",
    );

    const prisma = this.app.prisma;

    // Verify that the article exists and has been published
    const post = await prisma.post.findUnique({
      where: { id: data.postId },
      select: { id: true, published: true },
    });

    if (!post) {
      this.app.throw(404, "Article does not exist");
    }

    if (!post.published) {
      this.app.throw(400, "Cannot comment on unpublished articles");
    }const comment = await prisma.comment.create({
      data: {
        content: data.content,
        authorId: data.authorId,
        postId: data.postId,
      },
      include: {
        author: { select: { id: true, name: true } },
      },
    });

    this.logger.info({ commentId: comment.id }, "Comment added successfully");
    return comment;
  }
}

8. Routing

Root route

// src/routes/index.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get(
    "/",
    {
      docs: {
        summary: "Health Check",
        tags: ["Basics"],
      },
    },
    async (_req, res) => {
      const userCount = await app.services.user.count();
      res.json({
        status: "ok",
        database: "connected",
        users: userCount,
        uptime: Math.floor(process.uptime()),
        timestamp: new Date().toISOString(),
      });
    },
  );
});

User routing

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

export default defineRoutes((app) => {
  // GET /users/list — paging query
  app.get(
    "/list",
    {
      validate: {
        query: {
          page: "number:1-",
          limit: "number:1-100",
          keyword: "string?",
          role: "string?",
        },
      },
      docs: {
        summary: "User list",
        description:
          "Query the user list in pages, supporting keyword search and role filtering. Returns the count of articles and comments for each user.",
        tags: ["user"],
      },
    },
    async (req, res) => {
      const { page, limit, keyword, role } = req.valid("query");
      const result = await app.services.user.findAll({
        page,
        limit,
        keyword,
        role,
      });
      res.json(result);
    },
  );

  // GET /users/:id — Query details (including article list)
  app.get(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
      },
      docs: {
        summary: "Get user details",
        description: "Query user details, including list of published articles and statistical information.",
        tags: ["user"],
        responses: {
          200: { description: "Query successful" },
          404: { description: "User does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const user = await app.services.user.findById(id);
      if (!user) app.throw(404, "User does not exist");
      res.json(user);
    },
  );

  // POST /users — Create users (authentication required)
  app.post(
    "/",
    {
      validate: {
        body: {
          name: "string:1-50",
          email: "email",
          age: "number:0-200?",
          role: "enum:admin,user,editor?",
          bio: "string?",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Create user",
        tags: ["user"],
        responses: {
          201: { description: "Created successfully" },
          422: { description: "Parameter verification failed" },
          401: { description: "Not authenticated" },
          409: { description: "Email has been registered" },
        },
      },
    },
    async (req, res) => {
      const body = req.valid("body");
      const user = await app.services.user.create(body);
      res.json(user, 201);
    },
  );// PUT /users/:id — update user (authentication required)
  app.put(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
        body: {
          name: "string:1-50?",
          email: "email?",
          age: "number:0-200?",
          bio: "string?",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Update user",
        tags: ["user"],
        responses: {
          200: { description: "Update successful" },
          404: { description: "User does not exist" },
          409: { description: "The mailbox has been occupied" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const body = req.valid("body");
      const user = await app.services.user.update(id, body);
      res.json(user);
    },
  );

  // DELETE /users/:id — delete user (authentication required)
  app.delete(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Delete user",
        description: "Delete the user and all his posts and comments (cascade deletion).",
        tags: ["user"],
        responses: {
          204: { description: "Delete successfully" },
          404: { description: "User does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      await app.services.user.delete(id);
      res.status(204).json(null);
    },
  );
});

Article routing

// src/routes/posts.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  // GET /posts/list — Query published articles
  app.get(
    "/list",
    {
      validate: {
        query: {
          page: "number:1-",
          limit: "number:1-100",
          keyword: "string?",
          tag: "string?",
        },
      },
      docs: {
        summary: "Article List",
        description: "Query the list of published articles, supporting keyword search and tag filtering.",
        tags: ["article"],
      },
    },
    async (req, res) => {
      const { page, limit, keyword, tag } = req.valid("query");
      const result = await app.services.post.findPublished({
        page,
        limit,
        keyword,
        tag,
      });
      res.json(result);
    },
  );

  // GET /posts/:id — article details (including comments)
  app.get(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
      },
      docs: {
        summary: "Article details",
        description:
          "Query article details, including author information, tags, and comment list. Each visit automatically increases the browsing count.",
        tags: ["article"],
        responses: {
          200: { description: "Query successful" },
          404: { description: "The article does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const post = await app.services.post.findById(id);
      if (!post) app.throw(404, "Post does not exist");
      res.json(post);
    },
  );

  // POST /posts — create articles (authentication required)
  app.post(
    "/",
    {
      validate: {
        body: {
          title: "string:1-200",
          content: "string:1-",
          published: "boolean?",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Create article",
        description:
          "Create a new article. Tags (array of strings) can be associated via the tags field in the request body.",
        tags: ["article"],
        responses: {
          201: { description: "Created successfully" },
          401: { description: "Not authenticated" },
        },
      },
    },
    async (req, res) => {
      const body = req.valid("body");

      // Get the author ID from the authentication information
      const authorId = parseInt(req.user?.id ?? "0", 10);
      if (!authorId) {
        app.throw(401, "Unable to determine user identity");
      }

      // tags are obtained from the original body (not declared in validate schema, because schema-dsl does not support array validation)
      const rawBody = req.body as any;
      const tags = Array.isArray(rawBody?.tags) ? rawBody.tags : undefined;const post = await app.services.post.create({
        title: body.title,
        content: body.content,
        authorId,
        published: body.published,
        tags,
      });

      res.json(post, 201);
    },
  );

  // PUT /posts/:id — update post (authentication required)
  app.put(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
        body: {
          title: "string:1-200?",
          content: "string?",
          published: "boolean?",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Update article",
        tags: ["article"],
        responses: {
          200: { description: "Update successful" },
          404: { description: "The article does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const body = req.valid("body");

      const rawBody = req.body as any;
      const tags = Array.isArray(rawBody?.tags) ? rawBody.tags : undefined;

      const post = await app.services.post.update(id, { ...body, tags });
      res.json(post);
    },
  );

  // DELETE /posts/:id — delete post (authentication required)
  app.delete(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Delete article",
        tags: ["article"],
        responses: {
          204: { description: "Delete successfully" },
          404: { description: "The article does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      await app.services.post.delete(id);
      res.status(204).json(null);
    },
  );

  // POST /posts/:id/comments — add comments (authentication required)
  app.post(
    "/:id/comments",
    {
      validate: {
        param: { id: "number:1-" },
        body: {
          content: "string:1-1000",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Add comment",
        description: "Add a comment to the specified article. The article must have been published.",
        tags: ["article"],
        responses: {
          201: { description: "Comment successful" },
          400: { description: "Cannot comment on unpublished articles" },
          404: { description: "The article does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id: postId } = req.valid("param");
      const { content } = req.valid("body");

      const authorId = parseInt(req.user?.id ?? "0", 10);
      if (!authorId) {
        app.throw(401, "Unable to determine user identity");
      }

      const comment = await app.services.post.addComment({
        postId,
        authorId,
        content,
      });

      res.json(comment, 201);
    },
  );
});

9. Entry file

// src/index.ts
import { bootstrap } from "vextjs";

bootstrap().catch((err) => {
  console.error("Startup failed:", err);
  process.exit(1);
});

10. Operation and verification

#Initialize database
npx prisma migrate dev --name init

# Fill in seed data
pnpm db:seed

# Start development server
pnpmdev

Interface verification

#HealthCheck
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","database":"connected","users":3,...},...}

# User list (including article/comment count)
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[{"id":1,"name":"Alice",...,"_count":{"posts":3,"comments":0}},...],...},...}

# User details (including article list and tags)
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":1,"name":"Alice","posts":[{"id":1,"title":"VextJS Getting Started Guide","tags":[{"name":"Backend"},...]},...]},...}

# Article list (filter by tag)
curl "http://localhost:3000/posts/list?page=1&limit=10&tag=TypeScript"
# → Only return articles containing the "TypeScript" tag

# Article details (including comments)
curl http://localhost:3000/posts/1
# → {"code":0,"data":{"id":1,"title":"VextJS Getting Started Guide","viewCount":1,"comments":[...],"author":{"name":"Alice"},...},...}#Create article (including tags)
curl -X POST http://localhost:3000/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "title": "Node.js Performance Optimization",
    "content": "Share your experience in Node.js performance optimization...",
    "published": true,
    "tags": ["backend", "performance"]
  }'
# → 201 {"code":0,"data":{"id":4,"title":"Node.js Performance Optimization","tags":[{"name":"Backend"},{"name":"Performance"}],...},...}

#Add comment
curl -X POST http://localhost:3000/posts/1/comments \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-2-user" \
  -d '{"content": "Very practical article! I learned a lot"}'
# → 201 {"code":0,"data":{"id":4,"content":"A very practical article!...","author":{"name":"Bob"}},...}

# Keyword search
curl "http://localhost:3000/posts/list?page=1&limit=10&keyword=VextJS"
# → Only return articles whose title or content contains "VextJS"

# Open Prisma Studio (visual management database)
pnpm db:studio

Prisma Advanced Usage

Middleware (Soft Delete)

Prisma middleware can intercept queries and implement functions such as soft deletion:

//Add in src/plugins/database.ts
prisma.$use(async (params, next) => {
  // Soft deletion: delete operation is changed to update deletedAt
  if (params.action === "delete") {
    params.action = "update";
    params.args.data = { deletedAt: new Date() };
  }

  // Automatically filter deleted records when querying
  if (params.action === "findMany" || params.action === "findFirst") {
    if (!params.args.where) params.args.where = {};
    params.args.where.deletedAt = null;
  }

  return next(params);
});

Transaction

// interactive transaction
const result = await prisma.$transaction(async (tx) => {
  //Create user and initial article in transaction
  const user = await tx.user.create({
    data: { name: "Diana", email: "diana@example.com" },
  });

  const post = await tx.post.create({
    data: {
      title: "My first article",
      content: "Welcome to my blog!",
      authorId: user.id,
      published: true,
    },
  });

  return { user, post };
});

// Batch transactions (array form)
const [updatedUser, newPost] = await prisma.$transaction([
  prisma.user.update({
    where: { id: 1 },
    data: { name: "Alice Updated" },
  }),
  prisma.post.create({
    data: {
      title: "New Article",
      content: "content",
      authorId: 1,
    },
  }),
]);

Native SQL

// Complex statistical queries can use native SQL
const stats = await prisma.$queryRaw`
  SELECT
    u.name,
    COUNT(p.id) as post_count,
    COALESCE(SUM(p.view_count), 0) as total_views
  FROM users u
  LEFT JOIN posts p ON p.author_id = u.id AND p.published = 1
  GROUP BY u.id
  ORDER BY total_views DESC
  LIMIT 10
`;

Optimize query

// Use select to query only the required fields (reduce data transmission)
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    email: true,
    //Do not query large fields such as bio, avatarUrl, etc.
  },
});

// Use cursor for paging (more efficient than skip/take when the amount of data is large)
const posts = await prisma.post.findMany({
  take: 10,
  cursor: { id: lastPostId },
  skip: 1, // skip the cursor itself
  orderBy: { id: "desc" },
});

Test

// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
import type { TestApp } from "vextjs";

describe("User CRUD (Prisma)", () => {
  let testApp: TestApp;
  const AUTH = "Bearer user-1-admin";

  beforeEach(async () => {
    testApp = await createTestApp({
      plugins: true,
    });
  });

  afterEach(async () => {
    await testApp?.close();
  });

  it("GET /users/list should contain user statistics", async () => {
    const res = await testApp.request
      .get("/users/list")
      .query({ page: 1, limit: 10 });

    expect(res.status).toBe(200);
    expect(res.body.data.items[0]).toHaveProperty("_count");
    expect(res.body.data.items[0]._count).toHaveProperty("posts");
  });

  it("GET /users/:id should contain associated articles and tags", async () => {
    const res = await testApp.request.get("/users/1");expect(res.status).toBe(200);
    expect(Array.isArray(res.body.data.posts)).toBe(true);

    if (res.body.data.posts.length > 0) {
      expect(res.body.data.posts[0]).toHaveProperty("tags");
      expect(res.body.data.posts[0]).toHaveProperty("_count");
    }
  });

  it("POST /users should create users", async () => {
    const res = await testApp.request
      .post("/users")
      .set("Authorization", AUTH)
      .send({
        name: "Diana",
        email: "diana@example.com",
        bio: "new user",
      });

    expect(res.status).toBe(201);
    expect(res.body.data.name).toBe("Diana");
    expect(res.body.data.role).toBe("user");
    expect(res.body.data.createdAt).toBeDefined();
  });

  it("DELETE /users/:id should cascade delete articles and comments", async () => {
    const deleteRes = await testApp.request
      .delete("/users/1")
      .set("Authorization", AUTH);

    expect(deleteRes.status).toBe(204);

    // Confirm that the associated articles are also deleted
    const postsRes = await testApp.request
      .get("/posts/list")
      .query({ page: 1, limit: 100 });

    const alicePosts = postsRes.body.data.items.filter(
      (p: any) => p.author.id === 1,
    );
    expect(alicePosts).toHaveLength(0);
  });
});

describe("Articles and Comments (Prisma)", () => {
  let testApp: TestApp;
  const AUTH = "Bearer user-1-admin";

  beforeEach(async () => {
    testApp = await createTestApp({ plugins: true });
  });

  afterEach(async () => {
    await testApp?.close();
  });

  it("GET /posts/list should support tag filtering", async () => {
    const res = await testApp.request
      .get("/posts/list")
      .query({ page: 1, limit: 10, tag: "TypeScript" });

    expect(res.status).toBe(200);
    for (const post of res.body.data.items) {
      const tagNames = post.tags.map((t: any) => t.name);
      expect(tagNames).toContain("TypeScript");
    }
  });

  it("POST /posts/:id/comments should add comments", async () => {
    const res = await testApp.request
      .post("/posts/1/comments")
      .set("Authorization", AUTH)
      .send({ content: "Test Comment" });

    expect(res.status).toBe(201);
    expect(res.body.data.content).toBe("Test comment");
    expect(res.body.data.author).toHaveProperty("name");
  });

  it("GET /posts/:id should automatically increment the browsing count", async () => {
    const res1 = await testApp.request.get("/posts/1");
    const viewCount1 = res1.body.data.viewCount;

    const res2 = await testApp.request.get("/posts/1");
    const viewCount2 = res2.body.data.viewCount;

    expect(viewCount2).toBe(viewCount1 + 1);
  });
});

Project mode summary

HierarchyResponsibilitiesDocumentation
SchemaPrisma data model definitionprisma/schema.prisma
Seed dataInitial data fillingprisma/seed.ts
MigrationDatabase structure version managementprisma/migrations/
Plug-inInitialize PrismaClient, mount app.prisma, clean upsrc/plugins/database.ts
Service layerBusiness logic + Prisma querysrc/services/*.ts
Routing layerRequest verification + calling service + responsesrc/routes/*.ts
Type declarationapp.prisma type extensiontypes/vext.d.ts

Comparison with Drizzle solution

DimensionsPrismaDrizzle
Schema definition.prisma DSL fileTypeScript code
Type generationCode generation (prisma generate)Schema inference ($inferSelect)
Query APIAdvanced ORM API (include, nested writing)SQL-like API (close to native SQL)
Related queryinclude / select nestingRelations API / Manual JOIN
Migrationprisma migrate (declarative)drizzle-kit (declarative)
Transaction$transactiondb.transaction
Visualization ToolsPrisma StudioDrizzle Studio
PerformanceMedium (with query layer abstraction)High (directly compiled SQL)
Package sizeLarger (generated Client)Lightweight
Learning curveLow (rich documentation)Medium (requires SQL basics)

The integration modes of the two solutions in VextJS are exactly the same: Initialize connection through plug-in → Mount to app → Service layer encapsulation query → Routing layer orchestration call. Which one you choose depends on team preference and project needs.

Next step