Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions starter/blog-next-log/README.md
Original file line number Diff line number Diff line change
@@ -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):

[![Deploy with Vercel](https://vercel.com/button)](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
158 changes: 158 additions & 0 deletions starter/blog-next-log/app/api/og/[slug]/route.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div
key={`${row}-${col}`}
style={{
position: "absolute",
left: col * spacingX + 25,
top: row * spacingY + 15,
width: 3,
height: 3,
borderRadius: "50%",
background: "rgba(0, 0, 0, 0.12)",
}}
/>
);
}
}
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) ? (
<span
key={i}
style={{
background: "linear-gradient(90deg, #7b5ea7, #9f6ba0, #d47a8c)",
backgroundClip: "text",
color: "transparent",
}}
>
{part}
</span>
) : (
<span key={i} style={{ color: "#1a1a1a" }}>{part}</span>
)
);
}

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(
(
<div
style={{
display: "flex",
height: "100%",
width: "100%",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundImage:
"linear-gradient(135deg, #f0f0f5, #e8e6f0, #f5f3fa)",
position: "relative",
fontFamily: "Pretendard",
}}
>
{renderDots()}
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
fontSize: 56,
fontWeight: 700,
lineHeight: 1.3,
maxWidth: "1000px",
padding: "0 60px",
letterSpacing: "-1px",
wordBreak: "keep-all",
}}
>
{renderTitle(title, highlightWord)}
</div>
<div
style={{
position: "absolute",
bottom: 40,
right: 50,
display: "flex",
alignItems: "center",
fontSize: 24,
color: "#888888",
fontWeight: 400,
}}
>
{getConfig().author.name}
</div>
</div>
),
{
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 }
);
}
}
28 changes: 28 additions & 0 deletions starter/blog-next-log/app/components/GoogleAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}');
`}
</Script>
</>
);
}
22 changes: 22 additions & 0 deletions starter/blog-next-log/app/components/header/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getNavItems } from "~lib/nav";
import NavToggles from "./toggle";
import PageNav from "./nav";

function Header() {
const navItems = getNavItems();

return (
<header className="sticky top-0 z-10 w-full border-b bg-background/95 backdrop-blur">
<nav aria-label="Main navigation" className="container flex h-14 items-center justify-between lg:px-8">
<div className="md:flex">
<PageNav navItems={navItems} />
</div>
<div className="flex flex-1 items-center justify-end space-x-2">
<NavToggles />
</div>
</nav>
</header>
);
}

export default Header;
51 changes: 51 additions & 0 deletions starter/blog-next-log/app/components/header/nav/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="md:flex">
<Link
href="/"
className="hidden md:flex mr-6 items-center space-x-2"
>
<span className="font-bold sm:inline-block">{config.author.name}</span>
</Link>
<nav className="hidden md:flex gap-6 items-center font-medium text-sm">
{navItems.map((item) => (
<NavLink key={item.path} href={item.path} label={item.label} />
))}
</nav>
<NavSheet navItems={navItems} />
</div>
);
}

function NavLink({ href, label }: { href: string; label: string }) {
const isActive = useIsRouteActive(href);

return (
<Link
href={href}
className={cn(
"transition-colors hover:text-foreground/80 text-foreground/60",
isActive && "text-foreground"
)}
>
{label}
</Link>
);
}

export default PageNav;
Loading