์ฌ์ด๋ ํ๋ก์ ํธ๋ฅผ ์ํ 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";exportdefaultfunctionApp(){return(// Watermark๊ฐ ๋ทฐํฌํธ ์ ์ฒด ๋์ด/์ค์์ ๋ ฌ/ํ๋จ ํฌ๋ ๋ง์ ๊ฐ์ด ์ค๋๋ค.// (ํฌ๋ ๋ง ์คํธ๋ฆฝ์ ์
ธ ๋ฐ๋ก ๋ฐ์ ๋ถ์ด์ ์
ธ๊ณผ ํ ๋ฉ์ด๋ฆฌ๋ก ์ค์์ ๋ ฌ๋จ โ ํ๋ฉด ์ง์ง ๋ฐ๋ฅ์ ๊ณ ์ ๋๋ ๊ฒ ์๋)// AppShell๋ง ๋จ๋
์ผ๋ก ์ฐ๋ฉด ์ฝํ
์ธ ๋์ด๋งํผ ์ชผ๊ทธ๋ผ๋ค์ด์.<Watermarkcolor="#e2603f"text="myapp"><AppShell><AppShellHeader><h1>My App</h1></AppShellHeader><AppShellContent><SectionclassName="pt-4">{/* ์ฝํ
์ธ โ Divider๋ก ์น์
๊ตฌ๋ถ */}</Section></AppShellContent><TabBar><Tabhref="/"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><Tabactive={tab==="home"}onClick={()=>setTab("home")}label="ํ"icon={<HomeIcon/>}activeColor="#3b82f6"// ํ์ฑ ์์ ์์ ๋กญ๊ฒ ์ง์ /></TabBar>import{Avatar,Badge,StatChip,EmptyState,GrassMap}from"@m1kapp/kit";// Avatar โ ์ด๋์
or ์ด๋ฏธ์ง, ์ด๋ฏธ์ง ๋ก๋ ์คํจ ์ ์ด๋์
๋ก ์๋ fallback<Avatarsrc="/photo.jpg"fallback="MH"size="md"shape="circle"/><Avatarfallback="MH"size="lg"shape="rounded"color="#3b82f6"/>// size: "xs" | "sm" | "md" | "lg" | "xl"// shape: "circle" | "rounded"// Badge โ ์ํ/์นดํ
๊ณ ๋ฆฌ ๋ ์ด๋ธ<Badgevariant="green">LIVE</Badge><Badgevariant="red">์ค๋ฅ</Badge><Badgevariant="blue"size="sm">์ ๋ณด</Badge>// variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"// StatChip โ ์ซ์ stat ๋ฑ์ง<StatChiplabel="๋ฐฉ๋ฌธ์"value={1024}/>// EmptyState โ ๋น ๋ชฉ๋ก ํ๋ ์ด์คํ๋<EmptyStatemessage="์์ง ์๋ฌด๊ฒ๋ ์์ด์"/>// GrassMap โ GitHub ์คํ์ผ ํ๋ ํํธ๋งต<GrassMapdata={[{date: "2025-04-19",count: 42}]}accent="#3b82f6"/>๋ก๋ฉ ํ๋ ์ด์คํ๋. className์ผ๋ก ํฌ๊ธฐ๋ฅผ ์ง์ ํฉ๋๋ค.
import{Skeleton}from"@m1kapp/kit";// ํ
์คํธ ์ค<SkeletonclassName="h-4 w-3/4"/>// ์นด๋ ๋ธ๋ก<SkeletonclassName="h-32 w-full"rounded="xl"/>// ์๋ฐํ<SkeletonclassName="h-10 w-10"rounded="full"/>// ์ค์ ํจํดfunctionPostCardSkeleton(){return(<divclassName="flex gap-3 p-4"><SkeletonclassName="h-10 w-10"rounded="full"/><divclassName="flex-1 space-y-2"><SkeletonclassName="h-4 w-2/3"/><SkeletonclassName="h-3 w-1/2"/></div></div>);}backdrop ํด๋ฆญ, ESC ํค, ์คํฌ๋กค ์ ๊ธ ์๋ ์ฒ๋ฆฌ.
import{Dialog}from"@m1kapp/kit";// ๊ธฐ๋ณธ ์ฌ์ฉ<Dialogopen={open}onClose={()=>setOpen(false)}title="์ค์ "><pclassName="text-sm text-zinc-500">๋ด์ฉ</p></Dialog>// ํ์ธ ๋ค์ด์ผ๋ก๊ทธ<Dialogopen={confirmOpen}onClose={()=>setConfirmOpen(false)}title="์ญ์ ํ ๊น์?"><pclassName="text-sm text-zinc-500">์ด ์์
์ ๋๋๋ฆด ์ ์์ด์.</p><divclassName="flex gap-2 mt-4"><buttononClick={handleDelete}className="...">์ญ์ </button><buttononClick={()=>setConfirmOpen(false)}className="...">์ทจ์</button>
</div></Dialog>// size: "sm" (๊ธฐ๋ณธ) | "md" | "lg"// persistent: true โ backdrop ํด๋ฆญ / ESC๋ก ๋ซ๊ธฐ ๋นํ์ฑํimport{Button,Tooltip,Typewriter,EmojiButton,EmojiPicker}from"@m1kapp/kit";<ButtononClick={fn}>์์ํ๊ธฐ</Button><Tooltiplabel="์ค๋ช
ํ
์คํธ"><button>hover me</button></Tooltip><Typewriterwords={["Hello","World"]}color="#3b82f6"/>// ์ด๋ชจ์ง ์ ํ๊ธฐconst[emoji,setEmoji]=useState("๐ ");const[open,setOpen]=useState(false);<EmojiButtonemoji={emoji}onClick={()=>setOpen(true)}/><EmojiPickeropen={open}onClose={()=>setOpen(false)}current={emoji}onSelect={setEmoji}/>import{ShareButton,useShare}from"@m1kapp/kit";// ๋ฒํผ ๊ทธ๋๋ก ์ฌ์ฉ โ ๋ชจ๋ฐ์ผ์ ๋ค์ดํฐ๋ธ ๊ณต์ , ๋ฐ์คํฌํ์ ํด๋ฆฝ๋ณด๋ ๋ณต์ฌ<ShareButtonurl="https://m1k.app"title="My App"/>// ์ปค์คํ
UIconst{ share, copied, canNativeShare }=useShare({url: "https://m1k.app"});<buttononClick={()=>share()}>{copied ? "๋ณต์ฌ๋จ!" : "๊ณต์ "}</button>import{ToastProvider,useToast}from"@m1kapp/kit";// ์ฑ ๋ฃจํธ ๊ฐ์ธ๊ธฐ<ToastProvider><App/></ToastProvider>// ์ด๋์๋consttoast=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><scriptdangerouslySetInnerHTML={{__html: THEME_SCRIPT}}/></head>// ํ
๋ง ๋ฒํผ + ๋ค์ด์ผ๋ก๊ทธconst[themeOpen,setThemeOpen]=useState(false);<ThemeButtoncolor={color}dark={dark}onClick={()=>setThemeOpen(true)}/><ThemeDialogopen={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 โ ๊ฐ์ฅ ์ฌ์ (ํ์ ์ ์ฒด์ ์ ์ฉ)<AppShellaccent="#e2603f">...</AppShell>// โก ์๋ฌด ๋ํผ์๋ CSS ๋ณ์๋ก<divstyle={{"--kit-accent": "#e2603f"}asReact.CSSProperties}>...</div>// โข ์ปดํฌ๋ํธ๋ณ override<Switchchecked={on}onChange={setOn}accent="#e2603f"/>import{Field,Switch}from"@m1kapp/kit";// ๋ผ๋ฒจ๋ ์ธํ (stacked ๊ธฐ๋ณธ / inline / multiline)<Fieldlabel="์ด๋ฆ"value={name}onChange={setName}placeholder="์ด๋ฆ"/><Fieldlabel="์ด๋ฉ์ผ"value={email}inlinereadOnly/><Fieldlabel="๋ฉ๋ชจ"value={memo}onChange={setMemo}multilinerows={3}hint="์์ ๋กญ๊ฒ ์ ์ด์ฃผ์ธ์"/>// on/off ํ ๊ธ โ accent๋ --kit-accent ์๋ ์ฐ๋<Switchchecked={on}onChange={setOn}aria-label="์๋ฆผ"/>import{SegmentedControl}from"@m1kapp/kit";<SegmentedControlvalue={view}onChange={setView}options={[{value: "today",label: "์ค๋"},{value: "week",label: "์ด๋ฒ ์ฃผ"}]}/>// ์ธ๋ผ์ธ ํ ๊ธ์ฉ. ํ๋จ ๊ธ๋ก๋ฒ ๋ด๋น๊ฒ์ด์
์ TabBar๋ฅผ ์ฐ์ธ์.import{MessageList,ChatBubble,TypingIndicator}from"@m1kapp/kit";importtype{ChatMessage}from"@m1kapp/kit";constmessages: ChatMessage[]=[{role: "user",content: "๋ด์ผ 3์ ํ์ ์ก์์ค",timestamp: Date.now()},{role: "assistant",content: "๋ค, ์ก์๋๋ฆด๊ฒ์."},];// dayDivider: timestamp ๊ธฐ์ค ๋ ์ง๊ฐ ๋ฐ๋๋ฉด ๊ตฌ๋ถ์ ์๋ ์ฝ์
<MessageListmessages={messages}dayDivider>{pending&&<TypingIndicator/>}</MessageList>// ์ง์ ์ฐ๋ ค๋ฉด<ChatBubblerole="user">์ค๋์ผ์ ๋ณด์ฌ์ค</ChatBubble><ChatBubblerole="assistant">3๊ฑด ์์ด์.</ChatBubble>LLM ํด์ฝ์ฒ๋ผ "์ ์ โ ํ์ธ โ ์คํ" ํ๋ฆ์ ๋ฉ์์ง ์์ ๋ฐ์ ๋. ๋ชจ๋ฌ์ด ์๋๋ผ ํ๋ฆ์ ์ธ๋ผ์ธ๋ฉ๋๋ค.
import{ActionCard}from"@m1kapp/kit";const[state,setState]=useState<"pending"|"loading"|"done"|"cancelled">("pending");<ActionCardtitle="์ด๋ ๊ฒ ๊ธฐ๋กํด๋๊น์?"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";<ListRowaccent="#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";// ๋ค๋จ๊ณ ์งํ ํ์ (์์ ๋/๋ฉํฐํ์ด์ฆ ํ๋ก์ฐ)<Steppercurrent={phase}onStepClick={setPhase}steps={[{label: "๋ถ์",icon: "๐"},{label: "์ค๋น",icon: "๐ธ"},{label: "์์ฑ",icon: "โจ"},]}/>// ์ ๊ธฐ ์นด๋ (ํค๋+๋ฐฐ์ง+์ํ)<Collapsibleleading={1} title="ํ๋ฅด์๋ ๋ถ์"subtitle="์คํ์ผ ํ์
"completedopen={open ===1}onToggle={()=>setOpen(open===1 ? null : 1)}><p>๋ณธ๋ฌธโฆ</p></Collapsible>// ์ํ ์งํ๋ฅ <ProgressRingvalue={7}max={10}><spanclassName="text-2xl font-black">7</span></ProgressRing>import{Select,ColorPicker,InlineEdit}from"@m1kapp/kit";// ์ต์ปค๋ ๋๋กญ๋ค์ด (์นด์ดํธยท๋นํ์ฑ ์ต์
, ์ธ๋ถํด๋ฆญ/ESC ๋ซํ)<Selectvalue={cat}onChange={setCat}placeholder="๋์ด๋"options={[{value: "a",label: "์ด๊ธ",count: 12},{value: "b",label: "๊ณ ๊ธ",count: 0,disabled: true},]}/>// ์ปฌ๋ฌ ํผ์ปค (ํ๋ฆฌ์
+ ์ปค์คํ
hex)<ColorPickervalue={color}onChange={setColor}/>// ํญํ์ฌ ํธ์ง (Enter ์ ์ฅ ยท Esc ์ทจ์)<InlineEditvalue={name}onChange={rename}className="text-lg font-bold"/>import{CopyButton,CodeBlock,useCopy,BarList,Countdown,Carousel,Img}from"@m1kapp/kit";<CopyButtontext="npm i @m1kapp/kit">์ค์น ๋ณต์ฌ</CopyButton><CodeBlocklabel="install"code="npm i @m1kapp/kit"/>const{ copied, copy }=useCopy();// keyed: copy(text, "row-3")// ๊ฐ๋ก ๋ง๋ ๋ถ์ ์ฐจํธ<BarListitems={[{label: "/",value: 120},{label: "/about",value: 64,href: "/about"}]}/>// ์นด์ดํธ๋ค์ด (D-day)<Countdownto="2026-12-31"onComplete={celebrate}/>// ์ค์์ดํ ์บ๋ฌ์
(controlled, ์ ์ธ๋์ผ์ดํฐ)<Carouselcount={slides.length}index={i}onChange={setI}>{slides[i]}</Carousel>// ๋ค์ค URL ํด๋ฐฑ ์ด๋ฏธ์ง<Imgcandidates={[avatarUrl,gravatar]}fallback={<Avatarfallback="MH"/>}className="h-10 w-10 rounded-full"/>import{Watermark}from"@m1kapp/kit";<Watermarkcolor="#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์ผ๋ก ์ง์ ์ง์ ํ์ธ์:
// ๋๊ธฐ / ๋ช
์ ์ง์ <Watermarktrack={false}>โฆ</Watermark>// ๋น์ฝ ๋<WatermarktrackSlug="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.tsximport{OGImage,loadPretendard}from"@m1kapp/kit";import{ImageResponse}from"next/og";exportasyncfunctionGET(){constfont=awaitloadPretendard();returnnewImageResponse(<OGImagetype="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<OGImagetype="article"title="์ ๋ชฉ"author="minho"date="2025-04-19"category="Tutorial"sub="๋ถ์ "color={c}bg={bg}/>// stat<OGImagetype="stat"stat="1,000"label="๋ช
์ ๋ฐฉ๋ฌธ์"sub="๋ก ์นญ 3์ผ ๋ง์"badge="๐"color={c}bg={bg}/>// product<OGImagetype="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";constpretendard=awaitloadPretendard();// Pretendard ํ๊ตญ์ด ํฐํธconstroboto=awaitloadGoogleFont("Roboto",700);// Google FontsconstloadEmoji=createEmojiLoader("twemoji");// ์ด๋ชจ์ง fallbackpublic/manifest.json ๋์ ์ฝ๋๋ก ๊ด๋ฆฌ. ์์ด์ฝ ์ด๋ฏธ์ง ํ์ผ ๋ถํ์.
// app/manifest.tsimport{createManifest}from"@m1kapp/kit";exportdefaultcreateManifest({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.tsximport{mobileViewport}from"@m1kapp/kit";exportconstviewport=mobileViewport;๋ด๋ถ์ ์ผ๋ก CSS touch-action: pan-x pan-y์ input { font-size: max(16px, 1em) }๋ฅผ ์๋ ์ ์ฉํฉ๋๋ค.
import{svgIcon}from"@m1kapp/kit";constsrc=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";// ๋ฒํผ ๊ทธ๋๋ก ์ฌ์ฉ<PWAInstallButtonappName="My App"iconSrc={iconSrc}label="์ฑ์ผ๋ก ์ค์น"/>// ์ปค์คํ
UIconst{ state, install }=usePWAInstall();// state: "android-ready" | "ios-safari" | "installed" | "unsupported"if(state==="android-ready"){return<buttononClick={install}>์ค์น</button>;}if(state==="ios-safari"){return<buttononClick={()=>setSheetOpen(true)}>์ค์น</button>;}// iOS ์๋ด ์ํธ (์ง์ ์ ์ด ์)<IOSInstallSheetopen={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>;returndata?.map(u=><UserCardkey={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,// ํญ ์จ๊ธฐ๋ฉด ์๋ ์ ์ง โ ๋ถํ์ํ ์์ฒญ ์์});<buttononClick={()=>isRunning ? stop() : start()}>{isRunning ? "์ ์ง" : "์์"}</button>baseURL๊ณผ ๊ณตํต ํค๋๋ฅผ ํ ๋ฒ๋ง ์ค์ ํ๋ฉด ํ์ ์์ ํ API ํด๋ผ์ด์ธํธ๊ฐ ๋ง๋ค์ด์ง๋๋ค.
// lib/api.tsimport{createApiClient,ApiError}from"@m1kapp/kit";exportconstapi=createApiClient("https://api.myapp.com",{headers: {Authorization: `Bearer ${token}`},onError: (err)=>{if(err.status===401)signOut();},});// ์ฌ์ฉconstme=awaitapi.get<User>("/users/me");constpost=awaitapi.post<Post>("/posts",{ title, body });awaitapi.put("/posts/1",{title: "์์ ๋ ์ ๋ชฉ"});awaitapi.delete("/posts/1");// ์๋ฌ๋ ApiError๋ก ์ ๊ทํtry{awaitapi.delete("/posts/1");}catch(e){if(einstanceofApiError){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 โexportasyncfunctionGET(req: Request){constuser=awaitcurrentUser();if(!user)returnResponse.json({error: "Unauthorized"},{status: 401});try{constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnResponse.json(data);}catch{returnResponse.json({error: "Internal Server Error"},{status: 500});}}// After โ
exportconstGET=handler(async()=>{constuser=awaitcurrentUser();if(!user)unauthorized();// throws โ 401constdata=awaitdb.sites.findMany({where: {userId: user.id}});returnok(data);// 200 + JSON// ์ฒ๋ฆฌ๋์ง ์์ ์๋ฌ โ 500 ์๋});exportconstPOST=handler(async(req)=>{constuser=awaitcurrentUser();if(!user)unauthorized();const{ url }=awaitreq.json();if(!url)badRequest("url์ด ํ์ํด์");// throws โ 400constsite=awaitdb.sites.create({data: { url,userId: user.id}});returncreated(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";exportconstGET=handler(async()=>{const{ok: success, data, error }=awaitsafely(()=>db.users.findFirstOrThrow());if(!success)returnserverError("DB ์กฐํ ์คํจ");returnok(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 ์๋ ์ฌ์๋ (๋ง์ง๋ง ์๋ต ๋ฐํ)constres=awaitfetchWithRetry(url,{headers: {authorization: `Bearer ${XAI_API_KEY}`}});// ์๋ฌด async๋ ์ฌ์๋ (Neon ์ฝ๋์คํํธ ๋ฑ)constrows=awaitwithRetry(()=>db.query.users.findMany(),{shouldRetry: (e)=>String(e).includes("fetch failed"),});// LLM ์๋ต์์ JSON ๋ณต๊ตฌ (```json ํ์คยทํธ๋ ์ผ๋ง ์ฝค๋งยท๋
ธ์ด์ฆ ์ ๊ฑฐ)constdata=recoverJsonFromText<{items: string[]}>(llmReply);// Open Graph ์คํฌ๋ํconstog=awaitscrapeOg("example.com");// { title, description, image, siteName, url }// ํ์์กด ๋ ์งtodayKST();// "2026-06-04"dateInTz(Date.now(),"America/New_York");// base62 slug + ํธ์คํธidToSlug(42,1000);// "..." (slugToId๋ก ์ญ๋ณํ)constbase=`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("");constdebouncedQuery=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}`)});<formonSubmit={e=>{e.preventDefault();submit(inputValue);}}><inputvalue={inputValue}onChange={...}/>{error&&<pclassName="text-red-500 text-sm">{error.message}</p>}<buttondisabled={loading}>{loading ? "๋ฑ๋ก ์คโฆ" : "๋ฑ๋ก"}</button></form>๋ฌดํ์คํฌ๋กค ํธ๋ฆฌ๊ฑฐ, ๋ ์ด์ง ๋ก๋, ๋ฑ์ฅ ์ ๋๋ฉ์ด์ ์ ์ฌ์ฉ.
const{ ref, inView }=useInView({threshold: 0.1,once: true});useEffect(()=>{if(inView)fetchNextPage();},[inView]);return(<div>{posts.map(p=><PostCardkey={p.id}post={p}/>)}<divref={ref}/>{/* ๋ฆฌ์คํธ ๋งจ ์๋ ์ผํฐ๋ฌ */}</div>);์๋ก๊ณ ์นจ ํ์๋ ์ ์ง๋๋ ๋ก์ปฌ ์ํ. SSR ์์ .
const[theme,setTheme,removeTheme]=useLocalStorage("theme","light");setTheme("dark");// localStorage์ ์ ์ฅremoveTheme();// localStorage์์ ์ญ์ , ์ด๊ธฐ๊ฐ์ผ๋ก ๋ณต์