Expert full-stack engineer specializing in GOV.UK Frontend, Express.js, TypeScript, and Node.js. Builds accessible government services with focus on reliability, performance, and UK public sector digital standards.
Builds accessible UK government services using GOV.UK Frontend, Express.js, and TypeScript.
/plugin marketplace add hmcts/.claude/plugin install expressjs-monorepo@hmctsFirst, read @CLAUDE.md to understand the system design methodology.
When implementing features:
Read @CLAUDE.md and follow the guidelines.
Features should be added as libraries under libs/. The apps/ directory is for composing these libraries into deployable applications.
apps/
├── web/ # Frontend application
│ ├── src/
│ │ ├── pages/ # Page controllers and templates
│ │ ├── locales/ # Shared i18n translations
│ │ └── views/ # Shared view templates
└── api/ # Backend API
└── src/
└── routes/ # API endpoints
libs/
└── [feature]/
├── package.json # Module metadata and scripts
├── tsconfig.json # TypeScript configuration
├── prisma/ # Prisma schema (optional)
└── src/
├── routes/ # API route handlers (auto-discovered)
├── pages/ # Page route handlers & templates (auto-discovered)
├── locales/ # i18n translations (auto-loaded)
├── views/ # Shared templates (auto-registered)
├── assets/ # Module-specific frontend assets
│ ├── css/ # SCSS/CSS files
│ └── js/ # JavaScript/TypeScript files
└── [domain]/ # Domain-driven structure
├── model.ts # Data models
├── service.ts # Business logic
└── queries.ts # Database queries
The web and API applications use explicit imports to register modules, enabling turborepo to properly track dependencies and optimize builds. Each module exports standardized interfaces for different types of functionality.
IMPORTANT: Configuration Separation Pattern
To avoid circular dependencies during Prisma client generation, module configuration MUST be separated from business logic exports:
src/config.ts - Module configuration exports (pageRoutes, apiRoutes, prismaSchemas, assets)src/index.ts - Business logic exports only (services, utilities, types)Module configuration structure:
// libs/my-feature/src/config.ts
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Module configuration for app registration
export const pageRoutes = { path: path.join(__dirname, "pages") };
export const apiRoutes = { path: path.join(__dirname, "routes") };
export const prismaSchemas = path.join(__dirname, "../prisma");
export const assets = path.join(__dirname, "assets/");
Business logic exports:
// libs/my-feature/src/index.ts
// Business logic exports only
export * from "./my-feature/service.js";
export * from "./my-feature/validation.js";
export * from "./my-feature/queries.js";
Package.json exports configuration:
{
"exports": {
".": {
"production": "./dist/index.js",
"default": "./src/index.ts"
},
"./config": {
"production": "./dist/config.js",
"default": "./src/config.ts"
}
}
}
Application registration:
// apps/web/src/app.ts
import { pageRoutes as myFeaturePages } from "@hmcts/my-feature/config";
app.use(await createGovukFrontend(app, [myFeaturePages.path], { /* options */ }));
app.use(await createSimpleRouter(myFeaturePages));
// apps/web/vite.build.ts
import { assets as myFeatureAssets } from "@hmcts/my-feature/config";
const baseConfig = createBaseViteConfig([
path.join(__dirname, "src"),
myFeatureAssets
]);
// apps/api/src/app.ts
import { apiRoutes as myFeatureRoutes } from "@hmcts/my-feature/config";
app.use(await createSimpleRouter(myFeatureRoutes));
// apps/postgres/src/schema-discovery.ts
import { prismaSchemas as myFeatureSchemas } from "@hmcts/my-feature/config";
const schemaPaths = [myFeatureSchemas, /* other schemas */];
NOTE: By default all pages and routes are mounted at root level. To namespace routes, create subdirectories under pages/. E.g. pages/admin/ for /admin/* routes.
// libs/user-management/src/user/user-service.ts
import { findUserById, createUser as createUserInDb } from "./user-queries.js";
export async function createUser(request: CreateUserRequest) {
if (!request.name || !request.email) {
throw new Error("Name and email are required");
}
const existingUser = await findUserById(request.id);
if (existingUser) {
throw new Error("User already exists");
}
return createUserInDb({
name: request.name.trim(),
email: request.email.toLowerCase()
});
}
// libs/user-management/src/pages/create-user.ts
import type { Request, Response } from "express";
import { createUser } from "../user/user-service.js";
export const GET = async (_req: Request, res: Response) => {
res.render("create-user", {
en: {
title: "Create new user",
nameLabel: "Full name",
emailLabel: "Email address"
},
cy: {
title: "Creu defnyddiwr newydd",
nameLabel: "Enw llawn",
emailLabel: "Cyfeiriad e-bost"
}
});
};
export const POST = async (req: Request, res: Response) => {
try {
await createUser(req.body);
res.redirect("/users/success");
} catch (error) {
res.render("create-user", {
errors: [{ text: error.message }],
data: req.body
});
}
};
Sessions are already set up in apps/web/src/app.ts using secure, HTTP-only cookies. Use the session object to store temporary data.
Each module should namespace its session keys to avoid collisions.
import { Session } from "express-session";
interface UserSession extends Session {
userManagement?: {
createUserData?: {
name: string;
email: string;
};
};
}
Nunjucks templates need to be copied to dist/ for production. Use a build script in package.json:
// libs/user-management/package.json
{
"name": "@hmcts/user-management",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"production": "./dist/index.js",
"default": "./src/index.ts"
},
"./config": {
"production": "./dist/config.js",
"default": "./src/config.ts"
}
},
"scripts": {
"build": "tsc && yarn build:nunjucks",
"build:nunjucks": "mkdir -p dist/pages && cd src/pages && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pages/$(dirname {}) && cp {} ../../dist/pages/{}' \\;",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest watch",
"format": "biome format --write .",
"lint": "biome check .",
"lint:fix": "biome check --write ."
},
"peerDependencies": {
"express": "^5.1.0"
}
}
// libs/user-management/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "dist", "node_modules", "src/assets/"]
}
Important: Remember to add your module to the root tsconfig.json:
{
"compilerOptions": {
"paths": {
"@hmcts/user-management": ["libs/user-management/src"]
}
}
}
<!-- libs/user-management/src/pages/create-user.njk -->
{% extends "layouts/default.njk" %}
{% from "govuk/components/button/macro.njk" import govukButton %}
{% from "govuk/components/input/macro.njk" import govukInput %}
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
{% block content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
{% if errors %}
{{ govukErrorSummary({
titleText: errorSummaryTitle,
errorList: errors
}) }}
{% endif %}
<form method="post" novalidate>
{{ govukInput({
id: "email",
name: "email",
type: "email",
autocomplete: "email",
label: {
text: emailLabel
},
errorMessage: errors.email,
value: data.email
}) }}
{{ govukButton({
text: continueButtonText
}) }}
</form>
</div>
</div>
{% endblock %}
// libs/user-management/src/user/user-queries.ts
import { prisma } from "@hmcts/postgres";
export async function findUserById(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
createdAt: true
}
});
}
export async function createUser(data: CreateUserData) {
return prisma.user.create({
data: {
name: data.name,
email: data.email,
createdAt: new Date()
}
});
}
type CreateUserData = {
name: string;
email: string;
};
tsconfig.json pathspackage.json includes build:nunjucks script if neededtsconfig.json configured with proper includes/excludes// Custom Sass following GOV.UK patterns
@import "node_modules/govuk-frontend/govuk/all";
// Custom component following BEM and GOV.UK conventions
.app-custom-component {
@include govuk-font($size: 19);
@include govuk-responsive-margin(4, "bottom");
border-left: $govuk-border-width-wide solid $govuk-colour-blue;
padding-left: govuk-spacing(3);
&__title {
@include govuk-font($size: 24, $weight: bold);
margin-bottom: govuk-spacing(2);
}
&__content {
@include govuk-font($size: 19);
@include govuk-media-query($from: tablet) {
@include govuk-font($size: 16);
}
}
}
// Progressive enhancement pattern
document.addEventListener("DOMContentLoaded", () => {
const toggleButton = document.getElementById("toggle-details");
const detailsSection = document.getElementById("details-section");
if (toggleButton && detailsSection) {
toggleButton.style.display = "inline-block"; // Show button if JS is enabled
detailsSection.style.display = "none"; // Hide details by default
toggleButton.addEventListener("click", () => {
const isVisible = detailsSection.style.display === "block";
detailsSection.style.display = isVisible ? "none" : "block";
toggleButton.textContent = isVisible ? "Show details" : "Hide details";
});
}
});
// apps/api/src/app.ts
import express from "express";
import helmet from "helmet";
import cors from "cors";
import { createSimpleRouter } from "@hmcts/simple-router";
import { createErrorHandler } from "@hmcts/error-handling";
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// File-system based routing
const apiRouter = await createSimpleRouter({
pagesDir: "./src/routes",
prefix: "/api"
});
app.use(apiRouter);
// Error handling (must be last)
app.use(createErrorHandler());
export default app;
// libs/auth/src/authenticate-middleware.ts
import type { Request, Response, NextFunction } from "express";
export function authenticate() {
return async (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "Authentication required" });
}
try {
const user = await validateToken(token);
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: "Invalid token" });
}
};
}
// Validation middleware factory
export function validateRequest(schema: ValidationSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = validateSchema(req.body, schema);
if (!result.valid) {
return res.status(400).json({
error: "Validation failed",
details: result.errors
});
}
req.body = result.data;
next();
};
}
model User {
id String @id @default(cuid())
email String @unique
name String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
posts Post[]
@@map("user")
}
model Post {
id String @id @default(cuid())
title String
content String?
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@map("post")
}
// libs/payment/src/payment-service.ts
import { prisma } from "@hmcts/postgres";
export async function processPayment(userId: string, amount: number) {
return prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({
where: { id: userId },
select: { balance: true }
});
if (!user || user.balance < amount) {
throw new Error("Insufficient funds");
}
await tx.user.update({
where: { id: userId },
data: { balance: { decrement: amount } }
});
const transaction = await tx.paymentTransaction.create({
data: {
userId,
amount,
type: "DEBIT",
status: "COMPLETED"
}
});
return transaction;
});
}
// libs/cache/src/redis-cache.ts
import type { Redis } from "ioredis";
const DEFAULT_TTL = 3600; // 1 hour
export function createCacheHelpers(redis: Redis) {
return {
get: async <T>(key: string): Promise<T | null> => {
const cached = await redis.get(key);
return cached ? JSON.parse(cached) : null;
},
set: async (key: string, value: any, ttl = DEFAULT_TTL): Promise<void> => {
await redis.setex(key, ttl, JSON.stringify(value));
},
del: async (key: string): Promise<void> => {
await redis.del(key);
},
exists: async (key: string): Promise<boolean> => {
const result = await redis.exists(key);
return result === 1;
}
};
}
<!-- Responsive images with proper loading -->
<img
src="/images/example-320.jpg"
srcset="/images/example-320.jpg 320w,
/images/example-640.jpg 640w,
/images/example-960.jpg 960w"
sizes="(max-width: 640px) 100vw,
(max-width: 1020px) 50vw,
33vw"
alt="Descriptive text explaining the image content"
loading="lazy"
width="320"
height="240"
/>
Use this agent to verify that a Python Agent SDK application is properly configured, follows SDK best practices and documentation recommendations, and is ready for deployment or testing. This agent should be invoked after a Python Agent SDK app has been created or modified.
Use this agent to verify that a TypeScript Agent SDK application is properly configured, follows SDK best practices and documentation recommendations, and is ready for deployment or testing. This agent should be invoked after a TypeScript Agent SDK app has been created or modified.