diff --git a/starter/blog-next-log/README.md b/starter/blog-next-log/README.md
new file mode 100644
index 0000000000..dd8273445e
--- /dev/null
+++ b/starter/blog-next-log/README.md
@@ -0,0 +1,90 @@
+---
+name: Blog Starter with Next.js and MDX
+slug: blog-next-log
+description: A personal developer blog powered by Next.js 15, MDX, and Tailwind CSS v4. Features dark mode, dynamic OG images, RSS feed, Table of Contents, and resume page.
+framework: Next.js
+useCase:
+ - Starter
+ - Blog
+css: Tailwind CSS
+deployUrl: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fexamples%2Ftree%2Fmain%2Fstarter%2Fblog-next-log&project-name=blog-next-log&repository-name=blog-next-log
+demoUrl: https://create-next-log-demo.vercel.app
+relatedTemplates:
+ - blog-starter-kit
+ - nextjs-boilerplate
+---
+
+# Blog Starter with Next.js and MDX
+
+A fully-featured, config-driven personal developer blog built with Next.js 15 (App Router), MDX, and Tailwind CSS v4.
+
+## Features
+
+- MDX blog posts with syntax highlighting and copy button
+- Dark / Light mode with system preference detection
+- Dynamic OG image generation per post
+- Auto-generated sitemap and RSS feed
+- Table of Contents (auto-generated from headings)
+- Resume page generator
+- SEO optimizations (JSON-LD, canonical URLs, robots.txt)
+- Fully responsive (desktop, tablet, mobile)
+
+## Demo
+
+[create-next-log-demo.vercel.app](https://create-next-log-demo.vercel.app)
+
+## Deploy your own
+
+Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples):
+
+[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fexamples%2Ftree%2Fmain%2Fstarter%2Fblog-next-log&project-name=blog-next-log&repository-name=blog-next-log)
+
+## How to use
+
+You can also use the CLI scaffolder for a fully customized setup:
+
+```bash
+npx create-next-log
+```
+
+Or clone and deploy manually:
+
+```bash
+git clone https://github.com/vercel/examples/tree/main/starter/blog-next-log
+cd blog-next-log
+npm install
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) to see your blog.
+
+## Configuration
+
+All settings live in `next-log.config.ts`:
+
+```typescript
+const config = {
+ title: "My Dev Blog",
+ description: "Thoughts on web development",
+ url: "https://myblog.com",
+ language: "en",
+ author: { name: "Jane Doe" },
+ social: { github: "", linkedin: "" },
+ theme: { primaryColor: "#2563eb" },
+};
+```
+
+## Writing posts
+
+```bash
+npm run new-post "my-first-post"
+```
+
+Edit `posts/my-first-post/index.mdx` and set `published: true` when ready.
+
+## Tech Stack
+
+- [Next.js 15](https://nextjs.org) — App Router
+- [MDX](https://mdxjs.com) — Rich content with React components
+- [Tailwind CSS v4](https://tailwindcss.com) — CSS-first config
+- [Radix UI](https://www.radix-ui.com) — Accessible primitives
diff --git a/starter/blog-next-log/app/api/og/[slug]/route.tsx b/starter/blog-next-log/app/api/og/[slug]/route.tsx
new file mode 100644
index 0000000000..9395c0c496
--- /dev/null
+++ b/starter/blog-next-log/app/api/og/[slug]/route.tsx
@@ -0,0 +1,158 @@
+import { NextRequest, NextResponse } from "next/server";
+import { ImageResponse } from "next/og";
+import { getConfig } from "~lib/config";
+
+async function loadFonts() {
+ const [bold, regular] = await Promise.all([
+ fetch(
+ "https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/public/static/Pretendard-Bold.otf"
+ ).then((res) => res.arrayBuffer()),
+ fetch(
+ "https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/public/static/Pretendard-Regular.otf"
+ ).then((res) => res.arrayBuffer()),
+ ]);
+ return { bold, regular };
+}
+
+function renderDots() {
+ const dots = [];
+ const cols = 24;
+ const rows = 12;
+ const spacingX = 50;
+ const spacingY = 52;
+
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ dots.push(
+
+ );
+ }
+ }
+ return dots;
+}
+
+function renderTitle(title: string, highlightWords?: string) {
+ if (!highlightWords) {
+ return title;
+ }
+
+ const words = highlightWords.split(",").map((w) => w.trim()).filter(Boolean);
+ const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
+ const regex = new RegExp(`(${pattern})`, "g");
+ const parts = title.split(regex);
+
+ return parts.map((part, i) =>
+ words.includes(part) ? (
+
+ {part}
+
+ ) : (
+ {part}
+ )
+ );
+}
+
+export async function GET(req: NextRequest) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const title = searchParams.get("title") || "Title";
+ const highlightWord = searchParams.get("highlightWord") || undefined;
+
+ const fonts = await loadFonts();
+
+ return new ImageResponse(
+ (
+
+ {renderDots()}
+
+ {renderTitle(title, highlightWord)}
+
+
+ {getConfig().author.name}
+
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ fonts: [
+ {
+ name: "Pretendard",
+ data: fonts.bold,
+ weight: 700 as const,
+ style: "normal" as const,
+ },
+ {
+ name: "Pretendard",
+ data: fonts.regular,
+ weight: 400 as const,
+ style: "normal" as const,
+ },
+ ],
+ }
+ );
+ } catch (e) {
+ console.error("OG image generation failed:", e);
+ return NextResponse.json(
+ { error: "Failed to generate image" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/starter/blog-next-log/app/components/GoogleAnalytics.tsx b/starter/blog-next-log/app/components/GoogleAnalytics.tsx
new file mode 100644
index 0000000000..ef74fbc2c0
--- /dev/null
+++ b/starter/blog-next-log/app/components/GoogleAnalytics.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import Script from "next/script";
+
+interface Props {
+ gaId: string;
+}
+
+export default function GoogleAnalytics({ gaId }: Props) {
+ if (!gaId) return null;
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/starter/blog-next-log/app/components/header/index.tsx b/starter/blog-next-log/app/components/header/index.tsx
new file mode 100644
index 0000000000..267e23b5c3
--- /dev/null
+++ b/starter/blog-next-log/app/components/header/index.tsx
@@ -0,0 +1,22 @@
+import { getNavItems } from "~lib/nav";
+import NavToggles from "./toggle";
+import PageNav from "./nav";
+
+function Header() {
+ const navItems = getNavItems();
+
+ return (
+
+ );
+}
+
+export default Header;
diff --git a/starter/blog-next-log/app/components/header/nav/index.tsx b/starter/blog-next-log/app/components/header/nav/index.tsx
new file mode 100644
index 0000000000..fe2955e5b8
--- /dev/null
+++ b/starter/blog-next-log/app/components/header/nav/index.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import Link from "next/link";
+import useIsRouteActive from "~hooks/useIsActive";
+import { getConfig } from "~lib/config";
+import { cn } from "~lib/utils";
+import NavSheet from "../navSheet";
+
+interface NavItem {
+ label: string;
+ path: string;
+}
+
+function PageNav({ navItems }: { navItems: NavItem[] }) {
+ const config = getConfig();
+
+ return (
+
+
+ {config.author.name}
+
+
+
+
+ );
+}
+
+function NavLink({ href, label }: { href: string; label: string }) {
+ const isActive = useIsRouteActive(href);
+
+ return (
+
+ {label}
+
+ );
+}
+
+export default PageNav;
diff --git a/starter/blog-next-log/app/components/header/navSheet/index.tsx b/starter/blog-next-log/app/components/header/navSheet/index.tsx
new file mode 100644
index 0000000000..7765a41a91
--- /dev/null
+++ b/starter/blog-next-log/app/components/header/navSheet/index.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import Link from "next/link";
+import { getConfig } from "~lib/config";
+import MenuIcon from "~components/icon/menuIcon";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "~components/ui/sheet";
+
+interface NavItem {
+ label: string;
+ path: string;
+}
+
+function NavSheet({ navItems }: { navItems: NavItem[] }) {
+ const config = getConfig();
+
+ const socialLinks = [
+ config.social.github && { label: "GitHub", href: config.social.github },
+ config.social.linkedin && {
+ label: "LinkedIn",
+ href: config.social.linkedin,
+ },
+ ].filter(Boolean) as { label: string; href: string }[];
+
+ return (
+
+
+
+
+
+
+
+
+ {config.author.name}
+
+
+
+
+ {navItems.map((item) => (
+
+ {item.label}
+
+ ))}
+ {socialLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default NavSheet;
diff --git a/starter/blog-next-log/app/components/header/toggle/index.tsx b/starter/blog-next-log/app/components/header/toggle/index.tsx
new file mode 100644
index 0000000000..a84e8766e7
--- /dev/null
+++ b/starter/blog-next-log/app/components/header/toggle/index.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useTheme } from "~styles/themeProvider";
+import Link from "next/link";
+
+import { getConfig } from "~lib/config";
+import GithubIcon from "~components/icon/githubIcon";
+import LinkedInIcon from "~components/icon/linkedInIcon";
+import MoonIcon from "~components/icon/moonIcon";
+import SunIcon from "~components/icon/sunIcon";
+
+import { Button } from "~components/ui/button";
+
+function NavToggles() {
+ const { resolvedTheme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ const config = getConfig();
+
+ useEffect(() => setMounted(true), []);
+
+ const changeTheme = () => {
+ if (resolvedTheme === "dark") {
+ setTheme("light");
+ } else {
+ setTheme("dark");
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default NavToggles;
diff --git a/starter/blog-next-log/app/components/icon/githubIcon/index.tsx b/starter/blog-next-log/app/components/icon/githubIcon/index.tsx
new file mode 100644
index 0000000000..8dc173ba86
--- /dev/null
+++ b/starter/blog-next-log/app/components/icon/githubIcon/index.tsx
@@ -0,0 +1,16 @@
+import { IconProps } from "~types/icon";
+
+function GithubIcon({ ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export default GithubIcon;
diff --git a/starter/blog-next-log/app/components/icon/linkedInIcon/index.tsx b/starter/blog-next-log/app/components/icon/linkedInIcon/index.tsx
new file mode 100644
index 0000000000..6c600e2711
--- /dev/null
+++ b/starter/blog-next-log/app/components/icon/linkedInIcon/index.tsx
@@ -0,0 +1,16 @@
+import { IconProps } from "~types/icon";
+
+function LinkedInIcon({ ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export default LinkedInIcon;
diff --git a/starter/blog-next-log/app/components/icon/menuIcon/index.tsx b/starter/blog-next-log/app/components/icon/menuIcon/index.tsx
new file mode 100644
index 0000000000..4b9387681e
--- /dev/null
+++ b/starter/blog-next-log/app/components/icon/menuIcon/index.tsx
@@ -0,0 +1,21 @@
+import { IconProps } from "~types/icon";
+
+const MenuIcon = (props: IconProps) => (
+
+);
+export default MenuIcon;
diff --git a/starter/blog-next-log/app/components/icon/moonIcon/index.tsx b/starter/blog-next-log/app/components/icon/moonIcon/index.tsx
new file mode 100644
index 0000000000..2a9247a104
--- /dev/null
+++ b/starter/blog-next-log/app/components/icon/moonIcon/index.tsx
@@ -0,0 +1,22 @@
+import { IconProps } from "~types/icon";
+
+function MoonIcon({ ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export default MoonIcon;
diff --git a/starter/blog-next-log/app/components/icon/sunIcon/index.tsx b/starter/blog-next-log/app/components/icon/sunIcon/index.tsx
new file mode 100644
index 0000000000..010a4a2924
--- /dev/null
+++ b/starter/blog-next-log/app/components/icon/sunIcon/index.tsx
@@ -0,0 +1,30 @@
+import { IconProps } from "~types/icon";
+
+function SunIcon({ ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export default SunIcon;
diff --git a/starter/blog-next-log/app/components/mdx/CodeBlock.tsx b/starter/blog-next-log/app/components/mdx/CodeBlock.tsx
new file mode 100644
index 0000000000..1f93a7680f
--- /dev/null
+++ b/starter/blog-next-log/app/components/mdx/CodeBlock.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { useState, useRef } from "react";
+
+export function CodeBlock(props: React.HTMLAttributes) {
+ const [copied, setCopied] = useState(false);
+ const preRef = useRef(null);
+
+ const handleCopy = async () => {
+ const code = preRef.current?.querySelector("code")?.textContent || "";
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/starter/blog-next-log/app/components/mdx/FileTree.tsx b/starter/blog-next-log/app/components/mdx/FileTree.tsx
new file mode 100644
index 0000000000..4d476e7b39
--- /dev/null
+++ b/starter/blog-next-log/app/components/mdx/FileTree.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import {
+ useState,
+ useRef,
+ useEffect,
+ createContext,
+ useContext,
+ Children,
+ type ReactNode,
+} from "react";
+
+const INDENT = 20;
+
+const DepthContext = createContext(0);
+
+/* ── Lucide-style icons (stroke-based, 24x24) ── */
+
+function ChevronIcon({ open }: { open: boolean }) {
+ return (
+
+ );
+}
+
+function FolderIcon({ open }: { open: boolean }) {
+ return open ? (
+
+ ) : (
+
+ );
+}
+
+function FileIcon() {
+ return (
+
+ );
+}
+
+/* ── Animated collapse wrapper ── */
+
+function Collapsible({ open, children }: { open: boolean; children: ReactNode }) {
+ const ref = useRef(null);
+ const [height, setHeight] = useState(open ? "auto" : 0);
+ const initial = useRef(true);
+
+ useEffect(() => {
+ if (initial.current) {
+ initial.current = false;
+ return;
+ }
+ if (!ref.current) return;
+ if (open) {
+ const h = ref.current.scrollHeight;
+ setHeight(h);
+ const timer = setTimeout(() => setHeight("auto"), 200);
+ return () => clearTimeout(timer);
+ } else {
+ setHeight(ref.current.scrollHeight);
+ requestAnimationFrame(() => setHeight(0));
+ }
+ }, [open]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/* ── Tree lines ── */
+
+function TreeLine({ depth }: { depth: number }) {
+ if (depth === 0) return null;
+ return (
+ <>
+ {Array.from({ length: depth }).map((_, i) => (
+
+ ))}
+ >
+ );
+}
+
+/* ── FileTree (root) ── */
+
+interface FileTreeProps {
+ children: ReactNode;
+}
+
+function FileTree({ children }: FileTreeProps) {
+ return (
+
+ {/* grid background with edge fade */}
+
+
+ {children}
+
+
+ );
+}
+
+/* ── Folder ── */
+
+interface FolderProps {
+ name: string;
+ defaultOpen?: boolean;
+ children?: ReactNode;
+}
+
+function Folder({ name, defaultOpen = false, children }: FolderProps) {
+ const [open, setOpen] = useState(defaultOpen);
+ const depth = useContext(DepthContext);
+ const hasChildren = !!children;
+
+ return (
+
+
+ {hasChildren && (
+
+
+ {children}
+
+
+ )}
+
+ );
+}
+
+/* ── File ── */
+
+interface FileProps {
+ name: string;
+}
+
+function File({ name }: FileProps) {
+ const depth = useContext(DepthContext);
+
+ return (
+
+
+
+
+ {name}
+
+ );
+}
+
+export { FileTree, Folder, File };
diff --git a/starter/blog-next-log/app/components/mdx/MdxRenderer.tsx b/starter/blog-next-log/app/components/mdx/MdxRenderer.tsx
new file mode 100644
index 0000000000..82f8e8e04e
--- /dev/null
+++ b/starter/blog-next-log/app/components/mdx/MdxRenderer.tsx
@@ -0,0 +1,34 @@
+import { MDXRemote } from "next-mdx-remote/rsc";
+import rehypeSlug from "rehype-slug";
+import rehypeCodeTitles from "rehype-code-titles";
+import rehypePrism from "rehype-prism-plus";
+import rehypeExternalLinks from "rehype-external-links";
+import remarkGfm from "remark-gfm";
+import { mdxComponents } from "./index";
+
+interface MdxRendererProps {
+ source: string;
+}
+
+export function MdxRenderer({ source }: MdxRendererProps) {
+ return (
+
+ );
+}
diff --git a/starter/blog-next-log/app/components/mdx/Timeline.tsx b/starter/blog-next-log/app/components/mdx/Timeline.tsx
new file mode 100644
index 0000000000..c72c0036a8
--- /dev/null
+++ b/starter/blog-next-log/app/components/mdx/Timeline.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useRef } from "react";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "~components/ui/accordion";
+
+interface TimelineItemProps {
+ title: string;
+ summary: string;
+ children: React.ReactNode;
+}
+
+function TimelineItem({ title, summary, children }: TimelineItemProps) {
+ const triggerRef = useRef(null);
+
+ const handleClick = () => {
+ triggerRef.current?.click();
+ };
+
+ return (
+
+
+ {/* Title + summary + chevron (click to toggle) */}
+
+
+ {/* Expanded content */}
+
+
+
+
+
+ );
+}
+
+interface TimelineProps {
+ children: React.ReactNode;
+}
+
+function Timeline({ children }: TimelineProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { Timeline, TimelineItem };
diff --git a/starter/blog-next-log/app/components/mdx/index.ts b/starter/blog-next-log/app/components/mdx/index.ts
new file mode 100644
index 0000000000..284d2824f4
--- /dev/null
+++ b/starter/blog-next-log/app/components/mdx/index.ts
@@ -0,0 +1,12 @@
+import { Timeline, TimelineItem } from "./Timeline";
+import { FileTree, Folder, File } from "./FileTree";
+import { CodeBlock } from "./CodeBlock";
+
+export const mdxComponents = {
+ Timeline,
+ TimelineItem,
+ FileTree,
+ Folder,
+ File,
+ pre: CodeBlock,
+};
diff --git a/starter/blog-next-log/app/components/toc/TableOfContents.tsx b/starter/blog-next-log/app/components/toc/TableOfContents.tsx
new file mode 100644
index 0000000000..4c7d1e6567
--- /dev/null
+++ b/starter/blog-next-log/app/components/toc/TableOfContents.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import type { TableOfContents as TOCType } from "~core/blog/types";
+
+interface Props {
+ toc: TOCType;
+}
+
+export default function TableOfContents({ toc }: Props) {
+ const [activeSlug, setActiveSlug] = useState("");
+
+ useEffect(() => {
+ const h2Headings = Array.from(
+ document.querySelectorAll("h2")
+ ) as HTMLElement[];
+
+ h2Headings.forEach((h) => {
+ h.style.scrollMarginTop = "100px";
+ });
+
+ if (h2Headings.length === 0) return;
+
+ // Set initial active based on current scroll position
+ let initial = h2Headings[0].id;
+ for (const h of h2Headings) {
+ if (h.offsetTop <= window.scrollY + 120) initial = h.id;
+ }
+ setActiveSlug(initial);
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const intersecting = entries
+ .filter((e) => e.isIntersecting)
+ .sort((a, b) => (a.target as HTMLElement).offsetTop - (b.target as HTMLElement).offsetTop);
+
+ if (intersecting.length > 0) {
+ setActiveSlug(intersecting[0].target.id);
+ }
+ },
+ { rootMargin: "0px 0px -30% 0px", threshold: 0 }
+ );
+
+ h2Headings.forEach((h) => observer.observe(h));
+ return () => observer.disconnect();
+ }, []);
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent, slug: string) => {
+ e.preventDefault();
+ const el = document.getElementById(slug);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth" });
+ setActiveSlug(slug);
+ }
+ },
+ []
+ );
+
+ if (toc.length === 0) return null;
+
+ const tocList = (
+
+ {toc.map((section) => (
+
+ ))}
+
+ );
+
+ return (
+ <>
+ {/* Desktop: sticky sidebar, positioned outside content area */}
+
+ >
+ );
+}
diff --git a/starter/blog-next-log/app/components/ui/accordion.tsx b/starter/blog-next-log/app/components/ui/accordion.tsx
new file mode 100644
index 0000000000..c6d654c710
--- /dev/null
+++ b/starter/blog-next-log/app/components/ui/accordion.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "~lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/starter/blog-next-log/app/components/ui/button.tsx b/starter/blog-next-log/app/components/ui/button.tsx
new file mode 100644
index 0000000000..02b73ca9a3
--- /dev/null
+++ b/starter/blog-next-log/app/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "~lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/starter/blog-next-log/app/components/ui/dialog.tsx b/starter/blog-next-log/app/components/ui/dialog.tsx
new file mode 100644
index 0000000000..d819ddab5e
--- /dev/null
+++ b/starter/blog-next-log/app/components/ui/dialog.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "~lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = ({ ...props }: DialogPrimitive.DialogPortalProps) => (
+
+);
+DialogPortal.displayName = DialogPrimitive.Portal.displayName;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/starter/blog-next-log/app/components/ui/sheet.tsx b/starter/blog-next-log/app/components/ui/sheet.tsx
new file mode 100644
index 0000000000..e6e960f2f0
--- /dev/null
+++ b/starter/blog-next-log/app/components/ui/sheet.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import * as React from "react";
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+
+import { cn } from "~lib/utils";
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = ({ ...props }: SheetPrimitive.DialogPortalProps) => (
+
+);
+SheetPortal.displayName = SheetPrimitive.Portal.displayName;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = "SheetFooter";
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/starter/blog-next-log/app/core/blog/serializeMdx.ts b/starter/blog-next-log/app/core/blog/serializeMdx.ts
new file mode 100644
index 0000000000..c34bab5182
--- /dev/null
+++ b/starter/blog-next-log/app/core/blog/serializeMdx.ts
@@ -0,0 +1,34 @@
+import { TableOfContents } from "./types";
+
+export const parseToc = (source: string) => {
+ return source
+ .split("\n")
+ .filter((line) => line.match(/(^#{2})\s/))
+ .reduce((ac, rawHeading) => {
+ const nac = [...ac];
+ const removeMdx = rawHeading
+ .replace(/^##*\s/, "")
+ .replace(/[\*,\~]{2,}/g, "")
+ .replace(/(?<=\])\((.*?)\)/g, "")
+ .replace(/(? `
+ -
+
+ ${siteUrl}/post/${post.slug}
+ ${siteUrl}/post/${post.slug}
+
+ ${new Date(post.metadata.date).toUTCString()}
+ ${post.metadata.author}
+
`
+ )
+ .join("");
+
+ const feed = `
+
+
+
+ ${siteUrl}
+
+ ${config.language || "en"}
+ ${new Date().toUTCString()}
+
+ ${items}
+
+`;
+
+ return new Response(feed, {
+ headers: {
+ "Content-Type": "application/rss+xml; charset=utf-8",
+ },
+ });
+}
diff --git a/starter/blog-next-log/app/hooks/useIsActive.ts b/starter/blog-next-log/app/hooks/useIsActive.ts
new file mode 100644
index 0000000000..2d4032717b
--- /dev/null
+++ b/starter/blog-next-log/app/hooks/useIsActive.ts
@@ -0,0 +1,21 @@
+import { usePathname } from "next/navigation";
+
+/**
+ * Function checks if the current route is active
+ * @param {string} pathToCheck - path to check
+ * @returns {boolean} - true if the current route is active not flase
+ */
+function useIsRouteActive(pathToCheck: string) {
+ const pathname = usePathname();
+ const currentPath = pathname.split("?")[0]; // drop query params
+
+ // currentPath and pathToCheck are the same (e.g. /blog)
+ if (currentPath === pathToCheck) return true;
+
+ // currentPath is included pathToCheck (e.g. /blog/1 is included in /blog)
+ if (currentPath.includes(pathToCheck)) return true;
+
+ return false;
+}
+
+export default useIsRouteActive;
diff --git a/starter/blog-next-log/app/layout.tsx b/starter/blog-next-log/app/layout.tsx
new file mode 100644
index 0000000000..5d4ae4eaf6
--- /dev/null
+++ b/starter/blog-next-log/app/layout.tsx
@@ -0,0 +1,90 @@
+import "~styles/globals.css";
+import { Metadata } from "next";
+import Header from "~components/header";
+import ThemeProvider from "~styles/themeProvider";
+import { getConfig } from "~lib/config";
+import GoogleAnalytics from "~components/GoogleAnalytics";
+
+const config = getConfig();
+
+export const metadata: Metadata = {
+ metadataBase: new URL(config.url || "http://localhost:3000"),
+ title: {
+ default: config.title,
+ template: `%s | ${config.title}`,
+ },
+ description: config.description,
+ authors: [{ name: config.author.name }],
+ icons: {
+ icon: [
+ { url: "/favicon-light.svg", media: "(prefers-color-scheme: light)" },
+ { url: "/favicon-dark.svg", media: "(prefers-color-scheme: dark)" },
+ ],
+ },
+ alternates: {
+ types: {
+ "application/rss+xml": "/feed.xml",
+ },
+ },
+ openGraph: {
+ type: "website",
+ siteName: config.title,
+ title: config.title,
+ description: config.description,
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: config.title,
+ description: config.description,
+ },
+ ...(config.googleVerification && {
+ verification: { google: config.googleVerification },
+ }),
+};
+
+const RootLayout = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+
+
+
+ Skip to content
+
+
+ {config.googleAnalyticsId && (
+
+ )}
+
+
+
+ {children}
+
+
+
+
+ );
+};
+
+export default RootLayout;
diff --git a/starter/blog-next-log/app/lib/config.ts b/starter/blog-next-log/app/lib/config.ts
new file mode 100644
index 0000000000..5eaed85331
--- /dev/null
+++ b/starter/blog-next-log/app/lib/config.ts
@@ -0,0 +1,8 @@
+import siteConfig from "../../next-log.config";
+import type { SiteConfig } from "~types/config";
+
+export type { SiteConfig };
+
+export function getConfig(): SiteConfig {
+ return siteConfig;
+}
diff --git a/starter/blog-next-log/app/lib/nav.ts b/starter/blog-next-log/app/lib/nav.ts
new file mode 100644
index 0000000000..fd64137299
--- /dev/null
+++ b/starter/blog-next-log/app/lib/nav.ts
@@ -0,0 +1,11 @@
+import fs from "fs";
+import path from "path";
+
+export function getNavItems() {
+ const items: { label: string; path: string }[] = [];
+ const resumePath = path.join(process.cwd(), "app", "resume", "page.tsx");
+ if (fs.existsSync(resumePath)) {
+ items.push({ label: "Resume", path: "/resume" });
+ }
+ return items;
+}
diff --git a/starter/blog-next-log/app/lib/utils.ts b/starter/blog-next-log/app/lib/utils.ts
new file mode 100644
index 0000000000..23a57c02a3
--- /dev/null
+++ b/starter/blog-next-log/app/lib/utils.ts
@@ -0,0 +1,14 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function formatDate(date: string): string {
+ return new Date(date).toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+}
diff --git a/starter/blog-next-log/app/not-found.tsx b/starter/blog-next-log/app/not-found.tsx
new file mode 100644
index 0000000000..8cf1be27e8
--- /dev/null
+++ b/starter/blog-next-log/app/not-found.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Page Not Found",
+};
+
+export default function NotFound() {
+ return (
+
+
404
+
Page not found.
+
+ Back to posts
+
+
+ );
+}
diff --git a/starter/blog-next-log/app/page.tsx b/starter/blog-next-log/app/page.tsx
new file mode 100644
index 0000000000..705690b226
--- /dev/null
+++ b/starter/blog-next-log/app/page.tsx
@@ -0,0 +1,78 @@
+import { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+
+import { Post } from "~types/post";
+import { getAllPosts } from "~utils/posts";
+import { getConfig } from "~lib/config";
+import { formatDate } from "~lib/utils";
+
+const config = getConfig();
+
+export const metadata: Metadata = {
+ title: "Posts",
+ description: config.description,
+ openGraph: {
+ title: `Posts | ${config.title}`,
+ description: config.description,
+ },
+};
+
+const Article = async () => {
+ const posts = getAllPosts();
+
+ if (posts.length === 0) {
+ return (
+
+
+
No posts yet
+
+ Run npm run new-post "my-first-post" to create your first post.
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default Article;
diff --git a/starter/blog-next-log/app/post/[slug]/page.tsx b/starter/blog-next-log/app/post/[slug]/page.tsx
new file mode 100644
index 0000000000..49cbb51339
--- /dev/null
+++ b/starter/blog-next-log/app/post/[slug]/page.tsx
@@ -0,0 +1,137 @@
+import Image from "next/image";
+import { notFound } from "next/navigation";
+import { Metadata } from "next";
+
+import { getPostBySlug, getAllPosts } from "~utils/posts";
+import { MdxRenderer } from "~components/mdx/MdxRenderer";
+import { parseToc } from "~core/blog/serializeMdx";
+import TableOfContents from "~components/toc/TableOfContents";
+import { getConfig } from "~lib/config";
+import { formatDate } from "~lib/utils";
+import "~styles/prism.css";
+
+type Props = { params: Promise<{ slug: string }> };
+
+const config = getConfig();
+
+const PostPage = async ({ params }: Props) => {
+ const { slug } = await params;
+ const post = getPostBySlug(slug);
+ if (!post) notFound();
+
+ const toc = parseToc(post.content);
+
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "BlogPosting",
+ headline: post.metadata.title,
+ description: post.metadata.description,
+ datePublished: new Date(post.metadata.date).toISOString(),
+ author: {
+ "@type": "Person",
+ name: config.author.name,
+ url: `${config.url}/resume`,
+ },
+ url: `${config.url}/post/${post.slug}`,
+ };
+
+ return (
+ <>
+
+
+
+
{post.metadata.title}
+ {post.metadata.thumbnail && (
+
+
+
+ )}
+
+ {post.metadata.category} | {formatDate(post.metadata.date)}
+
+
+
+
+ {(post.metadata.introTitle || post.metadata.introDesc) && (
+
+ {post.metadata.introTitle && (
+
{post.metadata.introTitle}
+ )}
+ {post.metadata.introDesc && (
+ {post.metadata.introDesc}
+ )}
+
+ )}
+
+
+
+
+ >
+ );
+};
+
+export default PostPage;
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { slug } = await params;
+ const post = getPostBySlug(slug);
+ if (!post) return {};
+
+ const highlightParam = post.metadata.highlightWord
+ ? `&highlightWord=${encodeURIComponent(post.metadata.highlightWord)}`
+ : "";
+ const ogImageUrl = post.metadata.thumbnail
+ ? `${config.url}/posts/${slug}/${post.metadata.thumbnail}`
+ : `${config.url}/api/og/${slug}?title=${encodeURIComponent(
+ post.metadata.title
+ )}${highlightParam}`;
+
+ return {
+ title: post.metadata.title,
+ description: post.metadata.description,
+ alternates: {
+ canonical: `${config.url}/post/${slug}`,
+ },
+ authors: {
+ name: config.author.name,
+ url: `${config.url}/resume`,
+ },
+ openGraph: {
+ type: "article",
+ title: post.metadata.title,
+ description: post.metadata.description,
+ url: `/post/${slug}`,
+ publishedTime: new Date(post.metadata.date).toISOString(),
+ authors: [config.author.name],
+ images: [
+ {
+ url: ogImageUrl,
+ width: 1200,
+ height: 630,
+ alt: post.metadata.title,
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: post.metadata.title,
+ description: post.metadata.description,
+ images: [ogImageUrl],
+ },
+ };
+}
+
+export const generateStaticParams = async () => {
+ const posts = getAllPosts();
+ return posts.map((post) => ({ slug: post.slug }));
+};
diff --git a/starter/blog-next-log/app/resume/data.ts b/starter/blog-next-log/app/resume/data.ts
new file mode 100644
index 0000000000..be5a031fac
--- /dev/null
+++ b/starter/blog-next-log/app/resume/data.ts
@@ -0,0 +1,77 @@
+export const resumeData = {
+ name: "Geon",
+ title: "Frontend Engineer",
+ summary:
+ "Passionate frontend engineer with experience building modern web applications. Focused on user experience, performance, and clean code.",
+
+ experience: [
+ {
+ company: "Tech Corp",
+ position: "Senior Frontend Engineer",
+ period: "2022.03 ~ Present",
+ summary: "Leading frontend development for the main product.",
+ projects: [
+ {
+ name: "Design System",
+ duration: "2023.01 ~ Present",
+ description:
+ "Built and maintained a company-wide design system used by 5 teams.",
+ responsibilities: [
+ "Developed 30+ reusable React components with Storybook documentation",
+ "Reduced UI inconsistencies by 80% across products",
+ "Implemented automated visual regression testing with Chromatic",
+ ],
+ },
+ {
+ name: "Performance Optimization",
+ duration: "2022.06 ~ 2022.12",
+ description:
+ "Led a performance initiative to improve Core Web Vitals.",
+ responsibilities: [
+ "Reduced LCP from 4.2s to 1.8s through code splitting and image optimization",
+ "Implemented lazy loading for below-the-fold components",
+ "Set up performance monitoring with custom dashboards",
+ ],
+ },
+ ],
+ },
+ {
+ company: "Startup Inc",
+ position: "Frontend Engineer",
+ period: "2020.01 ~ 2022.02",
+ summary: "Full-stack web development for an early-stage startup.",
+ projects: [
+ {
+ name: "Customer Dashboard",
+ duration: "2020.06 ~ 2022.02",
+ description:
+ "Built the main customer-facing dashboard from scratch.",
+ responsibilities: [
+ "Developed responsive dashboard with React and TypeScript",
+ "Integrated RESTful APIs with React Query for data fetching",
+ "Implemented real-time notifications using WebSocket",
+ ],
+ },
+ ],
+ },
+ ],
+
+ skills: [
+ {
+ category: "Frontend",
+ items: [
+ "React, Next.js, TypeScript — primary stack for all projects",
+ "Tailwind CSS, CSS Modules — styling approaches",
+ "React Query, Zustand — state management and data fetching",
+ ],
+ },
+ {
+ category: "Tools & Infrastructure",
+ items: [
+ "Git, GitHub Actions — version control and CI/CD",
+ "Storybook, Chromatic — component documentation and visual testing",
+ "Vercel, AWS — deployment and hosting",
+ ],
+ },
+ ],
+};
diff --git a/starter/blog-next-log/app/resume/page.tsx b/starter/blog-next-log/app/resume/page.tsx
new file mode 100644
index 0000000000..20584a41d5
--- /dev/null
+++ b/starter/blog-next-log/app/resume/page.tsx
@@ -0,0 +1,83 @@
+import { resumeData } from "./data";
+
+export default function ResumePage() {
+ return (
+
+ {/* Header */}
+
+ {resumeData.name}
+
+ {resumeData.title}
+
+
+ {resumeData.summary}
+
+
+
+ {/* Experience */}
+
+
+ Experience
+
+
+ {resumeData.experience.map((exp) => (
+
+ {/* Left: Company info (sticky on desktop) */}
+
+
{exp.company}
+
+ {exp.position}
+
+
{exp.period}
+ {exp.summary && (
+
+ {exp.summary}
+
+ )}
+
+ {/* Right: Projects */}
+
+ {exp.projects.map((project) => (
+
+
{project.name}
+
+ {project.duration}
+
+
+ {project.description}
+
+
+ {project.responsibilities.map((r, i) => (
+ - {r}
+ ))}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* Skills */}
+
+
Skills
+
+ {resumeData.skills.map((skill) => (
+
+
{skill.category}
+
+ {skill.items.map((item, i) => (
+ - {item}
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/starter/blog-next-log/app/robots.ts b/starter/blog-next-log/app/robots.ts
new file mode 100644
index 0000000000..af8289891a
--- /dev/null
+++ b/starter/blog-next-log/app/robots.ts
@@ -0,0 +1,16 @@
+import { getConfig } from "~lib/config";
+import type { MetadataRoute } from "next";
+
+export default function robots(): MetadataRoute.Robots {
+ const config = getConfig();
+ const siteUrl = config.url || "http://localhost:3000";
+
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: "/api/",
+ },
+ sitemap: `${siteUrl}/sitemap.xml`,
+ };
+}
diff --git a/starter/blog-next-log/app/sitemap.ts b/starter/blog-next-log/app/sitemap.ts
new file mode 100644
index 0000000000..4c46142496
--- /dev/null
+++ b/starter/blog-next-log/app/sitemap.ts
@@ -0,0 +1,43 @@
+import fs from "fs";
+import path from "path";
+import { MetadataRoute } from "next";
+import { getConfig } from "~lib/config";
+import { getAllPosts } from "~utils/posts";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const config = getConfig();
+ const siteUrl = config.url;
+ const posts = getAllPosts();
+ const entries: MetadataRoute.Sitemap = [];
+
+ // Posts index
+ entries.push({
+ url: siteUrl,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ });
+
+ // Resume (only if the page exists)
+ const resumePath = path.join(process.cwd(), "app", "resume", "page.tsx");
+ if (fs.existsSync(resumePath)) {
+ entries.push({
+ url: `${siteUrl}/resume`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ });
+ }
+
+ // Individual posts
+ for (const post of posts) {
+ entries.push({
+ url: `${siteUrl}/post/${post.slug}`,
+ lastModified: new Date(post.metadata.date),
+ changeFrequency: "monthly",
+ priority: 0.8,
+ });
+ }
+
+ return entries;
+}
diff --git a/starter/blog-next-log/app/styles/globals.css b/starter/blog-next-log/app/styles/globals.css
new file mode 100644
index 0000000000..ff48c3ad21
--- /dev/null
+++ b/starter/blog-next-log/app/styles/globals.css
@@ -0,0 +1,372 @@
+@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css");
+@import "tailwindcss";
+
+@plugin "@tailwindcss/typography";
+
+@variant dark (&:where(.dark, .dark *));
+
+/* ─── Theme tokens ─── */
+
+@theme {
+ --font-sans: "Pretendard Variable", "Pretendard", system-ui, sans-serif;
+
+ --text-4xl: 36px;
+ --text-4xl--line-height: 1.4;
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-red: var(--red);
+ --color-blue: var(--blue);
+
+ --radius-lg: var(--radius);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-sm: calc(var(--radius) - 4px);
+
+ --width-full-minus-1: calc(100% - 1rem);
+ --width-full-minus-3: calc(100% - 3rem);
+
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
+ --animate-spin: spin 1s linear infinite;
+}
+
+/* ─── CSS variables ─── */
+
+:root {
+ --background: hsl(0 0% 100%);
+ --foreground: hsl(240 10% 3.9%);
+ --card: hsl(0 0% 100%);
+ --card-foreground: hsl(240 10% 3.9%);
+ --popover: hsl(0 0% 100%);
+ --popover-foreground: hsl(240 10% 3.9%);
+ --primary: #2563eb;
+ --primary-foreground: hsl(0 0% 98%);
+ --red: hsl(0, 60%, 50%);
+ --blue: hsl(216, 92%, 58%);
+ --secondary: hsl(240 4.8% 95.9%);
+ --secondary-foreground: hsl(240 5.9% 10%);
+ --muted: hsl(240 4.8% 95.9%);
+ --muted-foreground: hsl(240 3.8% 46.1%);
+ --accent: hsl(240 4.8% 95.9%);
+ --accent-foreground: hsl(240 5.9% 10%);
+ --destructive: hsl(0 84.2% 60.2%);
+ --destructive-foreground: hsl(0 0% 98%);
+ --border: hsl(240 5.9% 90%);
+ --input: hsl(240 5.9% 90%);
+ --ring: hsl(240 5.9% 10%);
+ --radius: 0.5rem;
+ --code-highlight-color: 55, 65, 81;
+ --code-inline-highlight-background: rgba(0, 0, 0, 0.03);
+ --code-inline-highlight-border: rgba(0, 0, 0, 0.04);
+ --code-keyword: #cf222e;
+ --code-control: #cf222e;
+ --code-function: #8250df;
+ --code-string: #0a3069;
+ --code-comment: #6a737d;
+ --code-constant: #0550ae;
+ --code-variable: #953800;
+ --code-type: #953800;
+ --code-tag: #116329;
+ --code-punctuation: #24292f;
+ --code-selector: #6f42c1;
+}
+
+.dark {
+ --background: hsl(240 10% 3.9%);
+ --foreground: hsl(0 0% 98%);
+ --card: hsl(240 10% 3.9%);
+ --card-foreground: hsl(0 0% 98%);
+ --popover: hsl(240 10% 3.9%);
+ --popover-foreground: hsl(0 0% 98%);
+ --primary: #2563eb;
+ --red: hsl(0, 60%, 50%);
+ --blue: hsl(216, 92%, 58%);
+ --primary-foreground: hsl(240 5.9% 10%);
+ --secondary: hsl(240 3.7% 15.9%);
+ --secondary-foreground: hsl(0 0% 98%);
+ --muted: hsl(240 3.7% 15.9%);
+ --muted-foreground: hsl(240 5% 64.9%);
+ --accent: hsl(240 3.7% 15.9%);
+ --accent-foreground: hsl(0 0% 98%);
+ --destructive: hsl(0 62.8% 30.6%);
+ --destructive-foreground: hsl(0 0% 98%);
+ --border: hsl(240 3.7% 15.9%);
+ --input: hsl(240 3.7% 15.9%);
+ --ring: hsl(240 4.9% 83.9%);
+ --code-highlight-color: 229, 231, 235;
+ --code-inline-highlight-background: #2a2828;
+ --code-inline-highlight-border: #3e3c3c;
+ --code-keyword: #569cd6;
+ --code-control: #c586c0;
+ --code-function: #dcdcaa;
+ --code-string: #ce9178;
+ --code-comment: #6a9955;
+ --code-constant: #b5cea8;
+ --code-variable: #9cdcfe;
+ --code-type: #4ec9b0;
+ --code-tag: #569cd6;
+ --code-punctuation: #d4d4d4;
+ --code-selector: #d7ba7d;
+}
+
+/* ─── Base styles ─── */
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-sans);
+ }
+}
+
+/* ─── Prose overrides for code blocks ─── */
+
+.prose pre {
+ background-color: hsl(204deg 100% 77%/0.1);
+ border: none;
+ box-shadow: none;
+ border-radius: 0.75rem;
+ padding: 1em 0;
+ margin: 0;
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.prose pre code {
+ background: transparent;
+ border: none;
+ padding: 0;
+ border-radius: 0;
+ font-size: 13px;
+}
+
+.prose code {
+ font-weight: 500;
+ font-size: 0.9em;
+}
+
+.prose code::before,
+.prose code::after {
+ display: none;
+}
+
+/* ─── Container utility ─── */
+
+@utility container {
+ margin-inline: auto;
+ width: 100%;
+ max-width: 1400px;
+ padding-inline: 1rem;
+ @media (min-width: 768px) {
+ padding-inline: 2rem;
+ }
+}
+
+/* ─── Keyframes ─── */
+
+@keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+}
+
+@keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes enter {
+ from {
+ opacity: var(--tw-enter-opacity, 1);
+ transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0)
+ scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1))
+ rotate(var(--tw-enter-rotate, 0));
+ }
+}
+
+@keyframes exit {
+ to {
+ opacity: var(--tw-exit-opacity, 1);
+ transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0)
+ scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1))
+ rotate(var(--tw-exit-rotate, 0));
+ }
+}
+
+/* ─── Animation utilities (tailwindcss-animate replacement) ─── */
+
+@utility animate-in {
+ animation-name: enter;
+ animation-duration: 150ms;
+ --tw-enter-opacity: initial;
+ --tw-enter-scale: initial;
+ --tw-enter-rotate: initial;
+ --tw-enter-translate-x: initial;
+ --tw-enter-translate-y: initial;
+}
+
+@utility animate-out {
+ animation-name: exit;
+ animation-duration: 150ms;
+ --tw-exit-opacity: initial;
+ --tw-exit-scale: initial;
+ --tw-exit-rotate: initial;
+ --tw-exit-translate-x: initial;
+ --tw-exit-translate-y: initial;
+}
+
+@utility fade-in-0 {
+ --tw-enter-opacity: 0;
+}
+
+@utility fade-in-* {
+ --tw-enter-opacity: calc(var(--value) / 100);
+}
+
+@utility fade-out-0 {
+ --tw-exit-opacity: 0;
+}
+
+@utility fade-out-* {
+ --tw-exit-opacity: calc(var(--value) / 100);
+}
+
+@utility zoom-in-* {
+ --tw-enter-scale: calc(var(--value) / 100);
+}
+
+@utility zoom-out-* {
+ --tw-exit-scale: calc(var(--value) / 100);
+}
+
+@utility slide-in-from-top {
+ --tw-enter-translate-y: -100%;
+}
+
+@utility slide-in-from-top-* {
+ --tw-enter-translate-y: calc(var(--spacing) * var(--value) * -1);
+}
+
+@utility slide-in-from-bottom {
+ --tw-enter-translate-y: 100%;
+}
+
+@utility slide-in-from-bottom-* {
+ --tw-enter-translate-y: calc(var(--spacing) * var(--value));
+}
+
+@utility slide-in-from-left {
+ --tw-enter-translate-x: -100%;
+}
+
+@utility slide-in-from-left-* {
+ --tw-enter-translate-x: calc(var(--spacing) * var(--value) * -1);
+}
+
+@utility slide-in-from-right {
+ --tw-enter-translate-x: 100%;
+}
+
+@utility slide-in-from-right-* {
+ --tw-enter-translate-x: calc(var(--spacing) * var(--value));
+}
+
+@utility slide-out-to-top {
+ --tw-exit-translate-y: -100%;
+}
+
+@utility slide-out-to-top-* {
+ --tw-exit-translate-y: calc(var(--spacing) * var(--value) * -1);
+}
+
+@utility slide-out-to-bottom {
+ --tw-exit-translate-y: 100%;
+}
+
+@utility slide-out-to-bottom-* {
+ --tw-exit-translate-y: calc(var(--spacing) * var(--value));
+}
+
+@utility slide-out-to-left {
+ --tw-exit-translate-x: -100%;
+}
+
+@utility slide-out-to-left-* {
+ --tw-exit-translate-x: calc(var(--spacing) * var(--value) * -1);
+}
+
+@utility slide-out-to-right {
+ --tw-exit-translate-x: 100%;
+}
+
+@utility slide-out-to-right-* {
+ --tw-exit-translate-x: calc(var(--spacing) * var(--value));
+}
+
+/* ─── Dialog-specific animations ─── */
+
+@keyframes dialog-enter {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -48%) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
+@keyframes dialog-exit {
+ from {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translate(-50%, -48%) scale(0.95);
+ }
+}
+
+@utility animate-dialog-in {
+ animation: dialog-enter 200ms ease-out;
+}
+
+@utility animate-dialog-out {
+ animation: dialog-exit 200ms ease-in;
+}
+
diff --git a/starter/blog-next-log/app/styles/prism.css b/starter/blog-next-log/app/styles/prism.css
new file mode 100644
index 0000000000..b90e9e5849
--- /dev/null
+++ b/starter/blog-next-log/app/styles/prism.css
@@ -0,0 +1,342 @@
+pre[class*="language-"],
+code[class*="language-"] {
+ color: rgba(var(--code-highlight-color), 1);
+ font-size: 13px;
+ text-shadow: none;
+ font-family: Menlo, Monaco, Consolas, "Andale Mono", "Ubuntu Mono",
+ "Courier New", monospace;
+ direction: ltr;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ line-height: 1.25rem;
+ -moz-tab-size: 4;
+ -o-tab-size: 4;
+ tab-size: 4;
+ -webkit-hyphens: none;
+ -moz-hyphens: none;
+ -ms-hyphens: none;
+ hyphens: none;
+}
+
+pre[class*="language-"]::selection,
+code[class*="language-"]::selection,
+pre[class*="language-"] *::selection,
+code[class*="language-"] *::selection {
+ text-shadow: none;
+ background-color: hsl(212deg 60% 50%/0.25);
+}
+
+.dark pre[class*="language-"]::selection,
+.dark code[class*="language-"]::selection,
+.dark pre[class*="language-"] *::selection,
+.dark code[class*="language-"] *::selection {
+ background-color: hsl(210deg 50% 60%/0.2);
+}
+
+pre {
+ white-space-collapse: preserve;
+ text-wrap: nowrap;
+}
+
+.rehype-code-title + pre {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ padding-top: 53px;
+}
+
+/* Inline code (not inside pre) */
+:not(pre) > code {
+ background: var(--code-inline-highlight-background);
+ border: 1px solid var(--code-inline-highlight-border);
+ padding: 0.125rem 0.25em;
+ border-radius: 0.375rem;
+ display: inline-block;
+ line-height: 1.2;
+ margin: 0;
+}
+
+/* border: 1px solid #ededed; */
+@media print {
+ pre[class*="language-"],
+ code[class*="language-"] {
+ text-shadow: none;
+ }
+}
+
+pre[class*="language-"] {
+ overflow: auto;
+}
+
+:not(pre) > code[class*="language-"] {
+ padding: 0.1em 0.3em;
+ color: #db4c69;
+ background-color: hsl(204deg 100% 77%/0.1);
+}
+
+.dark :not(pre) > code[class*="language-"] {
+ color: #db4c69;
+ background-color: hsl(204deg 100% 77%/0.1);
+}
+/*********************************************************
+* Tokens
+*/
+.namespace {
+ opacity: 0.7;
+}
+
+.token.doctype .token.doctype-tag {
+ color: var(--code-keyword);
+}
+
+.token.doctype .token.name {
+ color: var(--code-variable);
+}
+
+.token.comment,
+.token.prolog {
+ color: var(--code-comment);
+}
+
+.token.punctuation,
+.language-html .language-css .token.punctuation,
+.language-html .language-javascript .token.punctuation {
+ color: rgba(var(--code-highlight-color), 1);
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.inserted,
+.token.unit {
+ color: var(--code-constant);
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.deleted {
+ color: var(--code-string);
+}
+
+.language-css .token.string.url {
+ text-decoration: underline;
+}
+
+.token.operator,
+.token.entity {
+ color: var(--code-punctuation);
+}
+
+.token.operator.arrow {
+ color: var(--code-keyword);
+}
+
+.token.atrule {
+ color: var(--code-string);
+}
+
+.token.atrule .token.rule {
+ color: var(--code-control);
+}
+
+.token.atrule .token.url {
+ color: var(--code-variable);
+}
+
+.token.atrule .token.url .token.function {
+ color: var(--code-function);
+}
+
+.token.atrule .token.url .token.punctuation {
+ color: var(--code-punctuation);
+}
+
+.token.keyword {
+ color: var(--code-keyword);
+}
+
+.token.keyword.module,
+.token.keyword.control-flow {
+ color: var(--code-control);
+}
+
+.token.function,
+.token.function .token.maybe-class-name {
+ color: var(--code-function);
+}
+
+.token.regex {
+ color: var(--code-string);
+}
+
+.token.important {
+ color: var(--code-keyword);
+}
+
+.token.italic {
+ font-style: italic;
+}
+
+.token.constant {
+ color: var(--code-constant);
+}
+
+.token.class-name,
+.token.maybe-class-name {
+ color: var(--code-type);
+}
+
+.token.console {
+ color: var(--code-variable);
+}
+
+.token.parameter {
+ color: var(--code-variable);
+}
+
+.token.interpolation {
+ color: var(--code-variable);
+}
+
+.token.punctuation.interpolation-punctuation {
+ color: var(--code-keyword);
+}
+
+.token.boolean {
+ color: var(--code-keyword);
+}
+
+.token.property,
+.token.variable,
+.token.imports .token.maybe-class-name,
+.token.exports .token.maybe-class-name {
+ color: var(--code-variable);
+}
+
+.token.selector {
+ color: var(--code-selector);
+}
+
+.token.escape {
+ color: var(--code-selector);
+}
+
+.token.tag {
+ color: var(--code-tag);
+}
+
+.token.tag .token.punctuation {
+ color: var(--code-punctuation);
+}
+
+.token.cdata {
+ color: var(--code-comment);
+}
+
+.token.attr-name {
+ color: var(--code-constant);
+}
+
+.token.attr-value,
+.token.attr-value .token.punctuation {
+ color: var(--code-string);
+}
+
+.token.attr-value .token.punctuation.attr-equals {
+ color: var(--code-punctuation);
+}
+
+.token.entity {
+ color: var(--code-constant);
+}
+
+.token.namespace {
+ color: var(--code-type);
+}
+
+/*********************************************************
+* Language Specific
+*/
+
+pre[class*="language-javascript"],
+code[class*="language-javascript"],
+pre[class*="language-jsx"],
+code[class*="language-jsx"],
+pre[class*="language-typescript"],
+code[class*="language-typescript"],
+pre[class*="language-tsx"],
+code[class*="language-tsx"] {
+ color: var(--code-punctuation);
+}
+
+pre[class*="language-css"],
+code[class*="language-css"] {
+ color: var(--code-string);
+}
+
+pre[class*="language-html"],
+code[class*="language-html"] {
+ color: var(--code-punctuation);
+}
+
+.language-regex .token.anchor {
+ color: var(--code-function);
+}
+
+.language-html .token.punctuation {
+ color: var(--code-punctuation);
+}
+
+/*********************************************************
+* Line highlighting
+*/
+pre[class*="language-"] > code[class*="language-"] {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.code-line {
+ display: block;
+ padding: 0 1rem;
+}
+.line-highlight.line-highlight {
+ background: #f7ebc6;
+ box-shadow: inset 5px 0 0 #f7d87c;
+ z-index: 0;
+}
+
+.highlight-line {
+ box-shadow: inset 2px 0 hsl(204deg 100% 45%/0.5);
+ background-color: hsl(204deg 100% 77%/0.15);
+ color: hsl(204deg 100% 30%);
+}
+
+.dark .highlight-line {
+ background-color: hsl(204deg 100% 77%/0.1);
+ color: hsl(204deg 100% 75%);
+}
+
+.rehype-code-title {
+ color: rgba(var(--code-highlight-color), 1);
+ background-color: hsl(204deg 100% 77%/0.1);
+ font-size: 0.75rem;
+ padding: 0.5rem 1rem;
+ border-top-left-radius: 0.75rem;
+ border-top-right-radius: 0.75rem;
+ margin-bottom: -37px;
+}
+
+.rehype-code-title + pre[class*="language-"],
+.rehype-code-title + div > pre[class*="language-"] {
+ padding-top: 53px;
+}
diff --git a/starter/blog-next-log/app/styles/themeProvider.tsx b/starter/blog-next-log/app/styles/themeProvider.tsx
new file mode 100644
index 0000000000..eb96d14470
--- /dev/null
+++ b/starter/blog-next-log/app/styles/themeProvider.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { createContext, useContext, useEffect, useState, useCallback } from "react";
+
+type Theme = "light" | "dark" | "system";
+type ResolvedTheme = "light" | "dark";
+
+interface ThemeContextType {
+ theme: Theme;
+ resolvedTheme: ResolvedTheme;
+ setTheme: (theme: Theme) => void;
+}
+
+const defaultContext: ThemeContextType = {
+ theme: "system",
+ resolvedTheme: "light",
+ setTheme: () => {},
+};
+
+const ThemeContext = createContext(defaultContext);
+
+const STORAGE_KEY = "theme";
+
+function getSystemTheme(): ResolvedTheme {
+ if (typeof window === "undefined") return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+}
+
+function applyTheme(resolved: ResolvedTheme) {
+ const root = document.documentElement;
+ root.classList.remove("light", "dark");
+ root.classList.add(resolved);
+}
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setThemeState] = useState("system");
+ const [resolvedTheme, setResolvedTheme] = useState("light");
+ const [mounted, setMounted] = useState(false);
+
+ // Initialize from localStorage
+ useEffect(() => {
+ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
+ const initial = stored || "system";
+ const resolved = initial === "system" ? getSystemTheme() : initial;
+
+ setThemeState(initial);
+ setResolvedTheme(resolved);
+ applyTheme(resolved);
+ setMounted(true);
+ }, []);
+
+ // Listen for system preference changes
+ useEffect(() => {
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const handler = () => {
+ if (theme === "system") {
+ const resolved = getSystemTheme();
+ setResolvedTheme(resolved);
+ applyTheme(resolved);
+ }
+ };
+ media.addEventListener("change", handler);
+ return () => media.removeEventListener("change", handler);
+ }, [theme]);
+
+ const setTheme = useCallback((newTheme: Theme) => {
+ const resolved = newTheme === "system" ? getSystemTheme() : newTheme;
+ setThemeState(newTheme);
+ setResolvedTheme(resolved);
+ applyTheme(resolved);
+ localStorage.setItem(STORAGE_KEY, newTheme);
+ }, []);
+
+ // Prevent flash — don't render children until mounted
+ // The inline script in layout handles initial class, so no FOUC
+ if (!mounted) {
+ return <>{children}>;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ return useContext(ThemeContext);
+}
+
+export default ThemeProvider;
diff --git a/starter/blog-next-log/app/utils/posts.ts b/starter/blog-next-log/app/utils/posts.ts
new file mode 100644
index 0000000000..6b5e1754e4
--- /dev/null
+++ b/starter/blog-next-log/app/utils/posts.ts
@@ -0,0 +1,30 @@
+import fs from "fs";
+import path from "path";
+import matter from "gray-matter";
+import { Post, PostMetadata } from "~types/post";
+
+const postsDirectory = path.join(process.cwd(), "posts");
+
+export function getPostSlugs(): string[] {
+ if (!fs.existsSync(postsDirectory)) return [];
+ return fs.readdirSync(postsDirectory).filter((name) => {
+ const fullPath = path.join(postsDirectory, name);
+ return fs.statSync(fullPath).isDirectory();
+ });
+}
+
+export function getPostBySlug(slug: string): Post | null {
+ const mdxPath = path.join(postsDirectory, slug, "index.mdx");
+ if (!fs.existsSync(mdxPath)) return null;
+ const fileContents = fs.readFileSync(mdxPath, "utf8");
+ const { data, content } = matter(fileContents);
+ return { slug, metadata: data as PostMetadata, content };
+}
+
+export function getAllPosts(): Post[] {
+ const slugs = getPostSlugs();
+ return slugs
+ .map((slug) => getPostBySlug(slug))
+ .filter((post): post is Post => post !== null && post.metadata.published !== false)
+ .sort((a, b) => new Date(b.metadata.date).getTime() - new Date(a.metadata.date).getTime());
+}
diff --git a/starter/blog-next-log/components.json b/starter/blog-next-log/components.json
new file mode 100644
index 0000000000..27a1437e41
--- /dev/null
+++ b/starter/blog-next-log/components.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/styles/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true
+ },
+ "aliases": {
+ "components": "~components",
+ "utils": "~lib/utils"
+ }
+}
diff --git a/starter/blog-next-log/next-env.d.ts b/starter/blog-next-log/next-env.d.ts
new file mode 100644
index 0000000000..830fb594ca
--- /dev/null
+++ b/starter/blog-next-log/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/starter/blog-next-log/next-log.config.ts b/starter/blog-next-log/next-log.config.ts
new file mode 100644
index 0000000000..708f920f31
--- /dev/null
+++ b/starter/blog-next-log/next-log.config.ts
@@ -0,0 +1,26 @@
+import type { SiteConfig } from "./types/config";
+
+const config = {
+ title: "create-next-log",
+ description: "A CLI scaffolder for personal developer blogs, powered by Next.js 15 and MDX",
+ url: "https://create-next-log.vercel.app",
+ language: "en",
+
+ author: {
+ name: "Geon",
+ },
+
+ social: {
+ github: "https://github.com/geonsang-jo/create-next-log",
+ linkedin: "https://www.linkedin.com/in/geonsang-jo-5a570612b",
+ },
+
+ theme: {
+ primaryColor: "#2563eb",
+ },
+
+ googleVerification: "",
+ googleAnalyticsId: "",
+} satisfies SiteConfig;
+
+export default config;
diff --git a/starter/blog-next-log/next.config.ts b/starter/blog-next-log/next.config.ts
new file mode 100644
index 0000000000..a5d2a3f7b2
--- /dev/null
+++ b/starter/blog-next-log/next.config.ts
@@ -0,0 +1,24 @@
+import type { NextConfig } from "next";
+import createMDX from "@next/mdx";
+
+const securityHeaders = [
+ { key: "X-DNS-Prefetch-Control", value: "on" },
+ { key: "X-Frame-Options", value: "DENY" },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "Referrer-Policy", value: "origin-when-cross-origin" },
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
+];
+
+const nextConfig: NextConfig = {
+ pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
+ reactStrictMode: true,
+ async headers() {
+ return [{ source: "/(.*)", headers: securityHeaders }];
+ },
+};
+
+const withMDX = createMDX({
+ extension: /\.mdx?$/,
+});
+
+export default withMDX(nextConfig);
diff --git a/starter/blog-next-log/package.json b/starter/blog-next-log/package.json
new file mode 100644
index 0000000000..beb9ca2756
--- /dev/null
+++ b/starter/blog-next-log/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "next-log",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "new-post": "node scripts/new-post.js",
+ "new-resume": "node scripts/new-resume.js"
+ },
+ "dependencies": {
+ "@mdx-js/loader": "3.0.1",
+ "@mdx-js/react": "3.0.1",
+ "@next/mdx": "15.5.15",
+ "@radix-ui/react-accordion": "1.2.12",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-slot": "1.2.4",
+ "@tailwindcss/postcss": "4.2.4",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "gray-matter": "4.0.3",
+ "lucide-react": "0.469.0",
+ "next": "15.5.15",
+ "next-mdx-remote": "6.0.0",
+ "react": "19.2.5",
+ "react-dom": "19.2.5",
+ "rehype-autolink-headings": "7.1.0",
+ "rehype-code-titles": "1.2.1",
+ "rehype-external-links": "3.0.0",
+ "rehype-prism-plus": "1.6.3",
+ "rehype-slug": "6.0.0",
+ "remark-gfm": "4.0.1",
+ "tailwind-merge": "1.14.0",
+ "tailwindcss": "4.2.4"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "4.2.4",
+ "@tailwindcss/typography": "0.5.19",
+ "@types/node": "20.5.9",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "eslint": "9.39.4",
+ "eslint-config-next": "15.5.15",
+ "typescript": "5.2.2"
+ }
+}
diff --git a/starter/blog-next-log/postcss.config.js b/starter/blog-next-log/postcss.config.js
new file mode 100644
index 0000000000..483f378543
--- /dev/null
+++ b/starter/blog-next-log/postcss.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
diff --git a/starter/blog-next-log/posts/getting-started/index.mdx b/starter/blog-next-log/posts/getting-started/index.mdx
new file mode 100644
index 0000000000..5888521c15
--- /dev/null
+++ b/starter/blog-next-log/posts/getting-started/index.mdx
@@ -0,0 +1,85 @@
+---
+title: "Getting Started"
+date: 2024-01-03
+description: "Set up your blog in under 5 minutes"
+author: "next-log"
+category: "Guide"
+published: true
+---
+
+# Getting Started
+
+Welcome to **next-log**! This guide walks you through setting up your blog.
+
+## Configure Your Blog
+
+Open `next-log.config.ts` in the project root and update the fields:
+
+```typescript:next-log.config.ts
+const config = {
+ title: "My Awesome Blog",
+ description: "Thoughts on web development",
+ url: "https://myblog.com",
+
+ author: {
+ name: "Jane Doe",
+ },
+
+ social: {
+ github: "https://github.com/janedoe",
+ linkedin: "https://linkedin.com/in/janedoe",
+ },
+
+ theme: {
+ primaryColor: "#2563eb",
+ },
+};
+
+export default config;
+```
+
+- **title** and **description** are used for SEO and the site header.
+- **url** should be your production domain.
+- **social** links appear as icons in the header. Set a link to `""` (empty string) to hide that icon.
+- **primaryColor** accepts any CSS color value. Try `"#10b981"` for green or `"#f59e0b"` for amber.
+
+## Create Your First Post
+
+Run the following command to scaffold a new post:
+
+```bash:Terminal
+npm run new-post "my-first-post"
+```
+
+This creates `posts/my-first-post/index.mdx` with frontmatter already filled in. Open it and start writing!
+
+## Add a Resume Page
+
+If you want a resume/about page:
+
+```bash:Terminal
+npm run new-resume
+```
+
+This generates a resume template you can customize with your experience and skills.
+
+## Publishing Workflow
+
+New posts are created with `published: false` by default. This lets you draft posts without them appearing on your site.
+
+When you are ready to publish, change the frontmatter:
+
+```yaml:frontmatter
+published: true
+```
+
+Posts with `published: false` are hidden from the blog listing and sitemap.
+
+## Deploy to Vercel
+
+1. Push your project to a GitHub repository.
+2. Go to [vercel.com](https://vercel.com) and import the repository.
+3. Vercel auto-detects Next.js -- just click **Deploy**.
+4. Every push to `main` will automatically redeploy your blog.
+
+That's it! Your blog is live.
diff --git a/starter/blog-next-log/posts/hello-world/index.mdx b/starter/blog-next-log/posts/hello-world/index.mdx
new file mode 100644
index 0000000000..05609fe9ad
--- /dev/null
+++ b/starter/blog-next-log/posts/hello-world/index.mdx
@@ -0,0 +1,24 @@
+---
+title: "Hello World"
+date: 2024-01-04
+description: "Welcome to your new blog"
+author: "Author"
+category: "General"
+published: true
+---
+
+# Hello World
+
+Welcome to your blog! This is a sample post.
+
+Check out the other sample posts to learn how to write with MDX and Markdown:
+
+- [Getting Started](/post/getting-started) -- Set up your blog
+- [Writing with MDX](/post/writing-with-mdx) -- Use React components in posts
+- [Markdown Guide](/post/markdown-guide) -- Markdown syntax reference
+
+You can delete these sample posts and start writing your own:
+
+```bash:Terminal
+npm run new-post "my-first-post"
+```
diff --git a/starter/blog-next-log/posts/markdown-guide/index.mdx b/starter/blog-next-log/posts/markdown-guide/index.mdx
new file mode 100644
index 0000000000..9b04ea3911
--- /dev/null
+++ b/starter/blog-next-log/posts/markdown-guide/index.mdx
@@ -0,0 +1,176 @@
+---
+title: "Markdown Guide"
+date: 2024-01-01
+description: "Everything you can do with Markdown in your posts"
+author: "next-log"
+category: "Guide"
+published: true
+---
+
+# Markdown Guide
+
+A complete reference for writing Markdown in your blog posts.
+
+## Headings
+
+Use `##` for main sections and `###` for subsections. The table of contents on the right side of each post is automatically generated from your `##` and `###` headings.
+
+```markdown
+## Section Title
+### Subsection Title
+```
+
+> **Note:** Avoid using `#` (h1) in your post body -- the post title from frontmatter is already rendered as h1.
+
+## Text Formatting
+
+You can format text in several ways:
+
+- **Bold text** with `**double asterisks**`
+- *Italic text* with `*single asterisks*`
+- ~~Strikethrough~~ with `~~double tildes~~`
+- `Inline code` with `` `backticks` ``
+- **_Bold and italic_** with `**_combined_**`
+
+## Lists
+
+### Unordered Lists
+
+- First item
+- Second item
+ - Nested item
+ - Another nested item
+ - Deeply nested
+- Third item
+
+### Ordered Lists
+
+1. First step
+2. Second step
+3. Third step
+ 1. Sub-step A
+ 2. Sub-step B
+
+### Task Lists
+
+- [x] Set up the project
+- [x] Write the first post
+- [ ] Add more content
+- [ ] Deploy to production
+
+## Blockquotes
+
+> This is a blockquote. Use it for callouts, notes, or highlighting important information.
+
+> You can also have multi-paragraph blockquotes.
+>
+> Just keep using `>` on each line.
+
+## Links
+
+Regular links work as expected:
+
+- [Internal link to Getting Started](/post/getting-started)
+- [External link to Next.js](https://nextjs.org)
+
+External links automatically open in a new tab.
+
+## Images
+
+Place images in the `public/posts/` directory and reference them:
+
+```markdown
+
+```
+
+Or use a full URL for external images:
+
+```markdown
+
+```
+
+## Tables
+
+| Feature | Status | Notes |
+| ------------- | --------- | ------------------ |
+| Dark mode | Supported | Automatic toggle |
+| MDX | Supported | React components |
+| RSS | Supported | Auto-generated |
+| Search | Supported | Full-text search |
+
+Align columns with colons:
+
+```markdown
+| Left | Center | Right |
+| :----- | :-----: | -----: |
+| text | text | text |
+```
+
+## Horizontal Rules
+
+Separate sections with a horizontal rule:
+
+---
+
+Use three dashes `---` on their own line.
+
+## Code Blocks
+
+Specify a language for syntax highlighting:
+
+### JavaScript
+
+```javascript:greet.js {2}
+function greet(name) {
+ return `Hello, ${name}!`;
+}
+
+console.log(greet("World"));
+```
+
+### Python
+
+```python:fibonacci.py {3-5}
+def fibonacci(n):
+ a, b = 0, 1
+ for _ in range(n):
+ yield a
+ a, b = b, a + b
+
+for num in fibonacci(10):
+ print(num)
+```
+
+### CSS
+
+```css:theme.css {2-3}
+:root {
+ --primary: #2563eb;
+ --background: #ffffff;
+}
+
+body {
+ font-family: system-ui, sans-serif;
+ color: var(--primary);
+ background: var(--background);
+}
+```
+
+### Line Highlighting
+
+Highlight specific lines by adding `{lines}` after the language identifier:
+
+````markdown
+```typescript {3,5-7}
+// line 3 and lines 5-7 will be highlighted
+```
+````
+
+## Footnotes
+
+You can add footnotes for additional context[^1].
+
+They are rendered at the bottom of the post[^2].
+
+[^1]: This is the first footnote with additional detail.
+[^2]: Footnotes are great for citations and side notes.
diff --git a/starter/blog-next-log/posts/writing-with-mdx/index.mdx b/starter/blog-next-log/posts/writing-with-mdx/index.mdx
new file mode 100644
index 0000000000..7b63cc13e0
--- /dev/null
+++ b/starter/blog-next-log/posts/writing-with-mdx/index.mdx
@@ -0,0 +1,158 @@
+---
+title: "Writing with MDX"
+date: 2024-01-02
+description: "Use React components inside your blog posts"
+author: "next-log"
+category: "Guide"
+published: true
+---
+
+# Writing with MDX
+
+MDX lets you use React components directly in your Markdown. next-log comes with several built-in components you can use right away.
+
+## Timeline
+
+The Timeline component is great for showing project milestones, changelogs, or step-by-step processes. Click any item to expand it.
+
+
+
+ We chose Next.js as the framework and decided on MDX for content authoring. The initial project structure was scaffolded and the team agreed on coding conventions.
+
+
+ Core features were complete: blog listing, post pages, dark mode, and search. We collected feedback from 20 beta users and prioritized improvements based on their input.
+
+
+ All feedback items were addressed. We added a resume page, social links, and SEO optimizations. The documentation site went live alongside the release.
+
+
+
+## FileTree
+
+Use the FileTree component to display project structures. Folders can be expanded and collapsed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Code Blocks
+
+Fenced code blocks support syntax highlighting for many languages. Just specify the language after the triple backticks.
+
+### TypeScript / React
+
+```tsx:Greeting.tsx {6-8}
+interface Props {
+ name: string;
+ count?: number;
+}
+
+export function Greeting({ name, count = 0 }: Props) {
+ return (
+
+
Hello, {name}!
+
You have visited {count} times.
+
+ );
+}
+```
+
+### CSS
+
+```css:styles.css {2-4}
+.container {
+ max-width: 768px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ .container {
+ background: #1a1a2e;
+ color: #eaeaea;
+ }
+}
+```
+
+### Terminal Commands
+
+```bash:Terminal
+# Create a new post
+npm run new-post "my-post-title"
+
+# Start the dev server
+npm run dev
+
+# Build for production
+npm run build
+```
+
+### JSON
+
+```json:package.json {4-6}
+{
+ "name": "my-blog",
+ "version": "1.0.0",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ }
+}
+```
+
+### Line Highlighting
+
+You can highlight specific lines in any code block by adding `{lines}` after the language:
+
+- `{3}` — highlight line 3
+- `{1,4}` — highlight lines 1 and 4
+- `{2-5}` — highlight lines 2 through 5
+- `{1,3-5,8}` — combine individual lines and ranges
+
+## Adding Your Own Components
+
+You can create custom MDX components and register them in `app/components/mdx/index.ts`:
+
+```typescript:app/components/mdx/index.ts
+import { Timeline, TimelineItem } from "./Timeline";
+import { FileTree, Folder, File } from "./FileTree";
+import { MyComponent } from "./MyComponent"; // your custom component
+
+export const mdxComponents = {
+ Timeline,
+ TimelineItem,
+ FileTree,
+ Folder,
+ File,
+ MyComponent, // register it here
+};
+```
+
+Once registered, use it in any `.mdx` file without imports:
+
+```html
+
+ Content goes here.
+
+```
diff --git a/starter/blog-next-log/public/favicon-dark.svg b/starter/blog-next-log/public/favicon-dark.svg
new file mode 100644
index 0000000000..025097599b
--- /dev/null
+++ b/starter/blog-next-log/public/favicon-dark.svg
@@ -0,0 +1,4 @@
+
diff --git a/starter/blog-next-log/public/favicon-light.svg b/starter/blog-next-log/public/favicon-light.svg
new file mode 100644
index 0000000000..5a3706ba8a
--- /dev/null
+++ b/starter/blog-next-log/public/favicon-light.svg
@@ -0,0 +1,4 @@
+
diff --git a/starter/blog-next-log/public/posts/hello-world/.gitkeep b/starter/blog-next-log/public/posts/hello-world/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/starter/blog-next-log/scripts/new-post.js b/starter/blog-next-log/scripts/new-post.js
new file mode 100644
index 0000000000..656d81d1ab
--- /dev/null
+++ b/starter/blog-next-log/scripts/new-post.js
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+const path = require("path");
+
+const slug = process.argv[2];
+
+if (!slug) {
+ console.error("Usage: npm run new-post ");
+ console.error('Example: npm run new-post "my-first-post"');
+ process.exit(1);
+}
+
+// Read author from config
+let author = "Author";
+try {
+ const configPath = path.join(process.cwd(), "next-log.config.ts");
+ const configContent = fs.readFileSync(configPath, "utf8");
+ const match = configContent.match(/name:\s*["']([^"']+)["']/);
+ if (match) author = match[1];
+} catch (e) {
+ // fallback to default
+}
+
+const postsDir = path.join(process.cwd(), "posts", slug);
+
+if (fs.existsSync(postsDir)) {
+ console.error(`Error: Post "${slug}" already exists at posts/${slug}/`);
+ process.exit(1);
+}
+
+fs.mkdirSync(postsDir, { recursive: true });
+
+const today = new Date().toISOString().split("T")[0];
+
+const content = `---
+title: "${slug}"
+date: ${today}
+description: ""
+author: "${author}"
+category: ""
+thumbnail: ""
+published: false
+---
+
+Write your post here.
+`;
+
+const filePath = path.join(postsDir, "index.mdx");
+fs.writeFileSync(filePath, content);
+
+console.log(`Created: posts/${slug}/index.mdx`);
+console.log(`Set published: true when ready to publish.`);
diff --git a/starter/blog-next-log/scripts/new-resume.js b/starter/blog-next-log/scripts/new-resume.js
new file mode 100644
index 0000000000..1ce04db2e2
--- /dev/null
+++ b/starter/blog-next-log/scripts/new-resume.js
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+const path = require("path");
+
+const resumeDir = path.join(process.cwd(), "app", "resume");
+const pagePath = path.join(resumeDir, "page.tsx");
+const dataPath = path.join(resumeDir, "data.ts");
+
+if (fs.existsSync(pagePath)) {
+ console.error("Error: Resume page already exists at app/resume/page.tsx");
+ process.exit(1);
+}
+
+// Read author name from config
+let authorName = "Geon";
+try {
+ const configPath = path.join(process.cwd(), "next-log.config.ts");
+ const configContent = fs.readFileSync(configPath, "utf8");
+ const match = configContent.match(/name:\s*["']([^"']+)["']/);
+ if (match && match[1] !== "Author") authorName = match[1];
+} catch {}
+
+fs.mkdirSync(resumeDir, { recursive: true });
+
+// --- data.ts ---
+const dataTemplate = `export const resumeData = {
+ name: "${authorName}",
+ title: "Frontend Engineer",
+ summary:
+ "Passionate frontend engineer with experience building modern web applications. Focused on user experience, performance, and clean code.",
+
+ experience: [
+ {
+ company: "Tech Corp",
+ position: "Senior Frontend Engineer",
+ period: "2022.03 ~ Present",
+ summary: "Leading frontend development for the main product.",
+ projects: [
+ {
+ name: "Design System",
+ duration: "2023.01 ~ Present",
+ description:
+ "Built and maintained a company-wide design system used by 5 teams.",
+ responsibilities: [
+ "Developed 30+ reusable React components with Storybook documentation",
+ "Reduced UI inconsistencies by 80% across products",
+ "Implemented automated visual regression testing with Chromatic",
+ ],
+ },
+ {
+ name: "Performance Optimization",
+ duration: "2022.06 ~ 2022.12",
+ description:
+ "Led a performance initiative to improve Core Web Vitals.",
+ responsibilities: [
+ "Reduced LCP from 4.2s to 1.8s through code splitting and image optimization",
+ "Implemented lazy loading for below-the-fold components",
+ "Set up performance monitoring with custom dashboards",
+ ],
+ },
+ ],
+ },
+ {
+ company: "Startup Inc",
+ position: "Frontend Engineer",
+ period: "2020.01 ~ 2022.02",
+ summary: "Full-stack web development for an early-stage startup.",
+ projects: [
+ {
+ name: "Customer Dashboard",
+ duration: "2020.06 ~ 2022.02",
+ description:
+ "Built the main customer-facing dashboard from scratch.",
+ responsibilities: [
+ "Developed responsive dashboard with React and TypeScript",
+ "Integrated RESTful APIs with React Query for data fetching",
+ "Implemented real-time notifications using WebSocket",
+ ],
+ },
+ ],
+ },
+ ],
+
+ skills: [
+ {
+ category: "Frontend",
+ items: [
+ "React, Next.js, TypeScript — primary stack for all projects",
+ "Tailwind CSS, CSS Modules — styling approaches",
+ "React Query, Zustand — state management and data fetching",
+ ],
+ },
+ {
+ category: "Tools & Infrastructure",
+ items: [
+ "Git, GitHub Actions — version control and CI/CD",
+ "Storybook, Chromatic — component documentation and visual testing",
+ "Vercel, AWS — deployment and hosting",
+ ],
+ },
+ ],
+};
+`;
+
+// --- page.tsx ---
+const pageTemplate = `import { resumeData } from "./data";
+
+export default function ResumePage() {
+ return (
+
+ {/* Header */}
+
+ {resumeData.name}
+
+ {resumeData.title}
+
+
+ {resumeData.summary}
+
+
+
+ {/* Experience */}
+
+
+ Experience
+
+
+ {resumeData.experience.map((exp) => (
+
+ {/* Left: Company info (sticky on desktop) */}
+
+
{exp.company}
+
+ {exp.position}
+
+
{exp.period}
+ {exp.summary && (
+
+ {exp.summary}
+
+ )}
+
+ {/* Right: Projects */}
+
+ {exp.projects.map((project) => (
+
+
{project.name}
+
+ {project.duration}
+
+
+ {project.description}
+
+
+ {project.responsibilities.map((r, i) => (
+ - {r}
+ ))}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* Skills */}
+
+
Skills
+
+ {resumeData.skills.map((skill) => (
+
+
{skill.category}
+
+ {skill.items.map((item, i) => (
+ - {item}
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
+`;
+
+fs.writeFileSync(dataPath, dataTemplate);
+fs.writeFileSync(pagePath, pageTemplate);
+
+console.log("Created: app/resume/data.ts");
+console.log("Created: app/resume/page.tsx");
+console.log("");
+console.log("Resume link will automatically appear in the header navigation.");
+console.log("Edit app/resume/data.ts to update your resume content.");
diff --git a/starter/blog-next-log/tsconfig.json b/starter/blog-next-log/tsconfig.json
new file mode 100644
index 0000000000..59df5b91b1
--- /dev/null
+++ b/starter/blog-next-log/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "paths": {
+ "~types/*": ["./types/*"],
+ "~*": ["./app/*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "types/**/*.ts"
+ ],
+ "exclude": ["node_modules", ".next"]
+}
diff --git a/starter/blog-next-log/types/config.ts b/starter/blog-next-log/types/config.ts
new file mode 100644
index 0000000000..345f4f9ae5
--- /dev/null
+++ b/starter/blog-next-log/types/config.ts
@@ -0,0 +1,18 @@
+export interface SiteConfig {
+ title: string;
+ description: string;
+ url: string;
+ language: string;
+ author: {
+ name: string;
+ };
+ social: {
+ github: string;
+ linkedin: string;
+ };
+ theme: {
+ primaryColor: string;
+ };
+ googleVerification: string;
+ googleAnalyticsId: string;
+}
diff --git a/starter/blog-next-log/types/icon.ts b/starter/blog-next-log/types/icon.ts
new file mode 100644
index 0000000000..39d5135632
--- /dev/null
+++ b/starter/blog-next-log/types/icon.ts
@@ -0,0 +1,3 @@
+import { LucideProps } from "lucide-react";
+
+export interface IconProps extends LucideProps {}
diff --git a/starter/blog-next-log/types/post.ts b/starter/blog-next-log/types/post.ts
new file mode 100644
index 0000000000..3ef0343c14
--- /dev/null
+++ b/starter/blog-next-log/types/post.ts
@@ -0,0 +1,18 @@
+export type PostMetadata = {
+ title: string;
+ date: string;
+ thumbnail?: string;
+ description: string;
+ author: string;
+ introTitle?: string;
+ introDesc?: string;
+ category?: string;
+ highlightWord?: string;
+ published?: boolean;
+};
+
+export type Post = {
+ slug: string;
+ metadata: PostMetadata;
+ content: string;
+};