-
Notifications
You must be signed in to change notification settings - Fork 0
Dev Style Guide
Write consistent code :)))))))) google docs version of the guide
- Tooling & Setup
- Formatting Rules
- TypeScript Conventions
- Component Conventions
- Import Ordering
- Tailwind CSS
- Data Fetching & Server Actions
- File & Folder Naming
- Payload CMS Collections
| Tool | Version / Config |
|---|---|
| Package manager | pnpm |
| Framework | Next.js 15 with Turbopack |
| Language | TypeScript (strict mode) |
| Formatter | Prettier |
| Linter | ESLint (Next.js + TypeScript rules) |
| CSS | Tailwind CSS v4 |
| CMS | Payload CMS |
pnpm dev # Start dev server
pnpm build # Compiles app for production
pnpm start # Serves output from build
# Lint a single file
pnpm exec eslint path/to/your/file.tsx
# Format a single file with Prettier
pnpm exec prettier --write path/to/your/file.tsx
# Check formatting (without writing changes)
pnpm exec prettier --check path/to/your/file.tsxLefthook runs automatically on every commit:
-
Prettier formats staged
*.{js,jsx,ts,tsx,css,scss,json,md,yml,yaml}files -
ESLint lints staged
.ts/.tsxfiles
All formatting is enforced by Prettier. Don't configure your editor to use different settings.
| Rule | Value |
|---|---|
| Quotes | Double (") |
| JSX quotes | Double (") |
| Semicolons | Required |
| Trailing commas | All multi-line structures |
| Arrow function parens | (x) => x |
| Tailwind class order | Auto-sorted by prettier-plugin-tailwindcss
|
Define props as an interface directly above the component, named {ComponentName}Props:
interface ProfileCardProps {
name: string;
title: string;
degree: string;
imageSrc: string;
}
const ProfileCard = ({ name, title, degree, imageSrc }: ProfileCardProps) => {
// ...
};- Use
interface(nottype) for component props - Mark optional fields with
? - Destructure props in the function signature, not inside the body
Use import type when importing types only:
import type { CollectionConfig } from "payload";Async server functions should have explicit return types:
export const getBulletins = async (): Promise<Bulletin[]> => { ... };
export const getLatestBulletin = async (): Promise<Bulletin | null> => { ... };Prefix unused variables or parameters with _ to suppress the ESLint error:
catch (_error) {
return [];
}All components follow this pattern:
const MyComponent = () => {
return <div>...</div>;
};
export default MyComponent;- Arrow function assigned to a
const -
export defaulton the last line — not inline - Do not use
export default function MyComponent() {}
Add "use client" at the very top of the file, before any imports. Only add it when the component truly needs it (event handlers, useState, useEffect, browser APIs). Server components have no directive at all.
"use client";
import React, { useState } from "react";
import Image from "next/image";
const Navbar = () => {
const [isOpen, setIsOpen] = useState(false);
// ...
};
export default Navbar;Server components that fetch data are async. Fetch data directly in the component body and use early returns for null/empty states:
import { getLatestBulletin } from "@/features/bulletins/data/getBulletins";
const LatestArticle = async () => {
const latest = await getLatestBulletin();
if (!latest) return null;
return <div>...</div>;
};
export default LatestArticle;Components always use default exports. Named exports are only for types, interfaces, or utility functions in non-component files.
There is no auto-sorter for imports. Follow this order manually:
-
"use client"or"use server"directive (if needed) - Next.js built-ins (
next/image,next/link,next/navigation, etc.) - Third-party libraries (icon libraries, etc.)
- Local components using
@/alias - Local utilities / data functions using
@/alias
"use client";
import Image from "next/image";
import Link from "next/link";
import { FaInstagram } from "react-icons/fa";
import Navbar from "@/components/Navbar";
import { getBulletins } from "@/features/bulletins/data/getBulletins";Use the project's custom tokens instead of raw Tailwind values wherever possible:
| Token | Value | Usage |
|---|---|---|
text-darkBlue / bg-darkBlue
|
#145ca9 |
Primary brand blue |
text-babyBlue / bg-babyBlue
|
#5fb4ff |
Secondary/accent blue |
text-lightBlue / bg-lightBlue
|
#ebf7fe |
Light blue backgrounds |
bg-whiteHover |
#f0f8ff |
Hover state on white elements |
bg-grey-100 / border-grey-100
|
#d9d9d9 |
Light grey |
bg-grey-200 / border-grey-200
|
#cbc6c6 |
Medium grey |
text-header |
1.3rem |
Section headings |
text-body |
1rem |
Body text |
text-title |
2rem |
Page titles |
// Good — uses design tokens
<h1 className="text-darkBlue text-header font-bold">Meet the Team</h1>
// Okay for one-off sizes not covered by tokens
<h1 className="text-[41.65px]">...</h1>The site is mobile-first. Write base styles for mobile, then layer on larger-screen overrides:
// Base = mobile, sm: = 640px+, md: = 768px+, lg: = 1024px+
<div className="flex flex-col gap-4 p-4 md:flex-row md:gap-8 lg:px-10">Breakpoint order in class strings: sm: → md: → lg: → xl: → 2xl:
Don't manually order Tailwind classes — prettier-plugin-tailwindcss sorts them on format. For one-off values use bracket notation:
className="max-w-[160px] gap-[47px] text-[16px]"
// Spaces inside arbitrary values use underscores:
className="bg-[linear-gradient(to_top,white_25%,transparent_100%)]"All data fetching goes through Payload CMS via server actions.
The intended structure is one domain folder per feature:
src/features/
└── [domain]/
└── data/
└── get[Resource].ts # e.g. getBulletins.ts, getEvents.ts
New data functions should follow this pattern:
"use server";
import { getPayload } from "payload";
import config from "@payload-config";
export interface MyResource {
id: string;
title: string;
// ... fields
}
export const getMyResources = async (): Promise<MyResource[]> => {
const payload = await getPayload({ config });
try {
const result = await payload.find({
collection: "my-collection",
limit: 100,
sort: ["-createdAt"],
depth: 1,
});
return result.docs.map((doc: any) => ({
id: doc.id,
title: doc.title,
}));
} catch (error) {
console.error("Error fetching:", error);
return [];
}
};Note: Not all existing files follow this pattern.
getEvents.ts, for example, usesPromise<any[]>, returnsresult.docsdirectly without mapping, and has notry/catch. Treat those as legacy — write new functions to this standard.
-
"use server"at the top - Export the interface alongside the function so components can type their props
- Always wrap Payload calls in
try/catch - Return a safe fallback on error (
[]ornull) - Map
result.docsto your interface explicitly — don't return raw Payload docs
| What | Convention | Example |
|---|---|---|
| Component files | PascalCase |
ProfileCard.tsx, EventLanding.tsx
|
| Page files | always page.tsx
|
src/app/(home)/team/page.tsx |
| Layout files | always layout.tsx
|
src/app/(home)/layout.tsx |
| Utility/data files | camelCase |
getBulletins.ts, getEvents.ts
|
| Collection files | PascalCase |
Events.ts, Executive.ts
|
| Component folders | camelCase |
components/about/, components/home/
|
Images (public/) |
kebab-case |
danielle-smith.webp, uaic-logo.png
|
| File type | Location |
|---|---|
| Reusable UI components | src/components/[section]/ |
| Page-specific layouts | src/app/(home)/[route]/page.tsx |
| Data fetching functions | src/features/[domain]/data/ |
| Payload collections | src/collections/ |
| Images | public/assets/[section]/ |
Collections live in src/collections/ and are registered in src/payload.config.ts.
import type { CollectionConfig } from "payload";
export const MyCollection: CollectionConfig = {
slug: "my-collection",
labels: {
singular: "My Item",
plural: "My Items",
},
admin: {
useAsTitle: "title",
},
fields: [
{
name: "title",
type: "text",
label: "Title",
required: true,
},
],
access: {
read: () => true,
},
};- Use
import typefor Payload types - Always set
access.read: () => truefor public-facing content - Always define
labels.singularandlabels.plural