@statsbygg/generators
v0.1.16
Published
Boilerplate generation scripts for Statsbygg projects
Readme
@statsbygg/generators
Interactive code generators for scaffolding components, forms, API services, state management, and routes in Statsbygg applications.
Overview
@statsbygg/generators is a collection of CLI tools that automate the creation of boilerplate code. Instead of manually creating files, importing dependencies, and writing repetitive code, these generators handle it for you while enforcing Statsbygg's best practices.
Philosophy:
- Golden Path: Generators produce opinionated, production-ready code
- Consistency: All generated code follows the same patterns and structure
- Speed: Scaffold complex features in seconds, not hours
- Flexibility: Customize generated code through interactive prompts
Installation
This package is automatically included in projects created with @statsbygg/create-app. If you need to add it manually:
npm install --save-dev @statsbygg/generatorsUsage
All generators are available as npm scripts in your package.json:
npm run gen:<generator-name>Each generator provides an interactive CLI that guides you through the creation process.
Available Generators
1. gen:component - Component Generator
Scaffolds a new React component with TypeScript, CSS modules, tests, and Storybook stories.
Command
npm run gen:componentOr with a specific name:
npm run gen:component MyButtonInteractive Prompts
- Component name: PascalCase (e.g.,
UserCard,SearchBar) - Component type:
ui- Reusable UI component (e.g., buttons, inputs)feature- Feature-specific component
- Feature name (if type is
feature): e.g.,userManagement,dashboard - Nested path (optional): Subdirectory within the feature (e.g.,
forms,modals) - Files to create:
- Component file (.tsx) - Always included
- Styles (.module.css)
- Types (.types.ts)
- Tests (.test.tsx)
- Stories (.stories.tsx)
- Next.js integration options:
- State management (Zustand)
- API integration (TanStack Query)
- Client component
Generated Structure
For UI Component:
src/components/ui/MyButton/
├── MyButton.tsx # Main component
├── MyButton.module.css # CSS module
├── MyButton.types.ts # TypeScript types
├── MyButton.test.tsx # Vitest tests
├── MyButton.stories.tsx # Storybook stories
└── index.ts # Barrel exportFor Feature Component:
src/components/features/userManagement/forms/UserForm/
├── UserForm.tsx
├── UserForm.module.css
├── UserForm.types.ts
├── UserForm.test.tsx
├── UserForm.stories.tsx
└── index.tsGenerated Code Example
// MyButton.tsx
import { MyButtonProps } from './MyButton.types';
import styles from './MyButton.module.css';
export function MyButton(props: MyButtonProps) {
return (
<div className={styles.container}>
MyButton
</div>
);
}Index File Updates
The generator automatically updates:
/src/components/ui/index.ts(for UI components)/src/components/features/{featureName}/index.ts(for feature components)
2. gen:route - App Router Route Generator
Creates Next.js App Router pages with special files (layout, loading, error, not-found).
Command
npm run gen:routeOr specify the route path directly:
npm run gen:route dashboard
npm run gen:route admin/users
npm run gen:route api/usersInteractive Prompts
- Route path: e.g.,
about,dashboard,api/users - Files to create:
- Page (page.tsx or route.ts for API routes)
- Styles (page.module.css)
- Loading (loading.tsx)
- Error (error.tsx)
- Not Found (not-found.tsx)
- Layout (layout.tsx)
- Template (template.tsx)
- Client component? (for page routes only)
Generated Structure
For Standard Page:
src/app/dashboard/
├── page.tsx # Main page component
├── page.module.css # Page-specific styles
├── loading.tsx # Loading UI (Suspense fallback)
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── layout.tsx # Layout wrapper
└── template.tsx # Template with animationsFor API Route:
src/app/api/users/
└── route.ts # API endpoint handlersGenerated Code Example
// page.tsx (Server Component)
import { Heading } from '@digdir/designsystemet-react';
import styles from './page.module.css';
export default function DashboardPage() {
return (
<div className={styles.container}>
<Heading level={1}>Dashboard</Heading>
<p>This is the dashboard page.</p>
</div>
);
}// error.tsx (Client Component)
'use client';
import { Button, Heading } from '@digdir/designsystemet-react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import styles from './error.module.css';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
return (
<div className={styles.container}>
<AlertTriangle className={styles.icon} />
<Heading level={1}>Something went wrong</Heading>
<Button onClick={reset}>
<RefreshCw /> Try again
</Button>
</div>
);
}// route.ts (API Route)
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
return NextResponse.json({ message: 'GET users' });
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
return NextResponse.json({ message: 'POST users', data: body });
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}3. gen:api - API Service Generator
Creates type-safe API request functions and TanStack Query hooks for data fetching.
Command
npm run gen:apiOr specify the API name:
npm run gen:api usersInteractive Prompts
- API service name: camelCase (e.g.,
users,products,newsArticles) - API endpoint path: e.g.,
/users,/api/products(defaults to kebab-case of name) - Select endpoints:
- Get List (GET collection)
- Get Single (GET by ID)
- Create (POST)
- Update (PUT)
- Delete (DELETE)
- Requires authentication?: Yes/No
Generated Structure
src/lib/api/
├── requests/
│ └── users.ts # Request functions (get, post, put, delete)
├── hooks/
│ └── users.ts # TanStack Query hooks
└── index.ts # Updated with exports
src/types/api/
└── users.ts # TypeScript interfacesGenerated Code Example
Request Functions (requests/users.ts):
import { getRequest, postRequest, putRequest, deleteRequest } from '../requests';
import {
GetUsersListResponse,
GetUsersByIdResponse,
CreateUsersRequest,
CreateUsersResponse,
} from '@/types/api/users';
const ENDPOINT = '/users';
async function getUsersList(): Promise<GetUsersListResponse> {
const response = await getRequest(ENDPOINT);
return response.json();
}
async function getUsersById(id: string): Promise<GetUsersByIdResponse> {
const response = await getRequest(`${ENDPOINT}/${id}`);
return response.json();
}
async function createUsers(data: CreateUsersRequest): Promise<CreateUsersResponse> {
const response = await postRequest(ENDPOINT, data);
return response.json();
}
export { getUsersList, getUsersById, createUsers };TanStack Query Hooks (hooks/users.ts):
import { useQuery, useMutation } from '@tanstack/react-query';
import { getUsersList, getUsersById, createUsers } from '../requests/users';
import {
GetUsersListResponse,
GetUsersByIdResponse,
CreateUsersRequest,
CreateUsersResponse,
} from '@/types/api/users';
export function useGetUsersList(options?: any) {
return useQuery<GetUsersListResponse>({
queryKey: ['users'],
queryFn: () => getUsersList(),
...options,
});
}
export function useGetUsersById(id: string, options?: any) {
return useQuery<GetUsersByIdResponse>({
queryKey: ['users', id],
queryFn: () => getUsersById(id),
enabled: !!id,
...options,
});
}
export function useCreateUsers(options?: any) {
return useMutation<CreateUsersResponse, Error, CreateUsersRequest>({
mutationFn: (data: CreateUsersRequest) => createUsers(data),
...options,
});
}TypeScript Types (types/api/users.ts):
export interface User {
id: string;
// TODO: Define your User entity type
}
export interface GetUsersListResponse {
data: User[];
total: number;
page?: number;
limit?: number;
}
export interface GetUsersByIdResponse extends User {
// TODO: Add any additional fields
}
export interface CreateUsersRequest {
// TODO: Define request type
}
export interface CreateUsersResponse extends User {
// TODO: Add any additional fields
}Usage Example
'use client';
import { useGetUsersList, useCreateUsers } from '@/lib/api';
export function UserList() {
const { data, isLoading, error } = useGetUsersList();
const createUser = useCreateUsers();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<ul>
{data?.data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button
onClick={() => createUser.mutate({ name: 'John Doe' })}
>
Add User
</button>
</div>
);
}4. gen:state - State Management Generator
Creates Zustand stores with different patterns (list management, UI state, API integration).
Command
npm run gen:stateInteractive Prompts
- State name: camelCase (e.g.,
userList,appSettings,searchFilters) - Generation mode:
- Guided Setup (interactive pattern selection)
- Schema-based (use a state schema file)
- State pattern (for guided setup):
- List Management (arrays with filtering, sorting, CRUD)
- UI State (simple toggles, forms, modals)
- API Integration (loading/error/data states)
- Features (based on pattern):
- List: Filtering, Sorting, Pagination, Persistence
- UI: Persistence, Reset Actions
- API: Caching, Optimistic Updates
Generated Structure
src/lib/store/
├── useUserListStore.ts # Zustand store
└── useUserList.ts # Optional: API integration hookGenerated Code Example
List Management Store:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface UserListState {
// Data
items: unknown[];
filters: Record<string, unknown>;
filteredItems: unknown[];
sortBy: string;
sortOrder: 'asc' | 'desc';
// Actions
setItems: (items: unknown[]) => void;
addItem: (item: unknown) => void;
updateItem: (id: string, updates: Partial<unknown>) => void;
removeItem: (id: string) => void;
clearItems: () => void;
setFilter: (key: string, value: unknown) => void;
clearFilter: (key?: string) => void;
setSortBy: (field: string, order?: 'asc' | 'desc') => void;
}
const initialState = {
items: [],
filters: {},
filteredItems: [],
sortBy: '',
sortOrder: 'asc' as const,
};
export const useUserListStore = create<UserListState>()(
immer((set, get) => ({
...initialState,
setItems: (items) => set((state) => {
state.items = items;
state.filteredItems = filterItems(items, state.filters);
}),
addItem: (item) => set((state) => {
state.items.push(item);
state.filteredItems = filterItems(state.items, state.filters);
}),
setFilter: (key, value) => set((state) => {
if (value === null || value === undefined || value === '') {
delete state.filters[key];
} else {
state.filters[key] = value;
}
state.filteredItems = filterItems(state.items, state.filters);
}),
// ... other actions
}))
);
// Selectors
export const useUserListItems = () => useUserListStore((state) => state.items);
export const useUserListFilteredItems = () => useUserListStore((state) => state.filteredItems);
// Helper function
function filterItems(items: unknown[], filters: Record<string, unknown>): unknown[] {
if (Object.keys(filters).length === 0) return items;
return items.filter((item: any) => {
return Object.entries(filters).every(([key, value]) => {
if (!value) return true;
const itemValue = item[key];
if (typeof itemValue === 'string' && typeof value === 'string') {
return itemValue.toLowerCase().includes(value.toLowerCase());
}
return itemValue === value;
});
});
}UI State Store:
interface AppSettingsState {
value: unknown;
setValue: (value: unknown) => void;
reset: () => void;
}
const initialState = { value: null };
export const useAppSettingsStore = create<AppSettingsState>()(
immer((set) => ({
...initialState,
setValue: (value) => set((state) => {
state.value = value;
}),
reset: () => set(initialState),
}))
);Usage Example
'use client';
import { useUserListStore, useUserListFilteredItems } from '@/lib/store/useUserListStore';
export function UserList() {
const filteredUsers = useUserListFilteredItems();
const { setFilter, addItem } = useUserListStore();
return (
<div>
<input
type="text"
placeholder="Search users..."
onChange={(e) => setFilter('name', e.target.value)}
/>
<ul>
{filteredUsers.map((user: any) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button onClick={() => addItem({ id: '123', name: 'New User' })}>
Add User
</button>
</div>
);
}5. gen:schema - Form Schema Generator
Creates form schemas using a fluent DSL. These schemas are used by the gen:form generator.
Command
npm run gen:schemaInteractive Prompts
- Form name: PascalCase (e.g.,
ContactForm,UserRegistrationForm) - Features:
- Validation (Zod)
- Auto-save drafts
- Advanced state management
- File upload
- Storybook stories
- Testing
- Number of sections: How many groups of fields (1-5)
- For each section:
- Section name (camelCase)
- Number of fields (1-10)
- For each field:
- Field name (camelCase)
- Field type (string, email, number, textarea, select, checkbox, boolean, files, any)
- Configuration (required, validation rules, options, etc.)
Generated Structure
schemas/
└── contact-form.schema.js # Form schema definitionGenerated Schema Example
/**
* Form Schema: ContactForm
* This schema is designed to be loaded by the @statsbygg/generators tool.
*/
module.exports = defineFormSchema({
name: 'ContactForm',
features: ['validation', 'testing'],
translationNamespace: 'contactForm',
fields: {
firstName: field.string()
.min(2, 'validation.string.min')
.max(50, 'validation.string.max')
.required(),
lastName: field.string()
.min(2, 'validation.string.min')
.max(50, 'validation.string.max')
.required(),
email: field.email()
.required(),
message: field.textarea()
.rows(6)
.min(10, 'validation.string.min')
.required(),
contactMethod: field.select({
options: [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'mail', label: 'Mail' },
]
}).required(),
agreeToTerms: field.boolean()
.required(),
},
sections: [
{
name: 'personalInfo',
title: 'contactForm.sections.personalInfo',
fields: ['firstName', 'lastName', 'email']
},
{
name: 'messageDetails',
title: 'contactForm.sections.messageDetails',
fields: ['message', 'contactMethod', 'agreeToTerms']
}
],
});Field API Reference
String Fields:
field.string()
.min(2, 'Custom error message')
.max(100)
.pattern(/^[A-Z]/, 'Must start with capital letter')
.required()Email Fields:
field.email()
.required()Number Fields:
field.number()
.min(0)
.max(100)
.step(0.1)
.integer('Must be a whole number')
.required()Textarea Fields:
field.textarea()
.rows(6)
.min(10)
.max(500)
.required()Select Fields:
field.select({
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]
})
.required()
.searchable() // Enable search/filter
.multiple() // Allow multiple selectionFile Upload Fields:
field.files()
.accept(['.pdf', '.doc', '.docx'])
.maxFiles(3)
.maxSize('10MB')
.multiple()
.required()Boolean/Checkbox Fields:
field.boolean()
.required()6. gen:form - Form Component Generator
Generates production-ready form components with React Hook Form, Zod validation, and DDDS components.
Command
npm run gen:formOr generate directly from a schema file:
npm run gen:form schemas/contact-form.schema.jsInteractive Prompts
- Select schema file: Choose from available
.schema.jsfiles in/schemas - Select feature: Choose which feature this form belongs to
- Nested path (optional): Subdirectory within the feature (e.g.,
forms)
Generated Structure
src/components/features/contact/forms/ContactForm/
├── ContactForm.tsx # Main form component
├── ContactForm.module.css # Styles
├── ContactForm.types.ts # TypeScript types
├── contactFormSchema.ts # Zod schema
├── ContactForm.test.tsx # Vitest tests
├── ContactForm.stories.tsx # Storybook stories
├── useContactForm.ts # Custom hook (if features include autoSave)
├── contactFormStore.ts # Zustand store (if features include persistence)
└── index.ts # Barrel export
src/lib/locales/no/forms/
└── contactForm.json # Norwegian translations
src/lib/locales/en/forms/
└── contactForm.json # English translationsGenerated Form Component Example
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Card, Spinner, Textfield, Select } from '@digdir/designsystemet-react';
import { useTranslation } from '@/lib/i18n';
import clsx from 'clsx';
import { contactFormSchema } from './contactFormSchema';
import { ContactFormData } from './ContactForm.types';
import styles from './ContactForm.module.css';
interface ContactFormProps {
onSubmit: (data: ContactFormData) => void;
defaultValues?: Partial<ContactFormData>;
className?: string;
isLoading?: boolean;
}
export function ContactForm({
onSubmit,
defaultValues,
className,
isLoading = false
}: ContactFormProps) {
const { t } = useTranslation('contactForm');
const form = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
defaultValues,
});
const onFormSubmit = async (data: ContactFormData) => {
try {
await onSubmit(data);
} catch (error) {
console.error('Form submission failed:', error);
}
};
if (isLoading || form.formState.isSubmitting) {
return (
<Card className={clsx(styles.form, className)}>
<div className={styles.loadingContainer}>
<Spinner size="large" />
<p>{t('actions.loading')}</p>
</div>
</Card>
);
}
return (
<Card className={clsx(styles.form, className)}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className={styles.formContainer}>
{/* Personal Info Section */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('sections.personalInfo')}</h2>
<div className={styles.fieldGrid}>
<div className={styles.field}>
<Textfield
type="text"
label={t('fields.firstName.label')}
placeholder={t('fields.firstName.placeholder')}
error={form.formState.errors.firstName?.message}
required={true}
{...form.register('firstName')}
/>
</div>
<div className={styles.field}>
<Textfield
type="text"
label={t('fields.lastName.label')}
placeholder={t('fields.lastName.placeholder')}
error={form.formState.errors.lastName?.message}
required={true}
{...form.register('lastName')}
/>
</div>
<div className={styles.field}>
<Textfield
type="email"
label={t('fields.email.label')}
placeholder={t('fields.email.placeholder')}
error={form.formState.errors.email?.message}
required={true}
{...form.register('email')}
/>
</div>
</div>
</section>
{/* Message Details Section */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('sections.messageDetails')}</h2>
<div className={styles.fieldGrid}>
<div className={styles.field}>
<Textfield
as="textarea"
label={t('fields.message.label')}
placeholder={t('fields.message.placeholder')}
rows={6}
error={form.formState.errors.message?.message}
required={true}
{...form.register('message')}
/>
</div>
<div className={styles.field}>
<Select
label={t('fields.contactMethod.label')}
error={form.formState.errors.contactMethod?.message}
required={true}
{...form.register('contactMethod')}
>
<option value="">{t('common.select_option')}</option>
<option value="email">{t('contactMethodOptions.email')}</option>
<option value="phone">{t('contactMethodOptions.phone')}</option>
<option value="mail">{t('contactMethodOptions.mail')}</option>
</Select>
</div>
<div className={styles.field}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
className={styles.checkbox}
{...form.register('agreeToTerms')}
/>
{t('fields.agreeToTerms.label')}
</label>
</div>
</div>
</section>
{/* Submit Actions */}
<div className={styles.actions}>
<Button
type="submit"
size="large"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<>
<Spinner size="small" />
{t('actions.submitting')}
</>
) : (
t('actions.submit')
)}
</Button>
<Button
type="button"
variant="secondary"
size="large"
onClick={() => window.history.back()}
>
{t('actions.cancel')}
</Button>
</div>
</form>
</Card>
);
}Generated Zod Schema Example
import { z } from 'zod';
export const contactFormSchema = z.object({
firstName: z.string().min(2, 'validation.string.min').max(50, 'validation.string.max'),
lastName: z.string().min(2, 'validation.string.min').max(50, 'validation.string.max'),
email: z.string().email(),
message: z.string().min(10, 'validation.string.min'),
contactMethod: z.enum(['email', 'phone', 'mail']),
agreeToTerms: z.boolean(),
});
export type ContactFormData = z.infer<typeof contactFormSchema>;Generated Translation Files
Norwegian (no/forms/contactForm.json):
{
"sections": {
"personalInfo": "Personlig informasjon",
"messageDetails": "Melding"
},
"fields": {
"firstName": {
"label": "Fornavn",
"placeholder": "Skriv inn fornavn"
},
"lastName": {
"label": "Etternavn",
"placeholder": "Skriv inn etternavn"
},
"email": {
"label": "E-post",
"placeholder": "[email protected]"
},
"message": {
"label": "Melding",
"placeholder": "Skriv din melding her..."
},
"contactMethod": {
"label": "Foretrukket kontaktmetode"
},
"agreeToTerms": {
"label": "Jeg godtar vilkårene"
}
},
"contactMethodOptions": {
"email": "E-post",
"phone": "Telefon",
"mail": "Post"
},
"validation": {
"string": {
"required": "Dette feltet er påkrevd",
"min": "For kort",
"max": "For langt"
}
},
"actions": {
"submit": "Send inn",
"submitting": "Sender...",
"cancel": "Avbryt",
"loading": "Laster..."
},
"common": {
"select_option": "Velg et alternativ..."
}
}Usage Example
// In a page or parent component
import { ContactForm } from '@/components/features/contact';
export default function ContactPage() {
const handleSubmit = async (data) => {
console.log('Form data:', data);
// Send to API
};
return (
<div>
<h1>Contact Us</h1>
<ContactForm onSubmit={handleSubmit} />
</div>
);
}7. i18n:update - i18n Configuration Updater
Automatically scans translation files and updates the i18n configuration.
Command
npm run i18n:updateWhat It Does
- Scans
/src/lib/locales/for all JSON translation files - Generates import statements for each file
- Builds the resource structure for i18next
- Updates
/src/lib/i18n/config.tswith all translations
When to Use
Run this command whenever you:
- Add a new translation file
- Add a new locale directory
- Reorganize translation files
Before Running
src/lib/locales/
├── no/
│ ├── common.json
│ └── forms/
│ └── contactForm.json
└── en/
├── common.json
└── forms/
└── contactForm.jsonAfter Running
// config.ts (auto-generated)
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import commonNo from '../locales/no/common.json';
import formsContactFormNo from '../locales/no/forms/contactForm.json';
import commonEn from '../locales/en/common.json';
import formsContactFormEn from '../locales/en/forms/contactForm.json';
const resources = {
no: {
common: commonNo,
forms: {
contactForm: formsContactFormNo
}
},
en: {
common: commonEn,
forms: {
contactForm: formsContactFormEn
}
},
};
i18n.use(initReactI18next).init({
resources,
lng: 'no',
fallbackLng: 'no',
defaultNS: 'common',
ns: ['common', 'forms.contactForm'],
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
export default i18n;Advanced Usage
Generator Options via CLI
Most generators support passing options directly:
# Component generator
npm run gen:component MyButton -- --type=ui --no-tests
# API generator
npm run gen:api users -- --auth=true --endpoints=list,single,createCustom Templates
You can override default templates by creating a .generators/ directory in your project root:
.generators/
└── templates/
└── component/
└── component.tsx.hbsGenerator Hooks
Add pre/post generation hooks in .generators/hooks.js:
module.exports = {
beforeGenerate: async (type, options) => {
console.log(`Generating ${type}...`);
},
afterGenerate: async (type, files) => {
console.log(`Created ${files.length} files`);
},
};Troubleshooting
Generator Fails with "Permission Denied"
Run with elevated permissions:
sudo npm run gen:componentOr fix permissions on your node_modules:
sudo chown -R $(whoami) node_modulesGenerated Files Not Updating in VS Code
Reload the TypeScript server: Cmd+Shift+P → "TypeScript: Restart TS Server"
Import Paths Not Resolving
Ensure your tsconfig.json has the correct paths:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}Schema Parser Errors
If the schema generator fails:
- Check that you're using valid JavaScript in the schema file
- Don't use
require()orimportin schema files - Avoid arrow functions - use
function() {}syntax
Translation Keys Not Found
After adding translation files, run:
npm run i18n:updateThen restart your dev server.
Learn More
- React Hook Form Documentation
- Zod Documentation
- TanStack Query Documentation
- Zustand Documentation
- i18next Documentation
Created with ❤️ as Part of the Statsbygg Component System
