npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@alis-kit/template-be

v0.3.0

Published

Base Template Rest API — Scaffold REST API with Fastify + Mongoose + Zod

Readme

alis-kit-template-be

Base template REST API — Fastify + Mongoose + Zod + JWT, dibangun di atas @alis-kit/mongoose dan @alis-kit/routers.

npm version license


Table of Contents


Quick Start

npx @alis-kit/template-be
cd my-project
npm run dev

With Bun:

bunx @alis-kit/template-be
cd my-project
bun run dev:bun

Manual Setup

git clone <repo> my-project
cd my-project
npm install
cp .env.example .env
# edit .env with your config
npm run dev

Scripts

| npm | bun | Description | |-----|-----|-------------| | npm run dev | bun run dev:bun | Development with watch | | npm run build | bun run build:bun | Build for production | | npm start | bun run start:bun | Start production server | | npm run typecheck | bun run typecheck | TypeScript check | | npm run lint | bun run lint | Lint source | | npm run format | bun run format | Format source with Prettier | | npm test | bun run test | Run the test suite (Vitest) |


Tech Stack

  • Runtime: Node.js >=20 / Bun >=1.0
  • Framework: Fastify v5
  • ODM: @alis-kit/mongoose (Mongoose v9 wrapper — decorator-based schema & repository)
  • Validation: Zod v4
  • Decorators / Routing: @alis-kit/routers v3

Project Structure

Layers are grouped by type, then by domain. Files are named <domain>.<layer>.ts.

my-project/
├── src/
│   ├── schemas/            # @Schema-decorated entities (one folder per domain)
│   │   ├── index.ts        # Barrel — imported by app.ts so entities get registered
│   │   └── user/           # user, user.profile, user.token
│   ├── repositories/       # @Repository data access + *.query.ts (pure query builders)
│   │   └── user/
│   ├── models/
│   │   ├── types/<domain>/ # Zod: request payloads (IUser) & documents (IUserSchema)
│   │   ├── enums/<domain>/ # Domain enums (role, gender, ...)
│   │   └── filter/         # Zod search filters per collection
│   ├── controllers/        # @RestController-decorated route handlers
│   ├── config/             # env.ts, database.ts, server.ts
│   ├── utils/              # Small cross-domain helpers
│   ├── __tests__/          # *.spec.ts
│   ├── app.ts              # Bootstrap: register entities → connect DB → serve
│   └── polyfill.ts         # Symbol.metadata polyfill for decorators
├── .env.example
├── package.json
└── tsconfig.json

Two conventions worth knowing before you add code:

  • Imports use the @/ alias and keep the .js extension (NodeNext), e.g. import User from "@/schemas/user/user.schema.js". tsc-alias resolves it on build; vitest.config.ts maps it for tests.
  • A @Schema class only registers when its file is imported. Add every new entity to src/schemas/index.ts, which app.ts imports on startup.

Environment Variables

# .env.example
# ─── Runtime ─────────────────────────────────────────────────────────
NODE_ENV=development
PORT=5000
HOST=0.0.0.0
LOG_LEVEL=info

# ─── CORS ────────────────────────────────────────────────────────────
# Comma-separated allowlist untuk production. Dev boleh "*".
CORS_ORIGIN=*
....

look in file .env.example


Defining Entities (@alis-kit/mongoose)

Entities are defined with @Schema, validated at runtime with Zod, and exposed through a @Repository class. See the @alis-kit/mongoose docs for the full API (relations, TTL, soft delete, indexes).

Field names are stored verbatim. The generated Mongoose schema is strict: false and only registers the BaseEntity fields, so a property named userId is stored as userId — there is no camelCase → snake_case mapping. @Index, @Relation({ foreignField }), and SearchCustom.of() must all use the exact same spelling, otherwise you get an index on a field that does not exist and relations that always resolve to null.

