소스 정보
- 저장소
- theyoungastronauts/polaris
- 최근 소스 활동
- 2026년 7월 5일 02:32
- 감지된 SKILL.md 언어
- 영어
- 스타
- 4
- 포크
- 1
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/theyoungastronauts/polaris --skill nextjs-mui-bootstrap명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
Pull just the do-this-yourself steps out of a long answer, so the user doesn't have to read the whole thing.
Hand in-flight work to a fresh session instead of compacting, or pick up from a relay doc.
Close out a session — record what was learned, update any connected stores, and report what's left dirty.
SOC 직업 분류 기준
SKILL.md 표시 중
| name | nextjs-mui-bootstrap |
| description | Scaffold a Next.js app with Material UI (Emotion SSR, MUI v7). |
| disable-model-invocation | true |
On-demand command for scaffolding a new Next.js application with Docker, App Router, Material UI (MUI v7), Emotion, and standard conventions.
MANDATORY: This project runs entirely in Docker. Every Docker file below (Dockerfile.dev, docker-compose.yml, Makefile) MUST be generated. The Makefile is the sole interface — never run
npmdirectly on the host.
MUI component reference: For detailed component APIs, props, and usage patterns, see https://mui.com/material-ui/llms.txt
Ask the user for these values:
| Placeholder | Description | Example |
|---|---|---|
{app-name} | Project/directory name | my-app |
{compose-project} | Docker Compose project name (kebab-case) | my-app-web |
{host_port} | Host port for Next.js (default: 3000) | 3001 |
{api-port} | Backend API port (default: 8002) | 8002 |
{domain} | Production domain | myapp.com |
{app-title} | Display name for metadata | My App |
| Architecture mode | See below | Frontend-centric / SSR-centric / Combination |
| Include Agentation? | Browser annotation tool for visual feedback | Yes / No |
useEffect+useState, React Context, no middleware. Best for: apps with a separate Django/DRF backend.{app-name}/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── login/page.tsx
│ │ ├── forgot-password/page.tsx
│ │ ├── reset-password/page.tsx
│ │ └── <feature>/
│ │ ├── page.tsx
│ │ ├── new/page.tsx
│ │ └── [id]/page.tsx
│ ├── components/
│ │ ├── Providers.tsx
│ │ ├── layout/
│ │ │ ├── Header.tsx
│ │ │ └── AppLayout.tsx
│ │ ├── landing/
│ │ │ └── LandingPage.tsx
│ │ └── <feature>/
│ ├── contexts/ # Frontend-centric mode
│ │ └── UserContext.tsx
│ ├── lib/
│ │ ├── api.ts
│ │ ├── auth.ts # Frontend-centric mode
│ │ ├── constants.ts
│ │ └── hooks/
│ │ ├── index.ts
│ │ └── useRequireAuth.ts
│ ├── theme.ts # MUI theme configuration
│ └── types/
│ └── api.ts
├── public/
│ ├── favicon.png
│ └── og.jpg
├── .husky/
│ └── pre-commit
├── package.json
├── tsconfig.json
├── next.config.ts
├── eslint.config.mjs
├── .nvmrc
├── .env.local
├── .gitignore
├── Dockerfile.dev
├── docker-compose.yml
├── Makefile
└── .mcp.json # If Agentation enabled
Note: No postcss.config.mjs or globals.css — MUI handles all styling via Emotion. No Tailwind.
{
"name": "{app-name}",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"lint:fix": "eslint --fix",
"typecheck": "tsc --noEmit",
"prepare": "cd .. && husky {app-name}/.husky"
},
"dependencies": {
"next": "^16",
"react": "^19",
"react-dom": "^19",
"@mui/material": "^7",
"@mui/icons-material": "^7",
"@mui/material-nextjs": "^7",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"dayjs": "^1.11",
"sonner": "^2"
},
"devDependencies": {
"typescript": "^5",
"eslint": "^9",
"eslint-config-next": "^16",
"husky": "^9",
"lint-staged": "^15",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix"]
}
}
SSR-centric mode: Also add "@tanstack/react-query": "^5" to dependencies.
If Agentation enabled: Also add "agentation": "^0" to devDependencies.
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
"exclude": ["node_modules"]
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
22
NEXT_PUBLIC_API_BASE_URL=http://localhost:{api-port}
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# docker
docker-compose.override.yml
cd {app-name} && npx lint-staged
Make this file executable: chmod +x .husky/pre-commit
Only generate this file if the user opted into Agentation.
{
"mcpServers": {
"agentation": {
"command": "npx",
"args": ["agentation-mcp", "server"]
}
}
}
'use client';
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
cssVariables: {
colorSchemeSelector: 'data',
},
colorSchemes: {
light: {
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#9c27b0',
},
background: {
default: '#fafafa',
paper: '#ffffff',
},
},
},
dark: {
palette: {
primary: {
main: '#90caf9',
},
secondary: {
main: '#ce93d8',
},
background: {
default: '#121212',
paper: '#1e1e1e',
},
},
},
},
typography: {
fontFamily: 'var(--font-roboto)',
},
shape: {
borderRadius: 8,
},
});
export default theme;
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import InitColorSchemeScript from "@mui/material/InitColorSchemeScript";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v16-appRouter";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import theme from "@/theme";
import Providers from "@/components/Providers";
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-roboto",
});
export const metadata: Metadata = {
metadataBase: new URL("https://{domain}"),
title: {
default: "{app-title}",
template: "%s | {app-title}",
},
description: "{app-title} description",
icons: {
icon: "/favicon.png",
apple: "/favicon.png",
},
openGraph: {
type: "website",
locale: "en_US",
url: "https://{domain}",
siteName: "{app-title}",
title: "{app-title}",
description: "{app-title} description",
images: [{ url: "/og.jpg", width: 1200, height: 630, alt: "{app-title}" }],
},
twitter: {
card: "summary_large_image",
title: "{app-title}",
description: "{app-title} description",
images: ["/og.jpg"],
},
robots: { index: true, follow: true },
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={roboto.variable}>
<InitColorSchemeScript attribute="data-mui-color-scheme" />
<AppRouterCacheProvider options={{ enableCssLayer: true }}>
<ThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
<Providers>{children}</Providers>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
If Agentation enabled: Add the import and component inside <body>, before the closing </body>:
import { Agentation } from "agentation";
// Add after AppRouterCacheProvider closing tag, before </body>:
{process.env.NODE_ENV === "development" && <Agentation />}
import { getAccessToken } from '@/lib/auth';
import type { ApiErrorResponse } from '@/types/api';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:{api-port}';
const API_PATH = '/api/v1';
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public statusText: string,
public data?: ApiErrorResponse
) {
super(message);
this.name = 'ApiError';
}
}
async function apiFetch<T>(
endpoint: string,
options?: RequestInit & { skipAuth?: boolean }
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const { skipAuth, ...fetchOptions } = options || {};
const headers: HeadersInit = {
'Content-Type': 'application/json',
...fetchOptions?.headers,
};
if (!skipAuth) {
const token = getAccessToken();
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
}
try {
const response = await fetch(url, { ...fetchOptions, headers });
let data: Record<string, unknown>;
try {
data = await response.json();
} catch {
data = {};
}
if (!response.ok) {
const errorMessage =
(typeof data.detail === 'string' ? data.detail : null) ||
(typeof data.error === 'string' ? data.error : null) ||
(typeof data.message === 'string' ? data.message : null) ||
`Request failed: ${response.statusText}`;
throw new ApiError(errorMessage, response.status, response.statusText, data as unknown as ApiErrorResponse);
}
return data as T;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new ApiError('Network error. Please check your connection.', 0, 'Network Error');
}
throw new ApiError(
error instanceof Error ? error.message : 'An unknown error occurred',
0,
'Unknown Error'
);
}
}
export { apiFetch, API_PATH };
Add auth-specific API methods based on mode (see mode-specific sections below).
export const APP_NAME = '{app-title}';
// ============================================
// Auth & User
// ============================================
export interface User {
uuid: string;
username: string;
email: string;
first_name: string;
last_name: string;
}
export interface LoginResponse {
access: string;
refresh: string;
}
export interface RefreshResponse {
access: string;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
}
export interface RegisterResponse {
user: User;
tokens: { access: string; refresh: string };
}
export interface ApiErrorResponse {
detail?: string;
error?: string;
message?: string;
[key: string]: unknown;
}
// ============================================
// Add feature types below
// ============================================
'use client';
import Link from 'next/link';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
export default function Header() {
return (
<AppBar position="static" color="default" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Container maxWidth="md">
<Toolbar disableGutters>
<Typography
variant="h6"
component={Link}
href="/"
sx={{ flexGrow: 1, textDecoration: 'none', color: 'primary.main', fontWeight: 700 }}
>
{app-title}
</Typography>
{/* Navigation items */}
</Toolbar>
</Container>
</AppBar>
);
}
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
export default function HomePage() {
return (
<Container maxWidth="md" sx={{ py: 8 }}>
<Typography variant="h3" component="h1" fontWeight="bold">
{app-title}
</Typography>
</Container>
);
}
Generate these files when the user selects frontend-centric or combination mode.
const ACCESS_TOKEN_KEY = '{app-name}_access_token';
const REFRESH_TOKEN_KEY = '{app-name}_refresh_token';
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function setAccessToken(token: string): void {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
export function setTokens(access: string, refresh: string): void {
setAccessToken(access);
setRefreshToken(refresh);
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return getAccessToken() !== null;
}
import { setTokens, clearTokens, getRefreshToken } from '@/lib/auth';
import type {
User, LoginResponse, RefreshResponse,
RegisterRequest, RegisterResponse,
} from '@/types/api';
export const authApi = {
login: async (email: string, password: string): Promise<LoginResponse> => {
const response = await apiFetch<LoginResponse>(`${API_PATH}/auth/login/`, {
method: 'POST',
body: JSON.stringify({ email, password }),
skipAuth: true,
});
setTokens(response.access, response.refresh);
return response;
},
register: async (data: RegisterRequest): Promise<RegisterResponse> => {
const response = await apiFetch<RegisterResponse>(`${API_PATH}/auth/register/`, {
method: 'POST',
body: JSON.stringify(data),
skipAuth: true,
});
setTokens(response.tokens.access, response.tokens.refresh);
return response;
},
logout: (): void => {
clearTokens();
},
refresh: async (): Promise<string | null> => {
const refreshToken = getRefreshToken();
if (!refreshToken) return null;
try {
const response = await apiFetch<RefreshResponse>(`${API_PATH}/auth/refresh/`, {
method: 'POST',
body: JSON.stringify({ refresh: refreshToken }),
skipAuth: true,
});
setTokens(response.access, refreshToken);
return response.access;
} catch {
clearTokens();
return null;
}
},
me: () => apiFetch<User>(`${API_PATH}/auth/me/`),
};
'use client';
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import type { User, RegisterRequest } from '@/types/api';
import { authApi, ApiError } from '@/lib/api';
import { isAuthenticated, clearTokens } from '@/lib/auth';
interface UserContextType {
user: User | null;
loading: boolean;
isLoggedIn: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refreshUser = useCallback(async () => {
if (!isAuthenticated()) {
setUser(null);
setLoading(false);
return;
}
try {
const userData = await authApi.me();
setUser(userData);
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
const newToken = await authApi.refresh();
if (newToken) {
try {
const userData = await authApi.me();
setUser(userData);
return;
} catch {
// Fall through to clear
}
}
}
clearTokens();
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshUser();
}, [refreshUser]);
const login = async (email: string, password: string) => {
await authApi.login(email, password);
const userData = await authApi.me();
setUser(userData);
};
const register = async (data: RegisterRequest) => {
const response = await authApi.register(data);
setUser(response.user);
};
const logout = () => {
authApi.logout();
setUser(null);
};
return (
<UserContext.Provider
value={{ user, loading, isLoggedIn: user !== null, login, register, logout, refreshUser }}
>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
}
'use client';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import { UserProvider } from '@/contexts/UserContext';
export default function Providers({ children }: { children: ReactNode }) {
return (
<UserProvider>
{children}
<Toaster richColors position="top-right" />
</UserProvider>
);
}
Note: ThemeProvider, CssBaseline, and AppRouterCacheProvider are already in layout.tsx. Providers only handles app-level context (auth, toasts).
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '@/contexts/UserContext';
export function useRequireAuth() {
const { user, loading, isLoggedIn } = useUser();
const router = useRouter();
useEffect(() => {
if (!loading && !isLoggedIn) {
router.replace('/login');
}
}, [loading, isLoggedIn, router]);
return { user, loading, isAuthenticated: isLoggedIn };
}
export { useRequireAuth } from './useRequireAuth';
'use client';
import { ReactNode } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container';
import { useRequireAuth } from '@/lib/hooks';
import Header from './Header';
export default function AppLayout({ children }: { children: ReactNode }) {
const { loading } = useRequireAuth();
if (loading) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
return (
<>
<Header />
<Container component="main" maxWidth="md" sx={{ py: 4, flex: 1 }}>
{children}
</Container>
</>
);
}
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import MuiLink from '@mui/material/Link';
import { useUser } from '@/contexts/UserContext';
import { ApiError } from '@/lib/api';
import { toast } from 'sonner';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useUser();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
toast.success('Welcome back!');
router.push('/');
} catch (error) {
if (error instanceof ApiError) {
toast.error(error.message);
}
} finally {
setLoading(false);
}
};
return (
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', px: 2, py: 8 }}>
<Card sx={{ width: '100%', maxWidth: 400 }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h5" component="h1" align="center" fontWeight="bold" gutterBottom>
{app-title}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
size="small"
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
size="small"
/>
<Button
type="submit"
variant="contained"
fullWidth
disabled={loading}
sx={{ mt: 1 }}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Sign In'}
</Button>
</Box>
<Typography variant="body2" align="center" sx={{ mt: 2, color: 'text.secondary' }}>
<MuiLink component={Link} href="/forgot-password" color="primary">
Forgot password?
</MuiLink>
</Typography>
</CardContent>
</Card>
</Box>
);
}
Generate these files when the user selects SSR-centric or combination mode.
For SSR mode, provide a minimal stub that the user can extend with their preferred auth approach (cookies, NextAuth, etc.):
// SSR auth — extend based on your auth strategy
// Options: NextAuth, cookie-based sessions, server-side JWT validation
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
// Implement based on auth strategy
return null;
}
'use client';
import { ReactNode, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
export default function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster richColors position="top-right" />
</QueryClientProvider>
);
}
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const publicPaths = ['/login', '/forgot-password', '/reset-password'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (publicPaths.some((path) => pathname.startsWith(path))) {
return NextResponse.next();
}
const token = request.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.png|og.jpg).*)'],
};
Create src/lib/queries/ for domain-specific query hooks:
// src/lib/queries/useItems.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { itemsApi } from '@/lib/api';
import type { Item, ItemCreateRequest } from '@/types/api';
export function useItems() {
return useQuery<Item[]>({
queryKey: ['items'],
queryFn: () => itemsApi.list(),
});
}
export function useItem(id: string) {
return useQuery<Item>({
queryKey: ['items', id],
queryFn: () => itemsApi.get(id),
enabled: !!id,
});
}
export function useCreateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ItemCreateRequest) => itemsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
}
'use client';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<Box sx={{ minHeight: '50vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Something went wrong
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
{error.message}
</Typography>
<Button variant="contained" onClick={reset}>
Try again
</Button>
</Box>
);
}
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
export default function Loading() {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
// src/app/items/page.tsx (Server Component)
import { itemsApi } from '@/lib/api';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
export default async function ItemsPage() {
const items = await itemsApi.list();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold" sx={{ mb: 4 }}>
Items
</Typography>
<Grid container spacing={2}>
{items.map((item) => (
<Grid key={item.uuid} size={{ xs: 12, sm: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight="bold">
{item.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Container>
);
}
sx PropThe sx prop is the primary styling API. It accepts theme-aware values:
// Theme spacing — 1 unit = 8px by default
<Box sx={{ p: 2, mt: 4 }}> {/* padding: 16px, margin-top: 32px */}
// Theme palette colors
<Box sx={{ color: 'primary.main', bgcolor: 'background.paper' }}>
// Responsive values
<Box sx={{ width: { xs: '100%', sm: '50%', md: '33%' } }}>
// Pseudo-selectors
<Box sx={{ '&:hover': { bgcolor: 'action.hover' } }}>
Import each component individually for optimal tree-shaking:
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import SearchIcon from '@mui/icons-material/Search';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
<Button variant="contained">Primary</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="text">Text</Button>
<Button variant="contained" color="error">Destructive</Button>
<Button variant="contained" color="secondary">Secondary</Button>
<Button size="small">Small</Button>
<Button size="large">Large</Button>
<Button variant="contained" startIcon={<AddIcon />}>Create</Button>
<Button disabled><CircularProgress size={20} sx={{ mr: 1 }} />Loading</Button>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" fontWeight="bold">
Title
</Typography>
<Typography variant="body2" color="text.secondary">
Description text
</Typography>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
<Button size="small">Cancel</Button>
<Button size="small" variant="contained">Action</Button>
</CardActions>
</Card>
const statusColors: Record<string, 'success' | 'warning' | 'default' | 'error'> = {
active: 'success',
paused: 'warning',
completed: 'default',
failed: 'error',
};
<Chip label={status} color={statusColors[status]} size="small" sx={{ textTransform: 'capitalize' }} />
<TextField
label="Field Name"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
fullWidth
size="small"
required
helperText="Optional helper text"
error={!!fieldError}
/>
<Dialog open={isOpen} onClose={() => setIsOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Title</DialogTitle>
<DialogContent>
<Typography color="text.secondary">Description text</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={onConfirm}>Confirm</Button>
</DialogActions>
</Dialog>
import CircularProgress from '@mui/material/CircularProgress';
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
import { toast } from 'sonner';
toast.success('Item created!');
toast.error('Something went wrong');
toast.info('Processing...');
toast.warning('Are you sure?');
{/* Page container */}
<Container maxWidth="md" sx={{ py: 4 }}>
{/* Page header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
<Typography variant="h4" fontWeight="bold">Title</Typography>
<Button variant="contained" startIcon={<AddIcon />}>New</Button>
</Box>
{/* Responsive grid */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>...</Grid>
</Grid>
{/* Two-column detail */}
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 8 }}>
<Stack spacing={3}>{/* Main */}</Stack>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Stack spacing={3}>{/* Sidebar */}</Stack>
</Grid>
</Grid>
{/* Centered (auth pages) */}
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', px: 2, py: 8 }}>
'use client';
import { useColorScheme } from '@mui/material/styles';
import IconButton from '@mui/material/IconButton';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
export function ThemeToggle() {
const { mode, setMode } = useColorScheme();
return (
<IconButton
onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
color="inherit"
>
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
);
}
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4" sx={{ mb: 2, opacity: 0.2 }}>~</Typography>
<Typography variant="h6" fontWeight="bold" gutterBottom>
No items yet.
</Typography>
<Typography color="text.secondary" sx={{ mb: 3, maxWidth: 360, mx: 'auto' }}>
Descriptive text about what will appear here.
</Typography>
<Button variant="contained" component={Link} href="/items/new">
Create Your First Item
</Button>
</Box>
try {
const data = await itemsApi.create(formData);
toast.success('Item created!');
router.push(`/items/${data.uuid}`);
} catch (error) {
if (error instanceof ApiError) {
toast.error(error.message);
}
} finally {
setSubmitting(false);
}
import Image from 'next/image';
<Image
src="/images/logo.png"
alt="Logo"
width={574}
height={177}
style={{ height: 36, width: 'auto' }}
priority // Only for above-the-fold
/>
FROM node:22-alpine
WORKDIR /app
# Install dependencies first for layer caching
COPY package.json package-lock.json* ./
RUN npm install
# Copy application code
COPY . .
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
CMD ["npm", "run", "dev"]
name: {compose-project}
services:
web:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
- /app/.next
ports:
- "{host_port}:3000"
env_file:
- .env.local
environment:
- WATCHPACK_POLLING=true
Key details:
node_modules and .next prevent host overwriting container installsWATCHPACK_POLLING=true enables hot reload inside Docker (required on macOS with bind mounts){host_port} maps the host side# {compose-project} - Makefile
PROJECT_NAME = {compose-project}
DOCKER_COMPOSE = docker compose --project-name $(PROJECT_NAME)
# ============================================================================
# Docker
# ============================================================================
.PHONY: build up down restart stop ps logs bash wipe
build:
$(DOCKER_COMPOSE) build
up:
$(DOCKER_COMPOSE) up --detach
down:
$(DOCKER_COMPOSE) down
restart: down up
stop:
$(DOCKER_COMPOSE) stop
ps:
$(DOCKER_COMPOSE) ps
logs:
$(DOCKER_COMPOSE) logs -f web
bash:
$(DOCKER_COMPOSE) exec web sh
wipe:
@echo "WARNING: This will delete all containers and volumes!"
@read -p "Are you sure? [y/N] " confirm && [ "$$confirm" = "y" ]
$(DOCKER_COMPOSE) down -v --remove-orphans
# ============================================================================
# Development
# ============================================================================
.PHONY: dev lint lint-fix typecheck
dev: up logs
lint:
$(DOCKER_COMPOSE) exec web npm run lint
lint-fix:
$(DOCKER_COMPOSE) exec web npm run lint:fix
typecheck:
$(DOCKER_COMPOSE) exec web npm run typecheck
# ============================================================================
# Dependencies
# ============================================================================
.PHONY: install add
install:
$(DOCKER_COMPOSE) exec web npm install
add:
$(DOCKER_COMPOSE) exec web npm install $(filter-out $@,$(MAKECMDGOALS))
# ============================================================================
# Build
# ============================================================================
.PHONY: build-next
build-next:
$(DOCKER_COMPOSE) exec web npm run build
# Catch-all for npm commands
%:
@:
Dockerfile.dev, docker-compose.yml, Makefile.env.local from template abovemake build && make uphttp://localhost:{host_port}make lint — should pass cleanlymake typecheck — should pass cleanly/login.mcp.json exists at project root