Create public (unauthenticated) API endpoints for existing Express.js + MongoDB resources. Generates list and detail controllers with aggregation pipelines, and wires them into the public routes file. Use this skill whenever the user wants to add a public endpoint, create a storefront/frontend API, expose a resource publicly, add a public route, or says things like "I need a public page for products" or "create a frontend API for articles" or "add a public list endpoint for reviews".
Creates public (unauthenticated) read endpoints for existing resources. Public endpoints live in controllers/public/ and routes/public.js, separate from admin CRUD. They often use aggregation pipelines, populate related data, and have different response shapes than admin endpoints.
| Aspect | Admin CRUD | Public Endpoints |
|---|---|---|
| Auth | Protected by middleware.authenticate | No auth (under /public/ prefix which gets speed limiter) |
| Location | controllers/{resource}/ | controllers/public/ |
| Routes |
routes/{resource}.js |
routes/public.js |
| Operations | Full CRUD | Read-only (GET) |
| Data shape | Direct model queries | Often aggregation pipelines with $lookup |
| Filtering | Admin filters | May add isActive: true or similar |
Read references/naming-guide.md for full details.
get-{resources}.js — lightweight list endpoint (for dropdowns, selectors, simple lists)view-page-{resources}.js — full list page with pagination, filtering, complex dataview-page-{resource}.js — single resource detail page (by slug, with populated relations)Gather from the user:
view-page-{resources} or get-{resources}view-page-{resource}Read references/public-patterns.md for complete examples.
controllers/public/get-{resources}.jsFor lightweight lists (dropdowns, simple lists). Uses basic .find():
import { {Resource} } from '@models';
export default async (req, res) => {
const documents = await {Resource}.find({ isActive: true }).select('name slug').lean();
return res.status(200).json(documents);
};
controllers/public/view-page-{resources}.jsFor paginated list pages with filtering. Uses aggregation pipeline:
import { applyFilters{Resource} } from '@filters';
import { {Resource} } from '@models';
export default async (req, res) => {
const page = parseInt(req.query.page, 10) || 1;
const perPage = parseInt(req.query.perPage, 10) || 10;
const filters = {
...applyFilters{Resource}(req.query),
isActive: true,
};
const totalCount = await {Resource}.countDocuments(filters);
const documents = await {Resource}.aggregate([
{ $match: filters },
// Add $lookup stages for related data if needed
{ $skip: (page - 1) * perPage },
{ $limit: perPage },
]);
const pageParams = {
count: totalCount,
hasNext: page * perPage < totalCount,
page,
perPage,
};
return res.status(200).json({ pageParams, pages: documents });
};
controllers/public/view-page-{resource}.jsFor single resource pages (by slug). Uses aggregation to populate relations:
import { error } from '@functions';
import { {Resource} } from '@models';
export default async (req, res) => {
const { slug } = req.params;
if (!slug) {
throw error(400, 'Slug is required');
}
const result = await {Resource}.aggregate([
{ $match: { slug } },
// Add $lookup stages for related data
// Add $addFields for computed fields
// Add $project to exclude temporary fields
]);
if (result.length === 0) {
throw error(404, '{Resource} not found');
}
return res.status(200).json(result[0]);
};
$lookup{
$lookup: {
from: '{collection_name}', // MongoDB collection name (usually plural + underscore)
localField: '{refField}._id',
foreignField: '_id',
as: '{refField}Data',
},
},
{
$addFields: {
{refField}: {
$mergeObjects: ['${refField}', { $arrayElemAt: ['${refField}Data', 0] }],
},
},
},
{
$project: {
{refField}Data: 0,
},
},
In controllers/public/index.js, add the new exports in A-Z order:
export { default as get{Resources} } from './get-{resources}';
export { default as viewPage{Resource} } from './view-page-{resource}';
export { default as viewPage{Resources} } from './view-page-{resources}';
In routes/public.js, add routes under a comment section for the resource:
// {Resources}
router.get('/public/get-{resources}', Public.get{Resources});
router.get('/public/view-page-{resources}', diacriticInsensitive(['search']), Public.viewPage{Resources});
router.get('/public/view-page-{resource}/:slug', Public.viewPage{Resource});
Add diacriticInsensitive(['search']) to list endpoints that support text search.
/public/ prefixisActive: true filter when the model has an isActive field'{Resource} not found', 'Slug is required'references/public-patterns.md — Real public controller examples from the codebasereferences/naming-guide.md — When to use get-X vs view-page-X naming