์ฌ์ด๋ ํ๋ก์ ํธ๋ฅผ ์ํ UI ยท OG ยท PWA ยท Fetch ยท Utils ์ฌ์ธ์ ํท.
npm install @m1kapp/kitPeer dependencies: react >= 18, react-dom >= 18
Optional: @vercel/og >= 0.6 (Next.js ์ธ ํ๊ฒฝ์์ OG ์ด๋ฏธ์ง ์์ฑ ์)
// CSS ์๋ ์ฃผ์
โ ๋ณ๋ import ๋ถํ์
import { Watermark, AppShell, AppShellHeader, AppShellContent, Section, TabBar, Tab } from "@m1kapp/kit";
export default function App() {
return (
// Watermark๊ฐ ๋ทฐํฌํธ ์ ์ฒด ๋์ด/์ค์์ ๋ ฌ/ํ๋จ ํฌ๋ ๋ง์ ๊ฐ์ด ์ค๋๋ค.
// (ํฌ๋ ๋ง ์คํธ๋ฆฝ์ ์
ธ ๋ฐ๋ก ๋ฐ์ ๋ถ์ด์ ์
ธ๊ณผ ํ ๋ฉ์ด๋ฆฌ๋ก ์ค์์ ๋ ฌ๋จ โ ํ๋ฉด ์ง์ง ๋ฐ๋ฅ์ ๊ณ ์ ๋๋ ๊ฒ ์๋)
// AppShell๋ง ๋จ๋
์ผ๋ก ์ฐ๋ฉด ์ฝํ
์ธ ๋์ด๋งํผ ์ชผ๊ทธ๋ผ๋ค์ด์.
<Watermark color="#e2603f" text="myapp">
<AppShell>
<AppShellHeader>
<h1>My App</h1>
</AppShellHeader>
<AppShellContent>
<Section className="pt-4">{/* ์ฝํ
์ธ โ Divider๋ก ์น์
๊ตฌ๋ถ */}</Section>
</AppShellContent>
<TabBar>
<Tab href="/" icon={<HomeIcon />} label="ํ" />
</TabBar>
</AppShell>
</Watermark>
);
}ํ๋์ import๋ฉด ๋. UI ยท OG ยท PWA ยท Fetch ยท Utils๊ฐ ์ ๋ถ
@m1kapp/kit๋ฉ์ธ export์ ๋ค์ด ์์ต๋๋ค./server๋ง ๋ณ๋ ์๋ธํจ์ค์์. ์๋ธํจ์ค๋ฅผ ์ผ์ผ์ด ์ธ์ธ ํ์ ์์ดimport { ... } from "@m1kapp/kit"ํ๋๋ก ๋ค ๊บผ๋ด ์ฐ์ธ์.
| ๋ชจ๋ | import | ์ค๋ช |
|---|---|---|
| UI | @m1kapp/kit |
์ปดํฌ๋ํธ 45๊ฐ + ํ |
| OG Image | @m1kapp/kit |
OG ์ด๋ฏธ์ง ์์ฑ (์๋ฒ) |
| PWA | @m1kapp/kit |
manifest, viewport, ์ค์น ์ ๋ |
| Fetch | @m1kapp/kit |
์บ์ฑยท์ค๋ณต์ ๊ฑฐยท์ฌ์๋ fetch ์ ํธ |
| Utils | @m1kapp/kit |
๋ ์งยท์ซ์ ํฌ๋งท, ๋ฒ์ฉ ํ |
| Server | @m1kapp/kit/server |
Next.js API route ํธ๋ค๋ฌ ์ ํธ |
์์ผ๋ก ๋ง๋ค๊ธฐ ์ ์ ๋จผ์ ์ฐพ์๋ณด์ธ์. ํํ ํ๋ฉด์ ๋๋ถ๋ถ ์กฐํฉ์ผ๋ก ๋๋ฉ๋๋ค.
| ํ๊ณ ์ถ์ ๊ฒ | ์ด๋ ๊ฒ |
|---|---|
| ๋ชจ๋ฐ์ผ ์ฑ ์ ธ | AppShell + AppShellHeader / AppShellContent + TabBar/Tab + Watermark |
| ๋ชฉ๋ก + ๋ก๋ฉ + ๋น ํ๋ฉด | useFetch โ ๋ก๋ฉ์ด๋ฉด Skeleton, ๋น ๋ฐฐ์ด์ด๋ฉด EmptyState |
| ํผ ์ ์ฅ ํ ํผ๋๋ฐฑ | useFormSubmit + useToast (์ธ๋ผ์ธ ํ
์คํธ ๋์ ํ ์คํธ) |
| ์ค์ ํ๋ฉด | Section + Field(inline) + Switch |
| ์ธ๋ผ์ธ ํ ๊ธ (์ค๋/์ด๋ฒ์ฃผ) | SegmentedControl (ํ๋จ ๊ธ๋ก๋ฒ ๋ด๋น๋ TabBar) |
| ์ฑํ / AI ๋น์ | MessageList + ChatBubble + TypingIndicator |
| "์ด๋ ๊ฒ ํ ๊น์?" ํ์ธ | ActionCard (๋ฉ์์ง ํ๋ฆ ์), ๋ชจ๋ฌ์ด๋ฉด Dialog |
| ์ผ์ / ํ์๋ผ์ธ ํ | ListRow (sizeByMinutes๋ก ์์์๊ฐ ๋น๋ก ๋์ด) |
| ๋ฉ๋ชจ ์ URL ๋งํฌ | LinkifiedText |
| API ํธ์ถ | createApiClient (get/post/put/delete + ApiError) |
| ์ฃผ๊ธฐ์ ๊ฐฑ์ | usePolling (pauseOnHidden) |
| ํ ๋ง์ ํ ๋ฒ์ | <AppShell accent="#e2603f"> โ ๋ชจ๋ ์ปดํฌ๋ํธ๊ฐ --kit-accent๋ก ๋ฐ๋ผ์ด |
CSS๊ฐ import ์ ์๋ ์ฃผ์ ๋ฉ๋๋ค. ๋ณ๋ ์คํ์ผ์ํธ import ๋ถํ์.
import { AppShell, AppShellHeader, AppShellContent } from "@m1kapp/kit";
<AppShell> // ์ต๋ 430px ์ค์ ์ ๋ ฌ ๋ชจ๋ฐ์ผ ์ปจํ
์ด๋
<AppShellHeader>...</AppShellHeader> // ์๋จ sticky ํค๋
<AppShellContent>...</AppShellContent> // ์คํฌ๋กค ๊ฐ๋ฅํ ๋ณธ๋ฌธ
</AppShell>import { TabBar, Tab } from "@m1kapp/kit";
<TabBar>
<Tab
active={tab === "home"}
onClick={() => setTab("home")}
label="ํ"
icon={<HomeIcon />}
activeColor="#3b82f6" // ํ์ฑ ์์ ์์ ๋กญ๊ฒ ์ง์
/>
</TabBar>import { Avatar, Badge, StatChip, EmptyState, GrassMap } from "@m1kapp/kit";
// Avatar โ ์ด๋์
or ์ด๋ฏธ์ง, ์ด๋ฏธ์ง ๋ก๋ ์คํจ ์ ์ด๋์
๋ก ์๋ fallback
<Avatar src="/photo.jpg" fallback="MH" size="md" shape="circle" />
<Avatar fallback="MH" size="lg" shape="rounded" color="#3b82f6" />
// size: "xs" | "sm" | "md" | "lg" | "xl"
// shape: "circle" | "rounded"
// Badge โ ์ํ/์นดํ
๊ณ ๋ฆฌ ๋ ์ด๋ธ
<Badge variant="green">LIVE</Badge>
<Badge variant="red">์ค๋ฅ</Badge>
<Badge variant="blue" size="sm">์ ๋ณด</Badge>
// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"
// StatChip โ ์ซ์ stat ๋ฑ์ง
<StatChip label="๋ฐฉ๋ฌธ์" value={1024} />
// EmptyState โ ๋น ๋ชฉ๋ก ํ๋ ์ด์คํ๋
<EmptyState message="์์ง ์๋ฌด๊ฒ๋ ์์ด์" />
// GrassMap โ GitHub ์คํ์ผ ํ๋ ํํธ๋งต
<GrassMap data={[{ date: "2025-04-19", count: 42 }]} accent="#3b82f6" />๋ก๋ฉ ํ๋ ์ด์คํ๋. className์ผ๋ก ํฌ๊ธฐ๋ฅผ ์ง์ ํฉ๋๋ค.
import { Skeleton } from "@m1kapp/kit";
// ํ
์คํธ ์ค
<Skeleton className="h-4 w-3/4" />
// ์นด๋ ๋ธ๋ก
<Skeleton className="h-32 w-full" rounded="xl" />
// ์๋ฐํ
<Skeleton className="h-10 w-10" rounded="full" />
// ์ค์ ํจํด
function PostCardSkeleton() {
return (
<div className="flex gap-3 p-4">
<Skeleton className="h-10 w-10" rounded="full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
);
}backdrop ํด๋ฆญ, ESC ํค, ์คํฌ๋กค ์ ๊ธ ์๋ ์ฒ๋ฆฌ.
import { Dialog } from "@m1kapp/kit";
// ๊ธฐ๋ณธ ์ฌ์ฉ
<Dialog open={open} onClose={() => setOpen(false)} title="์ค์ ">
<p className="text-sm text-zinc-500">๋ด์ฉ</p>
</Dialog>
// ํ์ธ ๋ค์ด์ผ๋ก๊ทธ
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} title="์ญ์ ํ ๊น์?">
<p className="text-sm text-zinc-500">์ด ์์
์ ๋๋๋ฆด ์ ์์ด์.</p>
<div className="flex gap-2 mt-4">
<button onClick={handleDelete} className="...">์ญ์ </button>
<button onClick={() => setConfirmOpen(false)} className="...">์ทจ์</button>
</div>
</Dialog>
// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"
// persistent: true โ backdrop ํด๋ฆญ / ESC๋ก ๋ซ๊ธฐ ๋นํ์ฑํimport { Button, Tooltip, Typewriter, EmojiButton, EmojiPicker } from "@m1kapp/kit";
<Button onClick={fn}>์์ํ๊ธฐ</Button>
<Tooltip label="์ค๋ช
ํ
์คํธ">
<button>hover me</button>
</Tooltip>
<Typewriter words={["Hello", "World"]} color="#3b82f6" />
// ์ด๋ชจ์ง ์ ํ๊ธฐ
const [emoji, setEmoji] = useState("๐ ");
const [open, setOpen] = useState(false);
<EmojiButton emoji={emoji} onClick={() => setOpen(true)} />
<EmojiPicker open={open} onClose={() => setOpen(false)} current={emoji} onSelect={setEmoji} />import { ShareButton, useShare } from "@m1kapp/kit";
// ๋ฒํผ ๊ทธ๋๋ก ์ฌ์ฉ โ ๋ชจ๋ฐ์ผ์ ๋ค์ดํฐ๋ธ ๊ณต์ , ๋ฐ์คํฌํ์ ํด๋ฆฝ๋ณด๋ ๋ณต์ฌ
<ShareButton url="https://m1k.app" title="My App" />
// ์ปค์คํ
UI
const { share, copied, canNativeShare } = useShare({ url: "https://m1k.app" });
<button onClick={() => share()}>{copied ? "๋ณต์ฌ๋จ!" : "๊ณต์ "}</button>import { ToastProvider, useToast } from "@m1kapp/kit";
// ์ฑ ๋ฃจํธ ๊ฐ์ธ๊ธฐ
<ToastProvider>
<App />
</ToastProvider>
// ์ด๋์๋
const toast = useToast();
toast("์ ์ฅ๋์ด์!", { variant: "success" });
toast("์ค๋ฅ๊ฐ ๋ฐ์ํ์ด์.", { variant: "error", duration: 4000 });
toast("๋งํฌ๊ฐ ๋ณต์ฌ๋์ด์."); // default (dark)
// variant: "default" | "success" | "error" | "info"๋คํฌ๋ชจ๋ + ์ปฌ๋ฌ ํ ๋ง ์ ํ๊ธฐ.
import { ThemeButton, ThemeDialog, THEME_SCRIPT, colors } from "@m1kapp/kit";
// layout.tsx โ ๋คํฌ๋ชจ๋ ๊น๋นก์ ๋ฐฉ์ง
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
// ํ
๋ง ๋ฒํผ + ๋ค์ด์ผ๋ก๊ทธ
const [themeOpen, setThemeOpen] = useState(false);
<ThemeButton color={color} dark={dark} onClick={() => setThemeOpen(true)} />
<ThemeDialog
open={themeOpen}
onClose={() => setThemeOpen(false)}
current={color}
onSelect={setColor}
dark={dark}
onDarkToggle={() => setDark(v => !v)}
/>
// ํ๋ ํธ
colors.blue // "#3b82f6"
colors.purple // "#a855f7"
colors.green // "#22c55e"
// blue | purple | green | orange | pink | red | yellow | cyan | slate | zinc์์ ์๋ OK. ํ๋ ํธ๋ ํ๋ฆฌ์
์ผ ๋ฟ์ด๊ณ , ThemeDialog์ palette / onSelect๋ ์ด๋ค hex๋ ๋ฐ์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ SwitchยทSegmentedControlยทChatBubbleยทActionCardยทListRowยทLinkifiedText์ accent๋ --kit-accent CSS ๋ณ์๋ฅผ ๋ฐ๋ผ๊ฐ๋ฏ๋ก, ํ ๊ณณ๋ง ๋ฐ๊พธ๋ฉด ์ ์ฒด ํค์ด ๋ฐ๋๋๋ค.
// โ AppShell accent prop โ ๊ฐ์ฅ ์ฌ์ (ํ์ ์ ์ฒด์ ์ ์ฉ)
<AppShell accent="#e2603f">...</AppShell>
// โก ์๋ฌด ๋ํผ์๋ CSS ๋ณ์๋ก
<div style={{ "--kit-accent": "#e2603f" } as React.CSSProperties}>...</div>
// โข ์ปดํฌ๋ํธ๋ณ override
<Switch checked={on} onChange={setOn} accent="#e2603f" />import { Field, Switch } from "@m1kapp/kit";
// ๋ผ๋ฒจ๋ ์ธํ (stacked ๊ธฐ๋ณธ / inline / multiline)
<Field label="์ด๋ฆ" value={name} onChange={setName} placeholder="์ด๋ฆ" />
<Field label="์ด๋ฉ์ผ" value={email} inline readOnly />
<Field label="๋ฉ๋ชจ" value={memo} onChange={setMemo} multiline rows={3} hint="์์ ๋กญ๊ฒ ์ ์ด์ฃผ์ธ์" />
// on/off ํ ๊ธ โ accent๋ --kit-accent ์๋ ์ฐ๋
<Switch checked={on} onChange={setOn} aria-label="์๋ฆผ" />import { SegmentedControl } from "@m1kapp/kit";
<SegmentedControl
value={view}
onChange={setView}
options={[{ value: "today", label: "์ค๋" }, { value: "week", label: "์ด๋ฒ ์ฃผ" }]}
/>
// ์ธ๋ผ์ธ ํ ๊ธ์ฉ. ํ๋จ ๊ธ๋ก๋ฒ ๋ด๋น๊ฒ์ด์
์ TabBar๋ฅผ ์ฐ์ธ์.import { MessageList, ChatBubble, TypingIndicator } from "@m1kapp/kit";
import type { ChatMessage } from "@m1kapp/kit";
const messages: ChatMessage[] = [
{ role: "user", content: "๋ด์ผ 3์ ํ์ ์ก์์ค", timestamp: Date.now() },
{ role: "assistant", content: "๋ค, ์ก์๋๋ฆด๊ฒ์." },
];
// dayDivider: timestamp ๊ธฐ์ค ๋ ์ง๊ฐ ๋ฐ๋๋ฉด ๊ตฌ๋ถ์ ์๋ ์ฝ์
<MessageList messages={messages} dayDivider>
{pending && <TypingIndicator />}
</MessageList>
// ์ง์ ์ฐ๋ ค๋ฉด
<ChatBubble role="user">์ค๋ ์ผ์ ๋ณด์ฌ์ค</ChatBubble>
<ChatBubble role="assistant">3๊ฑด ์์ด์.</ChatBubble>LLM ํด์ฝ์ฒ๋ผ "์ ์ โ ํ์ธ โ ์คํ" ํ๋ฆ์ ๋ฉ์์ง ์์ ๋ฐ์ ๋. ๋ชจ๋ฌ์ด ์๋๋ผ ํ๋ฆ์ ์ธ๋ผ์ธ๋ฉ๋๋ค.
import { ActionCard } from "@m1kapp/kit";
const [state, setState] = useState<"pending" | "loading" | "done" | "cancelled">("pending");
<ActionCard
title="์ด๋ ๊ฒ ๊ธฐ๋กํด๋๊น์?"
state={state}
items={["๐ 6/4 15:00 ๋์์ธ ๋ฆฌ๋ทฐ", "๐ 6/4 (์ข
์ผ) ๋ง๊ฐ์ผ"]}
onConfirm={() => { setState("loading"); /* โฆ */ setState("done"); }}
onCancel={() => setState("cancelled")}
/>
// state๋ณ ์/๋ฌธ๊ตฌ ์๋: pending โ done(์ด๋ก) / cancelled(์ทจ์์ ) / loadingimport { ListRow } from "@m1kapp/kit";
<ListRow
accent="#7fc06a" // ์ผ์ชฝ ์ปฌ๋ฌ๋ฐ (์๋ต ์ --kit-accent)
lead="14:00" leadSub="15:00"
title="๋์์ธ ๋ฆฌ๋ทฐ" sub="๐ฅ ๊น์ํ"
trailing="โ ์ง๊ธ" active // ํ์ฌ ํญ๋ชฉ ๊ฐ์กฐ
heightScale={60 / 30} // ๋์ด ๋ฐฐ์จ (1=๊ธฐ๋ณธ, 46โ130px). ์ผ์ ์ ๋ถ/30์ ๋๊น
onClick={open}
/>import { LinkifiedText } from "@m1kapp/kit";
<LinkifiedText>{"ํ์๋ก: https://docs.google.com/โฆ\nํ์: meet.google.com/abc-def"}</LinkifiedText>
// http(s) URL + (๊ธฐ๋ณธ) meet.google.com ๊ฐ์ ๋๋ฉ์ธ/๊ฒฝ๋ก๋ฅผ ์๋ ๋งํฌ. ์ค๋ฐ๊ฟ ๋ณด์กด.import { Stepper, Collapsible, ProgressRing } from "@m1kapp/kit";
// ๋ค๋จ๊ณ ์งํ ํ์ (์์ ๋/๋ฉํฐํ์ด์ฆ ํ๋ก์ฐ)
<Stepper current={phase} onStepClick={setPhase} steps={[
{ label: "๋ถ์", icon: "๐" }, { label: "์ค๋น", icon: "๐ธ" }, { label: "์์ฑ", icon: "โจ" },
]} />
// ์ ๊ธฐ ์นด๋ (ํค๋+๋ฐฐ์ง+์ํ)
<Collapsible leading={1} title="ํ๋ฅด์๋ ๋ถ์" subtitle="์คํ์ผ ํ์
" completed
open={open === 1} onToggle={() => setOpen(open === 1 ? null : 1)}>
<p>๋ณธ๋ฌธโฆ</p>
</Collapsible>
// ์ํ ์งํ๋ฅ
<ProgressRing value={7} max={10}><span className="text-2xl font-black">7</span></ProgressRing>import { Select, ColorPicker, InlineEdit } from "@m1kapp/kit";
// ์ต์ปค๋ ๋๋กญ๋ค์ด (์นด์ดํธยท๋นํ์ฑ ์ต์
, ์ธ๋ถํด๋ฆญ/ESC ๋ซํ)
<Select value={cat} onChange={setCat} placeholder="๋์ด๋" options={[
{ value: "a", label: "์ด๊ธ", count: 12 }, { value: "b", label: "๊ณ ๊ธ", count: 0, disabled: true },
]} />
// ์ปฌ๋ฌ ํผ์ปค (ํ๋ฆฌ์
+ ์ปค์คํ
hex)
<ColorPicker value={color} onChange={setColor} />
// ํญํ์ฌ ํธ์ง (Enter ์ ์ฅ ยท Esc ์ทจ์)
<InlineEdit value={name} onChange={rename} className="text-lg font-bold" />import { CopyButton, CodeBlock, useCopy, BarList, Countdown, Carousel, Img } from "@m1kapp/kit";
<CopyButton text="npm i @m1kapp/kit">์ค์น ๋ณต์ฌ</CopyButton>
<CodeBlock label="install" code="npm i @m1kapp/kit" />
const { copied, copy } = useCopy(); // keyed: copy(text, "row-3")
// ๊ฐ๋ก ๋ง๋ ๋ถ์ ์ฐจํธ
<BarList items={[{ label: "/", value: 120 }, { label: "/about", value: 64, href: "/about" }]} />
// ์นด์ดํธ๋ค์ด (D-day)
<Countdown to="2026-12-31" onComplete={celebrate} />
// ์ค์์ดํ ์บ๋ฌ์
(controlled, ์ ์ธ๋์ผ์ดํฐ)
<Carousel count={slides.length} index={i} onChange={setI}>{slides[i]}</Carousel>
// ๋ค์ค URL ํด๋ฐฑ ์ด๋ฏธ์ง
<Img candidates={[avatarUrl, gravatar]} fallback={<Avatar fallback="MH" />} className="h-10 w-10 rounded-full" />import { Watermark } from "@m1kapp/kit";
<Watermark color="#3b82f6" text="myapp">
{children}
</Watermark>Watermark๋ ํ๋จ์ PoweredByKit ํฌ๋ ๋ง์ ์๋ ๋ด์ฅํฉ๋๋ค.
Watermark/PoweredByKit ํ๋จ ํฌ๋ ๋ง์ด ๋ฐฉ๋ฌธ์ ์๋ฅผ ์ง๊ณํ ์ ์์ต๋๋ค. slug๊ฐ ์์ ๋๋ง ์ผ์ง๊ณ , ์์ผ๋ฉด ์๋ฌด๊ฒ๋ ์ ์กํ์ง ์์์(๊ธฐ๋ณธ off). ์ง๊ณ๋ ํ์ด์ง๋ทฐ ์นด์ดํธ๋ฟ โ PII ์์.
# 1) ์ฌ์ดํธ ๋ฑ๋ก (๋ฌด๋ก๊ทธ์ธ) โ slug ๋ฐ๊ธ
npx m1kkit track https://myside.app# 2) Next.js๋ฉด .env ์ ํ ์ค โ ์ดํ ์๋ ์ง๊ณ (์ค๋ํซ ๋ถ์ฌ๋ฃ๊ธฐ ๋ถํ์)
NEXT_PUBLIC_M1K_SLUG=your-slugVite ๋ฑ Next.js๊ฐ ์๋ ์ฑ์ NEXT_PUBLIC_* env๊ฐ ์ ์ฝํ๋ trackSlug prop์ผ๋ก ์ง์ ์ง์ ํ์ธ์:
// ๋๊ธฐ / ๋ช
์ ์ง์
<Watermark track={false}>โฆ</Watermark> // ๋น์ฝ ๋
<Watermark trackSlug="your-slug">โฆ</Watermark> // env ๋์ ์ง์ ์ง์ (Vite๋ ์ด ๋ฐฉ์ ์ฌ์ฉ)
// PoweredByKit ๋จ๋
์ฌ์ฉ ์: <PoweredByKit slug="your-slug" track={false} />
npx m1kkit claim์ผ๋ก ์ต๋ช ๋ฑ๋ก ์ฌ์ดํธ๋ฅผ ๋ด m1k.app ๊ณ์ ์ ๊ท์ํ ์ ์์ด์. (.m1k.json์ ์ผํ์ฉ ํ ํฐ + ๋ก๊ทธ์ธ์ผ๋ก ์ธ์ฆ.) ๊ท์ํ๋ ๊น์ kit์ด ์ด ํ๋ก์ ํธ์์ ์ฝ๋๋ฅผ ์ผ๋ง๋ ์๊ปด์คฌ๋์ง ๋ถ์๋ ๊ฐ์ด ์ถ๋ ฅํด์ โ--no-stats๋ก ๋ ์ ์์ด์.
Next.js 14+๋ next/og๊ฐ ๋ด์ฅ๋์ด ์์ด ๋ณ๋ ์ค์น ๋ถํ์. ๊ทธ ์ธ ํ๊ฒฝ์ npm i @vercel/og.
// app/og/route.tsx
import { OGImage, loadPretendard } from "@m1kapp/kit";
import { ImageResponse } from "next/og";
export async function GET() {
const font = await loadPretendard();
return new ImageResponse(
<OGImage
type="default"
title="์ฌ์ด๋ ํ๋ก์ ํธ ์์ํ๊ธฐ"
sub="๋น ๋ฅด๊ฒ ๋ง๋ค๊ณ ๋น ๋ฅด๊ฒ ๋ฐฐ์ฐ๋"
badge="๐ NEW"
appName="myapp"
color="#3b82f6"
bg="dark" // "dark" | "gradient" | "blend"
domain="m1k.app"
/>,
{ width: 1200, height: 630, fonts: [font] }
);
}| type | ํฌ๊ธฐ | ์ฉ๋ |
|---|---|---|
default |
1200ร630 | ๊ธฐ๋ณธ OG |
article |
1200ร630 | ๋ธ๋ก๊ทธ ํฌ์คํธ โ author, date, category |
stat |
1200ร630 | ๋ง์ผ์คํค โ stat, label |
product |
1200ร630 | ์ ํ ์๊ฐ โ tagline, features[] |
match |
1200ร630 | ๊ฒฝ๊ธฐ ๊ฒฐ๊ณผ โ home, away, score |
square |
1200ร1200 | Instagram / SNS |
icon |
512ร512 | ์ฑ ์์ด์ฝ / favicon |
// article
<OGImage type="article" title="์ ๋ชฉ" author="minho" date="2025-04-19" category="Tutorial" sub="๋ถ์ " color={c} bg={bg} />
// stat
<OGImage type="stat" stat="1,000" label="๋ช
์ ๋ฐฉ๋ฌธ์" sub="๋ก ์นญ 3์ผ ๋ง์" badge="๐" color={c} bg={bg} />
// product
<OGImage type="product" title="@m1kapp/kit" tagline="์ฌ์ธ์ ํท" features={["๊ธฐ๋ฅ1", "๊ธฐ๋ฅ2"]} color={c} bg={bg} />Vite ๋ฑ ์๋ฒ ์๋ SPA๋ผ app/og/route.tsx๋ฅผ ๋ชป ๋ง๋ ๋ค๋ฉด, ๋น๋ ์์ด CLI๋ก
ํ๋น์ฝ๋ถํฐ OG ์ด๋ฏธ์ง, manifest๊น์ง ํ ๋ฒ์ ์์ฑํ์ธ์:
npx m1kkit favicon --appname="My App" --slogan="ํ ์ค ์๊ฐ" --color=#3b82f6 --domain=myapp.comfavicon.ico ยท apple-touch-icon.png ยท icon-192.png ยท icon-512.png ยท
icon-maskable-512.png ยท og-image.png(1200ร630, ์ default ํ
ํ๋ฆฟ ์ฌ์ฉ) ยท
manifest.json 7๊ฐ๊ฐ public/(์๋ ๊ฐ์ง)์ ํ ๋ฒ์ ์๊ธฐ๊ณ , <head>์ ๋ถ์ฌ๋ฃ์
ํ๊ทธ ์ค๋ํซ๋ ์ฝ์์ ์ถ๋ ฅ๋ฉ๋๋ค.
import { loadPretendard, loadGoogleFont, createEmojiLoader } from "@m1kapp/kit";
const pretendard = await loadPretendard(); // Pretendard ํ๊ตญ์ด ํฐํธ
const roboto = await loadGoogleFont("Roboto", 700); // Google Fonts
const loadEmoji = createEmojiLoader("twemoji"); // ์ด๋ชจ์ง fallbackpublic/manifest.json ๋์ ์ฝ๋๋ก ๊ด๋ฆฌ. ์์ด์ฝ ์ด๋ฏธ์ง ํ์ผ ๋ถํ์.
// app/manifest.ts
import { createManifest } from "@m1kapp/kit";
export default createManifest({
name: "My App",
shortName: "App",
description: "What this app does",
themeColor: "#3b82f6", // ์์ด์ฝ ๋ฐฐ๊ฒฝ์์ผ๋ก๋ ์ฌ์ฉ
backgroundColor: "#ffffff",
icon: { text: "MA" }, // ํ
์คํธ๋ก 192ร192, 512ร512 SVG ์์ด์ฝ ์๋ ์์ฑ
});iOS 10+์์ ํ์น ์ค๊ณผ ์ธํ ์๋ ํ๋๋ฅผ ๋ง์ต๋๋ค. viewportFit: "cover"๋ก ๋
ธ์น / Dynamic Island ๊ธฐ๊ธฐ์์ safe area inset๋ ์ง์ํฉ๋๋ค.
// app/layout.tsx
import { mobileViewport } from "@m1kapp/kit";
export const viewport = mobileViewport;๋ด๋ถ์ ์ผ๋ก CSS touch-action: pan-x pan-y์ input { font-size: max(16px, 1em) }๋ฅผ ์๋ ์ ์ฉํฉ๋๋ค.
import { svgIcon } from "@m1kapp/kit";
const src = svgIcon("MA", { size: 192, bg: "#3b82f6", color: "#ffffff", radius: 0.25 });
// โ "data:image/svg+xml,..." โ <img src={src} /> ๋๋ manifest icons์ ๋ฐ๋ก ์ฌ์ฉAndroid๋ ๋ค์ดํฐ๋ธ ์ค์น ํ๋กฌํํธ, iOS๋ ํ ํ๋ฉด ์ถ๊ฐ ์๋ด ์ํธ๋ฅผ ์๋์ผ๋ก ๋์์ค๋๋ค.
import { PWAInstallButton, IOSInstallSheet, usePWAInstall } from "@m1kapp/kit";
// ๋ฒํผ ๊ทธ๋๋ก ์ฌ์ฉ
<PWAInstallButton appName="My App" iconSrc={iconSrc} label="์ฑ์ผ๋ก ์ค์น" />
// ์ปค์คํ
UI
const { state, install } = usePWAInstall();
// state: "android-ready" | "ios-safari" | "installed" | "unsupported"
if (state === "android-ready") {
return <button onClick={install}>์ค์น</button>;
}
if (state === "ios-safari") {
return <button onClick={() => setSheetOpen(true)}>์ค์น</button>;
}
// iOS ์๋ด ์ํธ (์ง์ ์ ์ด ์)
<IOSInstallSheet open={sheetOpen} onClose={() => setSheetOpen(false)} appName="My App" iconSrc={iconSrc} />์์กด์ฑ ์ ๋ก. ์บ์ฑ ยท ์ค๋ณต์ ๊ฑฐ ยท ์ฌ์๋ ยท ํฌ์ปค์ค revalidate๊ฐ ๋ด์ฅ๋ fetch ์ ํธ.
import { useFetch } from "@m1kapp/kit";
const { data, loading, error, refetch } = useFetch<User[]>("/api/users", {
staleTime: 30_000, // 30์ด ์บ์ โ ๊ฐ์ URL ์ค๋ณต ์์ฒญ ์์
retry: 2, // ๋คํธ์ํฌ ์ค๋ฅ ์ ์ง์ ๋ฐฑ์คํ๋ก 2ํ ์ฌ์๋
revalidateOnFocus: true, // ํญ ๋์์ค๋ฉด ์๋ ์ต์ ๋ฐ์ดํฐ
});
// ๋ก๋ฉ ์ฒ๋ฆฌ
if (loading && !data) return <PostListSkeleton />;
if (error) return <p>{error.message}</p>;
return data?.map(u => <UserCard key={u.id} user={u} />);์ค์๊ฐ ๋ฐ์ดํฐ, ๋ผ์ด๋ธ ์ค์ฝ์ด ๋ฑ์ ์ฌ์ฉ.
import { usePolling } from "@m1kapp/kit";
const { data, isRunning, start, stop } = usePolling(
() => fetch("/api/match/live").then(r => r.json()),
{
interval: 5000, // 5์ด๋ง๋ค
enabled: true, // ์์ ์ฌ๋ถ
pauseOnHidden: true, // ํญ ์จ๊ธฐ๋ฉด ์๋ ์ ์ง โ ๋ถํ์ํ ์์ฒญ ์์
}
);
<button onClick={() => isRunning ? stop() : start()}>
{isRunning ? "์ ์ง" : "์์"}
</button>baseURL๊ณผ ๊ณตํต ํค๋๋ฅผ ํ ๋ฒ๋ง ์ค์ ํ๋ฉด ํ์ ์์ ํ API ํด๋ผ์ด์ธํธ๊ฐ ๋ง๋ค์ด์ง๋๋ค.
// lib/api.ts
import { createApiClient, ApiError } from "@m1kapp/kit";
export const api = createApiClient("https://api.myapp.com", {
headers: { Authorization: `Bearer ${token}` },
onError: (err) => {
if (err.status === 401) signOut();
},
});
// ์ฌ์ฉ
const me = await api.get<User>("/users/me");
const post = await api.post<Post>("/posts", { title, body });
await api.put("/posts/1", { title: "์์ ๋ ์ ๋ชฉ" });
await api.delete("/posts/1");
// ์๋ฌ๋ ApiError๋ก ์ ๊ทํ
try {
await api.delete("/posts/1");
} catch (e) {
if (e instanceof ApiError) {
console.log(e.status, e.body); // 404, { error: "Not found" }
}
}Next.js API route ์ ์ฉ. @m1kapp/kit/server๋ก import โ ํด๋ผ์ด์ธํธ ๋ฒ๋ค์ ํฌํจ๋์ง ์์ต๋๋ค.
try/catch ์์ด ์๋ฌ๋ฅผ ์ฒ๋ฆฌํฉ๋๋ค. unauthorized(), notFound() ๋ฑ์ never๋ฅผ ๋ฐํํ๋ฏ๋ก TypeScript๊ฐ ์ ์ด ํ๋ฆ์ ์ ํํ ์ถ๋ก ํฉ๋๋ค.
import { handler, ok, created, unauthorized, forbidden, notFound, badRequest } from "@m1kapp/kit/server";
// Before โ
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
try {
const data = await db.sites.findMany({ where: { userId: user.id } });
return Response.json(data);
} catch {
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// After โ
export const GET = handler(async () => {
const user = await currentUser();
if (!user) unauthorized(); // throws โ 401
const data = await db.sites.findMany({ where: { userId: user.id } });
return ok(data); // 200 + JSON
// ์ฒ๋ฆฌ๋์ง ์์ ์๋ฌ โ 500 ์๋
});
export const POST = handler(async (req) => {
const user = await currentUser();
if (!user) unauthorized();
const { url } = await req.json();
if (!url) badRequest("url์ด ํ์ํด์"); // throws โ 400
const site = await db.sites.create({ data: { url, userId: user.id } });
return created(site); // 201 + JSON
});| ํจ์ | ์ํ | ์ค๋ช |
|---|---|---|
ok(data) |
200 | JSON ์๋ต |
created(data) |
201 | ์์ฑ ์๋ฃ |
noContent() |
204 | ๋ณธ๋ฌธ ์์ |
badRequest(msg?) |
400 | ์๋ชป๋ ์์ฒญ |
unauthorized(msg?) |
401 | ์ธ์ฆ ํ์ |
forbidden(msg?) |
403 | ๊ถํ ์์ |
notFound(msg?) |
404 | ๋ฆฌ์์ค ์์ |
conflict(msg?) |
409 | ์ถฉ๋ |
serverError(msg?) |
500 | ์๋ฒ ์ค๋ฅ |
ํน์ ์๋ฌ๋ฅผ try/catch ์์ด ์ฒ๋ฆฌํ๊ณ ์ถ์ ๋.
import { handler, ok, serverError, safely } from "@m1kapp/kit/server";
export const GET = handler(async () => {
const { ok: success, data, error } = await safely(() => db.users.findFirstOrThrow());
if (!success) return serverError("DB ์กฐํ ์คํจ");
return ok(data);
});API ๋ผ์ฐํธ์์ ์์ฃผ ์์ผ๋ก ๋ง๋ค๋ ๊ฒ๋ค โ ์ ๋ถ @m1kapp/kit/server์ ์์ด์.
import {
requireEnv, fetchWithRetry, withRetry, recoverJsonFromText,
scrapeOg, todayKST, dateInTz, idToSlug, slugToId, appHost,
} from "@m1kapp/kit/server";
// ํ์ env ๊ฒ์ฆ (์์ผ๋ฉด 500 throw, ์์ผ๋ฉด ํ์
๋ ๊ฐ์ฒด)
const { XAI_API_KEY } = requireEnv(["XAI_API_KEY"]);
// fetch + ํ์์์ + 429/5xx ์๋ ์ฌ์๋ (๋ง์ง๋ง ์๋ต ๋ฐํ)
const res = await fetchWithRetry(url, { headers: { authorization: `Bearer ${XAI_API_KEY}` } });
// ์๋ฌด async๋ ์ฌ์๋ (Neon ์ฝ๋์คํํธ ๋ฑ)
const rows = await withRetry(() => db.query.users.findMany(), {
shouldRetry: (e) => String(e).includes("fetch failed"),
});
// LLM ์๋ต์์ JSON ๋ณต๊ตฌ (```json ํ์คยทํธ๋ ์ผ๋ง ์ฝค๋งยท๋
ธ์ด์ฆ ์ ๊ฑฐ)
const data = recoverJsonFromText<{ items: string[] }>(llmReply);
// Open Graph ์คํฌ๋ํ
const og = await scrapeOg("example.com"); // { title, description, image, siteName, url }
// ํ์์กด ๋ ์ง
todayKST(); // "2026-06-04"
dateInTz(Date.now(), "America/New_York");
// base62 slug + ํธ์คํธ
idToSlug(42, 1000); // "..." (slugToId๋ก ์ญ๋ณํ)
const base = `https://${appHost("m1k.app")}`;์์ ํจ์ โ ์์กด์ฑ ์์, ์ด๋์๋ import.
import { relativeTime, formatNumber, formatPrice, cn, formatDuration, groupByDay } from "@m1kapp/kit";
// ์๋ ์๊ฐ
relativeTime(post.createdAt) // "3๋ถ ์ ", "์ด์ ", "2025. 4. 19."
// ์์ ์๊ฐ ํฌ๋งท
formatDuration(90_000) // "1๋ถ 30์ด"
formatDuration(3_661_000, { style: "clock" }) // "1:01:01"
// ๋ ์ง๋ณ ๊ทธ๋ฃนํ โ [{ date, label: "์ค๋"|"์ด์ "|"4์ 19์ผ (ํ )", items }]
groupByDay(logs, (l) => l.timestamp)
// ์ซ์ ํฌ๋งท
formatNumber(1_500) // "1.5์ฒ"
formatNumber(15_000) // "1.5๋ง"
formatNumber(150_000_000) // "1.5์ต"
// ๊ฐ๊ฒฉ ํฌ๋งท
formatPrice(9_900) // "โฉ9,900"
formatPrice(9.99, "USD") // "$9.99"
// ์กฐ๊ฑด๋ถ ํด๋์ค โ Tailwind ์ถฉ๋ ์๋ ํด๊ฒฐ (clsx + tailwind-merge ๋ด์ฅ)
cn("base", isActive && "active", err && "border-red-500")
// โ "base active"
cn("px-2 py-1", "px-4") // โ "py-1 px-4" (์ถฉ๋ ํด๊ฒฐ)
cn({ "opacity-50": disabled }) // ๊ฐ์ฒด ๋ฌธ๋ฒ ์ง์import { useDebounce, useFormSubmit, useInView, useLocalStorage } from "@m1kapp/kit";const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) searchAPI(debouncedQuery); // ํ์ดํ ๋ฉ์ถ ๋๋ง ์คํ
}, [debouncedQuery]);๋ชจ๋ form handler์ loading / error / try-catch / finally ๋ณด์ผ๋ฌํ๋ ์ดํธ๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
const { submit, loading, error, data, reset } = useFormSubmit(
async (url: string) => api.post<Site>("/api/sites", { url }),
{ onSuccess: (site) => router.push(`/sites/${site.id}`) }
);
<form onSubmit={e => { e.preventDefault(); submit(inputValue); }}>
<input value={inputValue} onChange={...} />
{error && <p className="text-red-500 text-sm">{error.message}</p>}
<button disabled={loading}>{loading ? "๋ฑ๋ก ์คโฆ" : "๋ฑ๋ก"}</button>
</form>๋ฌดํ์คํฌ๋กค ํธ๋ฆฌ๊ฑฐ, ๋ ์ด์ง ๋ก๋, ๋ฑ์ฅ ์ ๋๋ฉ์ด์ ์ ์ฌ์ฉ.
const { ref, inView } = useInView({ threshold: 0.1, once: true });
useEffect(() => {
if (inView) fetchNextPage();
}, [inView]);
return (
<div>
{posts.map(p => <PostCard key={p.id} post={p} />)}
<div ref={ref} /> {/* ๋ฆฌ์คํธ ๋งจ ์๋ ์ผํฐ๋ฌ */}
</div>
);์๋ก๊ณ ์นจ ํ์๋ ์ ์ง๋๋ ๋ก์ปฌ ์ํ. SSR ์์ .
const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");
setTheme("dark"); // localStorage์ ์ ์ฅ
removeTheme(); // localStorage์์ ์ญ์ , ์ด๊ธฐ๊ฐ์ผ๋ก ๋ณต์