// src/schemas/user/user.schema.ts — the entity (collection definition)
import { BaseEntity, Index, Relation, Schema } from "@alis-kit/mongoose";
import { type IUserProfileSchema } from "@/models/types/user/user.profile.type.js";

@Index({ email: 1 }, { unique: true })
@Schema({ collection: "users", timestamps: true, idStrategy: "uuid" })
export default class User extends BaseEntity {
  email!: string;
  password!: string;
  isActive!: boolean;

  // Inverse one-to-one: the profile holds `userId`, so the join runs
  // users._id → user_profiles.userId.
  @Relation({ collection: "user_profiles", localField: "_id", foreignField: "userId" })
  profile!: IUserProfileSchema | null;
}
// src/models/types/user/user.type.ts — Zod shape of the stored document
import { z } from "zod";
import { BaseEntitySchema } from "@alis-kit/mongoose";

export const IUserSchema = BaseEntitySchema.extend({
  email: z.string(),
  password: z.string(),
  isActive: z.boolean(),
});
export type IUserSchema = z.infer<typeof IUserSchema>;
// src/repositories/user/user.repository.ts — data access
import { BaseRepository, Repository } from "@alis-kit/mongoose";
import User from "@/schemas/user/user.schema.js";
import { type IUserSchema } from "@/models/types/user/user.type.js";

@Repository(User)
export default class UserRepository extends BaseRepository<IUserSchema> {}

Database Layer — What's Included

The template ships with a working user domain you can copy for your own domains. It covers the data layer only — services, routes, and password hashing are intentionally left to you.

| Collection | Entity | Holds | |---|---|---| | users | schemas/user/user.schema.ts | Login credentials (email, password hash, isActive) | | user_profiles | schemas/user/user.profile.schema.ts | Personal data, one document per user | | user_tokens | schemas/user/user.token.schema.ts | One document per active refresh-token session |

const users = new UserRepository();

// Paginated list. `profile` is joined automatically (eager relation);
// the password hash is never included.
const page = await users.search({ email: "budi", isActive: true }, Pageable.of(1, 10));

// The only read that carries the password hash — for the login flow.
const account = await users.findByEmailForAuth("[email protected]");

// Searching by name goes through the profile repository (see note below).
const profiles = new UserProfileRepository();
await profiles.search({ keyword: "budi", role: UserRole.ADMIN }, Pageable.of(1, 10));

// Sessions: issue on login, revoke on logout, revoke all on password change.
const sessions = new UserTokenRepository();
await sessions.issue(user._id, rawRefreshToken, expiresAt);
await sessions.revokeAllByUser(user._id);

Conventions this layer follows — worth keeping when you add a domain:

  • Query building lives in *.query.ts as pure functions, separate from the repository. They are testable without a database, and filters that are undefined are skipped — passing them through would produce { field: undefined } and silently match nothing.
  • Search by a child collection's fields runs on that collection. The kit's OPERATION_JOIN_* operations only join forward (a local field holding the target's _id), so an inverse relation like user → profile cannot be filtered from the users side. Name search therefore lives in UserProfileRepository, where the fields are local and indexed.
  • Secrets stay out of reads. Every UserRepository read projects USER_PUBLIC_FIELDS; refresh tokens are stored as a SHA-256 hash, access tokens are not stored at all, and the session relation is lazy so it never rides along with a user read.
  • Expiry and deletion are built in. expireAt (from BaseEntity) has a TTL index, so expired sessions clean themselves up; logout is a soft delete.

Entity files can't be imported from tests — Vitest's bundled esbuild does not transform TC39 decorators yet. Test the *.query.ts modules, filters, and utils; verify decorator wiring against dist/ after npm run build.


Building Controllers (@alis-kit/routers)

Controllers use TC39 native decorators to define routes, request validation, and Swagger metadata. See the @alis-kit/routers docs for the full API (auth, cookies, exceptions, response envelopes).

// src/controllers/user.controller.ts
import { z } from "zod";
import {
  RestController, Authentication, Tag, Description,
  GetMapping, PostMapping,
  ReqBody, ReqQuery, ReqParam,
  Response, HttpStatus,
} from "@alis-kit/routers";
import { Pageable } from "@alis-kit/mongoose";
import UserRepository from "@/repositories/user/user.repository.js";

const CreateUserSchema = z.object({
  firstName: z.string().min(1),
  lastName: z.string().min(1),
  email: z.email(), // Zod v4 — not z.string().email()
  password: z.string().min(8),
});

const PaginationSchema = z.object({
  page: z.coerce.number().default(1),
  size: z.coerce.number().default(10),
});

@RestController("/users")
@Authentication("auth")
@Tag("User Management")
@Description("User management CRUD")
export class UserController {
  private userRepo = new UserRepository();

  @GetMapping("/")
  @Description("List users with pagination")
  @ReqQuery(PaginationSchema)
  @Response(200, "List of users")
  async getAll(query: z.infer<typeof PaginationSchema>) {
    return this.userRepo.findAll(undefined, Pageable.of(query.page, query.size));
  }

  @GetMapping("/:id")
  @Description("Get user detail by ID")
  @ReqParam("id")
  @Response(200, "User detail")
  @Response(404, "User not found")
  async getById(id: string) {
    return this.userRepo.findById(id);
  }

  @PostMapping("/")
  @HttpStatus(201)
  @ReqBody(CreateUserSchema)
  @Response(201, "User created successfully")
  async create(body: z.infer<typeof CreateUserSchema>) {
    return this.userRepo.save(body, { actorId: "system" });
  }
}

Putting It Together — Full Example

The template already wires this up across three files, so adding a domain means touching two of them:

// src/app.ts — registers entities, connects to MongoDB, then serves
import { env } from "@/config/env.js";
import Server from "@/config/server.js";
import { ConnectDatabase, DisconnectDatabase } from "@/config/database.js";
import "@/schemas/index.js"; // ← every entity must be imported to be registered

async function bootstrap(): Promise<void> {
  await ConnectDatabase();
  const app = await Server();
  await app.listen({ host: env.HOST, port: env.PORT });
  // ... graceful shutdown on SIGTERM/SIGINT
}

void bootstrap();
// src/config/server.ts — Fastify, plugins, error handler, route registration
RouterKit.setup({
  framework: "fastify",
  app,
  responseEnvelope: "raw",
  globalPrefix: "/api",
});

RouterKit.register(HealthController /*, UserController, ... */);
RouterKit.handleNotFound();

So a new domain is: add the entity to src/schemas/index.ts, and add the controller to RouterKit.register(...).


Authentication (JWT)

Register an auth middleware with RouterKit.setup() so routes marked with @Authentication("auth") are protected automatically:

import jwt from "jsonwebtoken";

RouterKit.setup({
  framework: "fastify",
  app,
  authMiddleware: async (req) => {
    const token = req.headers.authorization?.replace("Bearer ", "");
    if (!token) throw new Error("Missing token");
    return jwt.verify(token, env.JWT_ACCESS_SECRET);
  },
});

Issue tokens on login (e.g. inside an AuthController), and record the refresh token as a session so it can be revoked later:

import jwt from "jsonwebtoken";
import { env } from "@/config/env.js";
import UserTokenRepository from "@/repositories/user/user.token.repository.js";

const accessToken = jwt.sign({ sub: user._id, email: user.email }, env.JWT_ACCESS_SECRET, {
  expiresIn: env.JWT_ACCESS_TTL,
});

const refreshToken = jwt.sign({ sub: user._id }, env.JWT_REFRESH_SECRET, {
  expiresIn: env.JWT_REFRESH_TTL,
});

// Stored as a SHA-256 hash; the raw token only ever lives in the httpOnly cookie.
await new UserTokenRepository().issue(user._id, refreshToken, expiresAt);

Password hashing is not included — hash with argon2 or bcrypt in your service layer before calling save(). utils/hash.util.ts is for refresh tokens only, never for passwords.


License

MIT