├── .env.example
├── .eslintrc.json
├── .gitignore
├── .npmrc
├── .vscode
└── settings.json
├── README.md
├── README_ja.md
├── README_zh.md
├── actions
└── newsletter.ts
├── app
├── BaiDuAnalytics.tsx
├── GoogleAdsense.tsx
├── GoogleAnalytics.tsx
├── PlausibleAnalytics.tsx
├── [locale]
│ ├── about
│ │ └── page.tsx
│ ├── blog
│ │ ├── BlogCard.tsx
│ │ ├── [slug]
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ ├── privacy-policy
│ │ └── page.tsx
│ ├── terms-of-service
│ │ └── page.tsx
│ └── unsubscribe
│ │ └── page.tsx
├── api
│ └── newsletter
│ │ └── route.ts
├── robots.ts
└── sitemap.ts
├── blogs
├── en
│ └── 1.demo.mdx
├── ja
│ └── 1.demo.mdx
└── zh
│ ├── 1.demo.mdx
│ └── 2.demo2.mdx
├── components.json
├── components
├── BuiltWithButton.tsx
├── LanguageDetectionAlert.tsx
├── LocaleSwitcher.tsx
├── TailwindIndicator.tsx
├── ThemeToggle.tsx
├── WebsiteLogo.tsx
├── footer
│ ├── Badges.tsx
│ ├── Footer.tsx
│ └── Newsletter.tsx
├── header
│ ├── Header.tsx
│ ├── HeaderLinks.tsx
│ └── MobileMenu.tsx
├── home
│ └── index.tsx
├── icons
│ ├── expanding-arrow.tsx
│ ├── eye.tsx
│ ├── index.tsx
│ ├── loading-circle.tsx
│ ├── loading-dots.module.css
│ ├── loading-dots.tsx
│ ├── loading-spinner.module.css
│ ├── loading-spinner.tsx
│ ├── moon.tsx
│ └── sun.tsx
├── mdx
│ ├── Aside.tsx
│ ├── Callout.tsx
│ ├── MDXComponents.tsx
│ └── MdxCard.tsx
├── social-icons
│ ├── icons.tsx
│ └── index.tsx
└── ui
│ ├── alert.tsx
│ ├── button.tsx
│ ├── dropdown-menu.tsx
│ ├── select.tsx
│ ├── toast.tsx
│ └── toaster.tsx
├── config
└── site.ts
├── content
├── about
│ ├── en.mdx
│ ├── ja.mdx
│ └── zh.mdx
├── privacy-policy
│ ├── en.mdx
│ ├── ja.mdx
│ └── zh.mdx
└── terms-of-service
│ ├── en.mdx
│ ├── ja.mdx
│ └── zh.mdx
├── gtag.js
├── hooks
└── use-toast.ts
├── i18n
├── messages
│ ├── en.json
│ ├── ja.json
│ └── zh.json
├── request.ts
└── routing.ts
├── lib
├── email.ts
├── getBlogs.ts
├── logger.ts
├── metadata.ts
└── utils.ts
├── log
└── .db69a462fcd0bc6ae6d0cc5220e6aa21ec198c81-audit.json
├── middleware.ts
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── ads.txt
├── favicon.ico
├── logo.png
├── logo.svg
├── logo_nexty.png
├── og.png
├── og.webp
├── placeholder.svg
├── try-nexty.webp
└── zs.jpeg
├── stores
└── localeStore.ts
├── styles
├── globals.css
└── loading.css
├── tailwind.config.ts
├── tsconfig.json
└── types
├── blog.ts
├── common.ts
└── siteConfig.ts
/.env.example:
--------------------------------------------------------------------------------
1 | # -----------------------------------------------------------------------------
2 | # Site info
3 | # -----------------------------------------------------------------------------
4 | NEXT_PUBLIC_SITE_URL=http://localhost:3000
5 | NEXT_PUBLIC_LOCALE_DETECTION=false
6 |
7 | # -----------------------------------------------------------------------------
8 | # Discord
9 | # -----------------------------------------------------------------------------
10 | NEXT_PUBLIC_DISCORD_INVITE_URL="https://discord.com/invite/R7bUxWKRqZ"
11 |
12 | # -----------------------------------------------------------------------------
13 | # Analytics
14 | # Google Analytics: https://analytics.google.com/analytics/web/
15 | # Baidu Tongji: https://tongji.baidu.com/
16 | # Plausible: https://plausible.io/
17 | # -----------------------------------------------------------------------------
18 | NEXT_PUBLIC_GOOGLE_ID=
19 | NEXT_PUBLIC_BAIDU_TONGJI=
20 | NEXT_PUBLIC_PLAUSIBLE_DOMAIN=
21 | NEXT_PUBLIC_PLAUSIBLE_SRC=
22 |
23 | #------------------------------------------------------------------------
24 | # Ads
25 | # Google Adsense: https://www.google.com/adsense/
26 | #------------------------------------------------------------------------
27 | NEXT_PUBLIC_GOOGLE_ADSENSE_ID=
28 |
29 | #------------------------------------------------------------------------
30 | # Resend: https://resend.com/
31 | #------------------------------------------------------------------------
32 | RESEND_API_KEY=
33 | ADMIN_EMAIL=
34 | RESEND_AUDIENCE_ID=
35 |
36 | #------------------------------------------------------------------------
37 | # Upstash: https://upstash.com/
38 | #------------------------------------------------------------------------
39 | UPSTASH_REDIS_REST_URL=
40 | UPSTASH_REDIS_REST_TOKEN=
41 | UPSTASH_REDIS_NEWSLETTER_RATE_LIMIT_KEY=newsletter-rate-limit
42 | DAY_MAX_SUBMISSIONS=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Node template
2 | .idea
3 | .DS_Store
4 | dist
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 | .temp
14 | yarn.lock
15 |
16 | # Diagnostic reports (https://nodejs.org/api/report.html)
17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 | *.lcov
31 |
32 | # nyc test coverage
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 | .grunt
37 |
38 | # Bower dependency directory (https://bower.io/)
39 | bower_components
40 |
41 | # node-waf configuration
42 | .lock-wscript
43 |
44 | # Compiled binary addons (https://nodejs.org/api/addons.html)
45 | build/Release
46 |
47 | # Dependency directories
48 | node_modules/
49 | jspm_packages/
50 |
51 | # Snowpack dependency directory (https://snowpack.dev/)
52 | web_modules/
53 |
54 | # TypeScript cache
55 | *.tsbuildinfo
56 |
57 | # Optional npm cache directory
58 | .npm
59 |
60 | # Optional eslint cache
61 | .eslintcache
62 |
63 | # Microbundle cache
64 | .rpt2_cache/
65 | .rts2_cache_cjs/
66 | .rts2_cache_es/
67 | .rts2_cache_umd/
68 |
69 | # Optional REPL history
70 | .node_repl_history
71 |
72 | # Output of 'npm pack'
73 | *.tgz
74 |
75 | # Yarn Integrity file
76 | .yarn-integrity
77 |
78 | # dotenv environment variables file
79 | .env
80 | .env.local
81 | .env.test
82 |
83 | # parcel-bundler cache (https://parceljs.org/)
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 | out
90 |
91 | # Nuxt.js build / generate output
92 | .nuxt
93 | dist
94 |
95 | # Gatsby files
96 | .cache/
97 | # Comment in the assets line in if your project uses Gatsby and not Next.js
98 | # https://nextjs.org/blog/next-9-1#public-directory-support
99 | # assets
100 |
101 | # vuepress build output
102 | .vuepress/dist
103 |
104 | # Serverless directories
105 | .serverless/
106 |
107 | # FuseBox cache
108 | .fusebox/
109 |
110 | # DynamoDB Local files
111 | .dynamodb/
112 |
113 | # TernJS port file
114 | .tern-port
115 |
116 | # Stores VSCode versions used for testing VSCode extensions
117 | .vscode-test
118 |
119 | # yarn v2
120 | .yarn/cache
121 | .yarn/unplugged
122 | .yarn/build-state.yml
123 | .yarn/install-state.gz
124 | .pnp.*
125 |
126 | /.vuepress/dist/
127 |
128 | # sitemap
129 | */sitemap*.xml
130 | */robots.txt
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # if use pnpm
2 | enable-pre-post-scripts=true
3 | public-hoist-pattern[]=*@nextui-org/*
4 | registry=https://registry.npmmirror.com/
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll.eslint": "explicit",
7 | "source.organizeImports": "explicit"
8 | },
9 | "headwind.runOnSave": false,
10 | "typescript.preferences.importModuleSpecifier": "non-relative",
11 | "eslint.validate": ["javascript", "javascriptreact", "typescript"],
12 | "typescript.tsdk": "node_modules/typescript/lib",
13 | "commentTranslate.source": "Bing",
14 | "cSpell.words": [
15 | "contentlayer",
16 | "lemonsqueezy"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | Ship your SaaS faster with Nexty - the only Next.js boilerplate featuring a visual pricing dashboard, AI playground, and enterprise-level CMS, plus i18n, auth, payment, email, and SEO optimization.
3 |
4 | Try [Nexty.dev today](https://nexty.dev?utm_source=github-nextjs-starter)
5 | ---
6 |
7 | [
](https://nexty.dev?utm_source=github-nextjs-starter)
8 |
9 |
10 | 🌍 *[English](README.md) ∙ [简体中文](README_zh.md) ∙ [日本語](README_ja.md)*
11 |
12 | # Next Forge - Multilingual Next.js 15 Starter
13 |
14 | A feature-rich Next.js 15 multilingual starter template to help you quickly build globally-ready websites.
15 |
16 | - [👉 Source Code](https://github.com/weijunext/nextjs-15-starter)
17 | - [👉 Live Demo](https://nextforge.dev/)
18 |
19 | **🚀 Looking for a full-featured SaaS Starter Template? [Check out the complete version](https://nexty.dev)**
20 |
21 | ## ✨ Features
22 |
23 | - 🌐 Built-in i18n support (English, Chinese, Japanese)
24 | - 🎨 Modern UI design with Tailwind CSS
25 | - 🌙 Dark/Light theme toggle
26 | - 📱 Responsive layout
27 | - 📝 MDX blog system
28 | - 🔍 SEO optimization
29 | - 📊 Integrated analytics tools
30 | - Google Analytics
31 | - Baidu Analytics
32 | - Google Adsense
33 | - Vercel Analytics
34 |
35 | ## 🚀 Quick Start
36 |
37 | ### Prerequisites
38 |
39 | - Node.js 18.17 or higher
40 | - pnpm 9.0 or higher (recommended)
41 |
42 | > **Note**: The project has configured `packageManager` field, we recommend using pnpm for the best experience.
43 |
44 | ### Installation
45 |
46 | 1. Clone the repository:
47 | ```bash
48 | git clone https://github.com/weijunext/nextjs-15-starter.git
49 | cd nextjs-15-starter
50 | ```
51 |
52 | 2. Enable Corepack (recommended):
53 | ```bash
54 | corepack enable
55 | ```
56 |
57 | 3. Install dependencies:
58 | ```bash
59 | pnpm install
60 | # or use other package managers
61 | npm install
62 | yarn
63 | ```
64 |
65 | 4. Copy environment variables:
66 | ```bash
67 | cp .env.example .env
68 | ```
69 |
70 | 5. Start the development server:
71 | ```bash
72 | pnpm dev
73 | # or npm run dev
74 | ```
75 |
76 | Visit http://localhost:3000 to view your application.
77 |
78 | ## ⚙️ Configuration
79 |
80 | 1. Basic Setup
81 | - Edit `config/site.ts` for website information
82 | - Update icons and logo in `public/`
83 | - Configure `app/sitemap.ts` for sitemap
84 | - Update `app/robots.ts` for robots.txt
85 |
86 | 2. i18n Setup
87 | - Add/modify language files in `i18n/messages/`
88 | - Configure supported languages in `i18n/routing.ts`
89 | - Set up i18n routing in `middleware.ts`
90 | - Create pages under `app/[locale]/`
91 | - Use the `Link` component from `i18n/routing.ts` instead of Next.js default
92 |
93 | ## 📝 Content Management
94 |
95 | ### Blog Posts
96 | Create MDX files in `blog/[locale]` with the following format:
97 |
98 | ```markdown
99 | ---
100 | title: Post Title
101 | description: Post Description
102 | image: /image.png
103 | slug: /url-path
104 | tags: tag1,tag2
105 | date: 2025-02-20
106 | visible: published
107 | pin: true
108 | ---
109 |
110 | Post content...
111 | ```
112 |
113 | Reference `types/blog.ts` for supported fields.
114 |
115 | ### Static Pages
116 | Manage static page content in `content/[page]/[locale].mdx`.
117 |
118 | ## 🔍 SEO Optimization
119 |
120 | Built-in comprehensive SEO features:
121 | - Server-side rendering and static generation
122 | - Automatic sitemap.xml generation
123 | - robots.txt configuration
124 | - Optimized metadata
125 | - Open Graph support
126 | - Multilingual SEO support
127 |
128 | ## 📊 Analytics
129 |
130 | Enable analytics by adding IDs in `.env`:
131 | ```
132 | NEXT_PUBLIC_GOOGLE_ANALYTICS=
133 | NEXT_PUBLIC_BAIDU_TONGJI=
134 | NEXT_PUBLIC_GOOGLE_ADSENSE=
135 | ```
136 |
137 | ## 📁 Project Structure
138 |
139 | ```
140 | nextjs-15-starter/
141 | ├── app/ # App directory
142 | │ ├── [locale]/ # Internationalized routes
143 | │ │ ├── about/ # About page
144 | │ │ ├── blog/ # Blog pages
145 | │ │ └── ... # Other pages
146 | │ ├── api/ # API routes
147 | │ └── globals/ # Global components
148 | ├── blog/ # Blog content (MDX)
149 | │ ├── en/ # English blog
150 | │ ├── ja/ # Japanese blog
151 | │ └── zh/ # Chinese blog
152 | ├── components/ # Reusable components
153 | │ ├── ui/ # Base UI components
154 | │ ├── header/ # Header components
155 | │ ├── footer/ # Footer components
156 | │ └── ... # Other components
157 | ├── config/ # Configuration files
158 | ├── content/ # Static content (MDX)
159 | ├── i18n/ # Internationalization
160 | │ ├── messages/ # Translation files
161 | │ ├── routing.ts # Routing configuration
162 | │ └── request.ts # Request configuration
163 | ├── lib/ # Utility functions
164 | ├── public/ # Static assets
165 | └── types/ # Type definitions
166 | ```
167 |
168 | ## 🛠️ Tech Stack
169 |
170 | - **Framework**: Next.js 15 (App Router)
171 | - **Language**: TypeScript
172 | - **Styling**: Tailwind CSS + Shadcn/ui
173 | - **Internationalization**: next-intl
174 | - **Content**: MDX
175 | - **State Management**: Zustand
176 | - **Deployment**: Vercel
177 | - **Package Manager**: pnpm (recommended)
178 |
179 | ## 🚀 Deployment
180 |
181 | ### One-Click Deploy
182 |
183 | [](https://vercel.com/new/clone?repository-url=https://github.com/weijunext/nextjs-15-starter&project-name=&repository-name=nextjs-15-starter&demo-title=Nextjs15Starter&demo-description=Nextjs%2015%20starter.&demo-url=https://nextforge.dev&demo-image=https://nextforge.dev/og.png)
184 |
185 | ### Manual Deployment to Vercel
186 |
187 | 1. Push your code to GitHub
188 | 2. Import project in Vercel
189 | 3. Configure environment variables
190 | 4. Deploy
191 |
192 | ### Other Platforms
193 |
194 | ```bash
195 | # Build for production
196 | pnpm build
197 |
198 | # Start production server
199 | pnpm start
200 | ```
201 |
202 | ## 💡 Development Best Practices
203 |
204 | ### Package Manager
205 |
206 | - Project configured with `packageManager: "pnpm@10.12.4"`
207 | - Enable Corepack: `corepack enable`
208 | - Team members should use the same pnpm version
209 |
210 | ### Code Quality
211 |
212 | ```bash
213 | # Lint code
214 | pnpm lint
215 |
216 | # Type checking
217 | pnpm type-check
218 | ```
219 |
220 | ### Internationalization Development
221 |
222 | 1. Adding new language support:
223 | - Add new language files in `i18n/messages/`
224 | - Update `i18n/routing.ts` configuration
225 | - Create corresponding language directories in `blog/` and `content/`
226 |
227 | 2. Using translations:
228 | ```tsx
229 | import { useTranslations } from 'next-intl';
230 |
231 | export default function MyComponent() {
232 | const t = useTranslations('namespace');
233 | return
{t('title')}
;
234 | }
235 | ```
236 |
237 | ## 🔧 Troubleshooting
238 |
239 | ### Common Issues
240 |
241 | **1. Package manager version mismatch**
242 | ```bash
243 | # Remove node_modules and lockfile
244 | rm -rf node_modules pnpm-lock.yaml
245 | # Reinstall
246 | pnpm install
247 | ```
248 |
249 | **2. MDX files not displaying**
250 | - Check file path is correct
251 | - Verify frontmatter format
252 | - Ensure `visible` field is set to `published`
253 |
254 | **3. Internationalization routing issues**
255 | - Use `Link` component from `i18n/routing.ts`
256 | - Check `middleware.ts` configuration
257 |
258 | **4. Styles not working**
259 | - Verify Tailwind CSS class names are correct
260 | - Try restarting development server
261 |
262 | ### Environment Variables
263 |
264 | Ensure `.env` file contains necessary configuration:
265 | ```bash
266 | # Copy example config
267 | cp .env.example .env
268 | # Modify as needed
269 | ```
270 |
271 | ## 📄 License
272 |
273 | MIT
274 |
275 | ## 🤝 Contributing
276 |
277 | Issues and Pull Requests are welcome!
278 |
279 | ## About the Author
280 |
281 | Next.js full-stack specialist providing expert services in project development, performance optimization, and SEO improvement.
282 |
283 | For consulting and training opportunities, reach out at weijunext@gmail.com
284 |
285 | - [Github](https://github.com/weijunext)
286 | - [Bento](https://bento.me/weijunext)
287 | - [Twitter/X](https://twitter.com/judewei_dev)
288 |
289 |
290 |
291 | [](https://ko-fi.com/G2G6TWWMG)
--------------------------------------------------------------------------------
/README_ja.md:
--------------------------------------------------------------------------------
1 | ---
2 | Nexty で SaaS を迅速に立ち上げよう - ビジュアル料金ダッシュボード、AI プレイグラウンド、エンタープライズレベル CMS を備えた唯一の Next.js ボイラープレート。多言語対応、認証、決済、メール機能、SEO 最適化も完備。
3 |
4 | [Nexty.dev を今すぐ試す](https://nexty.dev?utm_source=github-nextjs-starter)
5 | ---
6 |
7 | [
](https://nexty.dev?utm_source=github-nextjs-starter)
8 |
9 | 🌍 *[English](README.md) ∙ [简体中文](README_zh.md) ∙ [日本語](README_ja.md)*
10 |
11 | # Next Forge - 多言語対応 Next.js 15 スターター
12 |
13 | グローバル対応のウェブサイトを素早く構築するための、機能豊富なNext.js 15多言語スターターテンプレートです。
14 |
15 | - [👉 ソースコード](https://github.com/weijunext/nextjs-15-starter)
16 | - [👉 デモサイト](https://nextforge.dev/)
17 |
18 | **🚀 多機能で使いやすいフルスタックの起動テンプレートをお探しですか? ぜひ、当社の[アドバンス版](https://nexty.dev)をお試しください。**
19 |
20 | ## ✨ 主な機能
21 |
22 | - 🌐 多言語対応(英語・中国語・日本語)
23 | - 🎨 Tailwind CSSによるモダンなUI
24 | - 🌙 ダーク/ライトテーマ切り替え
25 | - 📱 レスポンシブデザイン
26 | - 📝 MDXブログシステム
27 | - 🔍 SEO最適化
28 | - 📊 アナリティクスツール統合
29 | - Google Analytics
30 | - Baidu Analytics
31 | - Google Adsense
32 | - Vercel Analytics
33 |
34 | ## 🚀 クイックスタート
35 |
36 | ### 必要な環境
37 |
38 | - Node.js 18.17 以上
39 | - pnpm 9.0 以上(推奨)
40 |
41 | > **注意**: プロジェクトには `packageManager` フィールドが設定されており、最適な体験のために pnpm の使用を推奨しています。
42 |
43 | ### インストール手順
44 |
45 | 1. リポジトリのクローン:
46 | ```bash
47 | git clone https://github.com/weijunext/nextjs-15-starter.git
48 | cd nextjs-15-starter
49 | ```
50 |
51 | 2. Corepack の有効化(推奨):
52 | ```bash
53 | corepack enable
54 | ```
55 |
56 | 3. 依存関係のインストール:
57 | ```bash
58 | pnpm install
59 | # または他のパッケージマネージャーを使用
60 | npm install
61 | yarn
62 | ```
63 |
64 | 4. 環境変数の設定:
65 | ```bash
66 | cp .env.example .env
67 | ```
68 |
69 | 5. 開発サーバーの起動:
70 | ```bash
71 | pnpm dev
72 | # または npm run dev
73 | ```
74 |
75 | http://localhost:3000 にアクセスして確認できます。
76 |
77 | ## ⚙️ 設定方法
78 |
79 | 1. 基本設定
80 | - `config/site.ts`でウェブサイト情報を編集
81 | - `public/`内のアイコンとロゴを更新
82 | - `app/sitemap.ts`でサイトマップを設定
83 | - `app/robots.ts`でrobots.txtを更新
84 |
85 | 2. 多言語設定
86 | - `i18n/messages/`内の言語ファイルを追加/編集
87 | - `i18n/routing.ts`でサポートする言語を設定
88 | - `middleware.ts`で多言語ルーティングを設定
89 | - `app/[locale]/`配下にページを作成
90 | - Next.jsデフォルトの代わりに`i18n/routing.ts`の`Link`コンポーネントを使用
91 |
92 | ## 📝 コンテンツ管理
93 |
94 | ### ブログ投稿
95 | `blog/[locale]`にMDXファイルを以下のフォーマットで作成:
96 |
97 | ```markdown
98 | ---
99 | title: 投稿タイトル
100 | description: 投稿の説明
101 | image: /image.png
102 | slug: /url-path
103 | tags: tag1,tag2
104 | date: 2025-02-20
105 | visible: published
106 | pin: true
107 | ---
108 |
109 | 投稿内容...
110 | ```
111 |
112 | 対応フィールドについては`types/blog.ts`を参照してください。
113 |
114 | ### 静的ページ
115 | `content/[page]/[locale].mdx`で静的ページのコンテンツを管理します。
116 |
117 | ## 🔍 SEO最適化
118 |
119 | 包括的なSEO機能を搭載:
120 | - サーバーサイドレンダリングと静的生成
121 | - sitemap.xml自動生成
122 | - robots.txt設定
123 | - 最適化されたメタデータ
124 | - OGP対応
125 | - 多言語SEOサポート
126 |
127 | ## 📊 アナリティクス
128 |
129 | `.env`にIDを追加して有効化:
130 | ```
131 | NEXT_PUBLIC_GOOGLE_ANALYTICS=
132 | NEXT_PUBLIC_BAIDU_TONGJI=
133 | NEXT_PUBLIC_GOOGLE_ADSENSE=
134 | ```
135 |
136 | ## 📁 プロジェクト構成
137 |
138 | ```
139 | nextjs-15-starter/
140 | ├── app/ # アプリディレクトリ
141 | │ ├── [locale]/ # 多言語ルート
142 | │ │ ├── about/ # Aboutページ
143 | │ │ ├── blog/ # ブログページ
144 | │ │ └── ... # その他のページ
145 | │ ├── api/ # APIルート
146 | │ └── globals/ # グローバルコンポーネント
147 | ├── blog/ # ブログコンテンツ(MDX)
148 | │ ├── en/ # 英語ブログ
149 | │ ├── ja/ # 日本語ブログ
150 | │ └── zh/ # 中国語ブログ
151 | ├── components/ # 再利用可能なコンポーネント
152 | │ ├── ui/ # ベースUIコンポーネント
153 | │ ├── header/ # ヘッダーコンポーネント
154 | │ ├── footer/ # フッターコンポーネント
155 | │ └── ... # その他のコンポーネント
156 | ├── config/ # 設定ファイル
157 | ├── content/ # 静的コンテンツ(MDX)
158 | ├── i18n/ # 国際化設定
159 | │ ├── messages/ # 翻訳ファイル
160 | │ ├── routing.ts # ルーティング設定
161 | │ └── request.ts # リクエスト設定
162 | ├── lib/ # ユーティリティ関数
163 | ├── public/ # 静的アセット
164 | └── types/ # 型定義
165 | ```
166 |
167 | ## 🛠️ 技術スタック
168 |
169 | - **フレームワーク**: Next.js 15 (App Router)
170 | - **言語**: TypeScript
171 | - **スタイリング**: Tailwind CSS + Shadcn/ui
172 | - **国際化**: next-intl
173 | - **コンテンツ**: MDX
174 | - **状態管理**: Zustand
175 | - **デプロイ**: Vercel
176 | - **パッケージマネージャー**: pnpm(推奨)
177 |
178 | ## 🚀 デプロイ
179 |
180 | ### ワンクリックデプロイ
181 |
182 | [](https://vercel.com/new/clone?repository-url=https://github.com/weijunext/nextjs-15-starter&project-name=&repository-name=nextjs-15-starter&demo-title=Nextjs15Starter&demo-description=Nextjs%2015%20starter.&demo-url=https://nextforge.dev&demo-image=https://nextforge.dev/og.png)
183 |
184 | ### Vercelへの手動デプロイ
185 |
186 | 1. GitHubにコードをプッシュ
187 | 2. Vercelでプロジェクトをインポート
188 | 3. 環境変数を設定
189 | 4. デプロイ
190 |
191 | ### その他のプラットフォーム
192 |
193 | ```bash
194 | # プロダクション用ビルド
195 | pnpm build
196 |
197 | # プロダクションサーバーを起動
198 | pnpm start
199 | ```
200 |
201 | ## 💡 開発のベストプラクティス
202 |
203 | ### パッケージマネージャー使用
204 |
205 | - プロジェクトは `packageManager: "pnpm@10.12.4"` で設定済み
206 | - Corepack を有効化: `corepack enable`
207 | - チームメンバーは同じ pnpm バージョンを使用すべき
208 |
209 | ### コード品質
210 |
211 | ```bash
212 | # コードリント
213 | pnpm lint
214 |
215 | # 型チェック
216 | pnpm type-check
217 | ```
218 |
219 | ### 多言語開発
220 |
221 | 1. 新しい言語サポートの追加:
222 | - `i18n/messages/` に新しい言語ファイルを追加
223 | - `i18n/routing.ts` 設定を更新
224 | - `blog/` と `content/` に対応する言語ディレクトリを作成
225 |
226 | 2. 翻訳の使用:
227 | ```tsx
228 | import { useTranslations } from 'next-intl';
229 |
230 | export default function MyComponent() {
231 | const t = useTranslations('namespace');
232 | return {t('title')}
;
233 | }
234 | ```
235 |
236 | ## 🔧 トラブルシューティング
237 |
238 | ### よくある問題
239 |
240 | **1. パッケージマネージャーのバージョン不一致**
241 | ```bash
242 | # node_modules と lockfile を削除
243 | rm -rf node_modules pnpm-lock.yaml
244 | # 再インストール
245 | pnpm install
246 | ```
247 |
248 | **2. MDXファイルが表示されない**
249 | - ファイルパスが正しいか確認
250 | - frontmatter のフォーマットが正しいか確認
251 | - `visible` フィールドが `published` に設定されているか確認
252 |
253 | **3. 多言語ルーティングの問題**
254 | - `i18n/routing.ts` の `Link` コンポーネントを使用
255 | - `middleware.ts` の設定を確認
256 |
257 | **4. スタイルが効かない**
258 | - Tailwind CSS のクラス名が正しいか確認
259 | - 開発サーバーの再起動を試す
260 |
261 | ### 環境変数の問題
262 |
263 | `.env` ファイルに必要な設定が含まれていることを確認:
264 | ```bash
265 | # サンプル設定をコピー
266 | cp .env.example .env
267 | # 必要に応じて設定を変更
268 | ```
269 |
270 | ## 📄 ライセンス
271 |
272 | MIT
273 |
274 | ## 🤝 コントリビューション
275 |
276 | Issue、PRは大歓迎です!
277 |
278 | ## 作者について
279 |
280 | Next.jsのフルスタックスペシャリストとして、プロジェクト開発、パフォーマンス最適化、SEO改善のエキスパートサービスを提供しています。
281 |
282 | コンサルティングやトレーニングについては、 weijunext@gmail.com までご連絡ください。
283 |
284 | - [Github](https://github.com/weijunext)
285 | - [Bento](https://bento.me/weijunext)
286 | - [Twitter/X](https://twitter.com/judewei_dev)
287 |
288 |
289 |
290 | [](https://ko-fi.com/G2G6TWWMG)
--------------------------------------------------------------------------------
/README_zh.md:
--------------------------------------------------------------------------------
1 | ---
2 | 使用 Nexty 加速 SaaS 开发 - 唯一一个集成可视化定价面板、AI 开发环境和企业级 CMS 的 Next.js 模板,还包含多语言支持、身份验证、支付系统、电子邮件功能和 SEO 优化。
3 |
4 | 立即体验 [Nexty.dev](https://nexty.dev/?utm_source=github-nextjs-starter)
5 | ---
6 |
7 | [
](https://nexty.dev/?utm_source=github-nextjs-starter)
8 |
9 | 🌍 *[English](README.md) ∙ [简体中文](README_zh.md) ∙ [日本语](README_ja.md)*
10 |
11 | # Next Forge - 多语言 Next.js 15 启动模板
12 |
13 | 一个轻量的 Next.js 15 多语言启动模板,帮助你快速构建面向全球的网站。
14 |
15 | - [👉 源码地址](https://github.com/weijunext/nextjs-15-starter)
16 | - [👉 在线预览](https://nextforge.dev/)
17 |
18 | **🚀 如果你正在寻找功能完备的全栈启动模板,请了解我们的[高级版](https://nexty.dev/?utm_source=github-nextjs-starter)**
19 |
20 | ## ✨ 特性
21 |
22 | - 🌐 内置多语言支持 (中文、英文、日语)
23 | - 🎨 基于 Tailwind CSS 的现代 UI 设计
24 | - 🌙 深色/浅色主题切换
25 | - 📱 响应式布局
26 | - 📝 MDX 博客系统
27 | - 🔍 SEO 优化
28 | - 📊 集成多个统计分析工具
29 | - Google Analytics
30 | - Baidu Analytics
31 | - Google Adsense
32 | - Vercel Analytics
33 |
34 | ## 🚀 快速开始
35 |
36 | ### 环境要求
37 |
38 | - Node.js 18.17 或更高版本
39 | - pnpm 9.0 或更高版本(推荐)
40 |
41 | > **注意**: 项目已配置 `packageManager` 字段,推荐使用 pnpm 以获得最佳体验。
42 |
43 | ### 安装步骤
44 |
45 | 1. 克隆项目:
46 | ```bash
47 | git clone https://github.com/weijunext/nextjs-15-starter.git
48 | cd nextjs-15-starter
49 | ```
50 |
51 | 2. 启用 Corepack (推荐):
52 | ```bash
53 | corepack enable
54 | ```
55 |
56 | 3. 安装依赖:
57 | ```bash
58 | pnpm install
59 | # 或使用其他包管理器
60 | npm install
61 | yarn
62 | ```
63 |
64 | 4. 复制环境变量文件:
65 | ```bash
66 | cp .env.example .env
67 | ```
68 |
69 | 5. 启动开发服务器:
70 | ```bash
71 | pnpm dev
72 | # 或 npm run dev
73 | ```
74 |
75 | 访问 http://localhost:3000 查看你的应用。
76 |
77 | ## ⚙️ 配置
78 |
79 | 1. 基础配置
80 | - 修改 `config/site.ts` 配置网站信息
81 | - 修改 `public/` 下的图标和 logo
82 | - 更新 `app/sitemap.ts` 配置站点地图
83 | - 更新 `app/robots.ts` 配置 robots.txt
84 |
85 | 2. 多语言配置
86 | - 在 `i18n/messages/` 下添加或修改语言文件
87 | - 在 `i18n/routing.ts` 中配置支持的语言
88 | - 在 `middleware.ts` 中配置多语言路由
89 | - 在 `app/[locale]/` 目录下创建页面
90 | - 多语言页面使用 `i18n/routing.ts` 导出的 `Link` 组件替代 next.js 的
91 |
92 | ## 📝 内容管理
93 |
94 | ### 博客文章
95 | 在 `blog/[locale]` 目录下创建 MDX 文件,支持以下格式:
96 |
97 | ```markdown
98 | ---
99 | title: 文章标题
100 | description: 文章描述
101 | image: /image.png
102 | slug: /url-path
103 | tags: tag1,tag2
104 | date: 2025-02-20
105 | visible: published
106 | pin: true
107 | ---
108 |
109 | 文章内容...
110 | ```
111 |
112 | 可参考类型定义 `types/blog.ts` 确认支持的字段。
113 |
114 | ### 静态页面
115 | 在 `content/[page]/[locale].mdx` 下管理静态页面内容。
116 |
117 | ## 🔍 SEO 优化
118 |
119 | 模板内置了完整的 SEO 优化方案:
120 | - 服务端渲染和静态生成
121 | - 自动生成 sitemap.xml
122 | - 配置 robots.txt
123 | - 优化的 metadata
124 | - 支持 Open Graph
125 | - 多语言 SEO 支持
126 |
127 | ## 📊 统计分析
128 |
129 | 在 `.env` 文件中配置相应的 ID 即可启用:
130 | ```
131 | NEXT_PUBLIC_GOOGLE_ANALYTICS=
132 | NEXT_PUBLIC_BAIDU_TONGJI=
133 | NEXT_PUBLIC_GOOGLE_ADSENSE=
134 | ```
135 |
136 | ## 📁 项目结构
137 |
138 | ```
139 | nextjs-15-starter/
140 | ├── app/ # 应用路由目录
141 | │ ├── [locale]/ # 多语言路由
142 | │ │ ├── about/ # 关于页面
143 | │ │ ├── blog/ # 博客页面
144 | │ │ └── ... # 其他页面
145 | │ ├── api/ # API 路由
146 | │ └── globals/ # 全局组件
147 | ├── blog/ # 博客内容 (MDX)
148 | │ ├── en/ # 英文博客
149 | │ ├── ja/ # 日文博客
150 | │ └── zh/ # 中文博客
151 | ├── components/ # 可复用组件
152 | │ ├── ui/ # 基础 UI 组件
153 | │ ├── header/ # 头部组件
154 | │ ├── footer/ # 底部组件
155 | │ └── ... # 其他组件
156 | ├── config/ # 配置文件
157 | ├── content/ # 静态内容 (MDX)
158 | ├── i18n/ # 国际化配置
159 | │ ├── messages/ # 翻译文件
160 | │ ├── routing.ts # 路由配置
161 | │ └── request.ts # 请求配置
162 | ├── lib/ # 工具函数
163 | ├── public/ # 静态资源
164 | └── types/ # 类型定义
165 | ```
166 |
167 | ## 🛠️ 技术栈
168 |
169 | - **框架**: Next.js 15 (App Router)
170 | - **语言**: TypeScript
171 | - **样式**: Tailwind CSS + Shadcn/ui
172 | - **国际化**: next-intl
173 | - **内容**: MDX
174 | - **状态管理**: Zustand
175 | - **部署**: Vercel
176 | - **包管理器**: pnpm (推荐)
177 |
178 |
179 | ## 🚀 部署
180 |
181 | ### 一键部署
182 |
183 | [](https://vercel.com/new/clone?repository-url=https://github.com/weijunext/nextjs-15-starter&project-name=&repository-name=nextjs-15-starter&demo-title=Nextjs15Starter&demo-description=Nextjs%2015%20starter.&demo-url=https://nextforge.dev&demo-image=https://nextforge.dev/og.png)
184 |
185 | ### 手动部署到 Vercel
186 |
187 | 1. 推送代码到 GitHub
188 | 2. 在 Vercel 中导入项目
189 | 3. 配置环境变量
190 | 4. 部署
191 |
192 | ### 其他平台部署
193 |
194 | ```bash
195 | # 构建生产版本
196 | pnpm build
197 |
198 | # 启动生产服务器
199 | pnpm start
200 | ```
201 |
202 | ## 💡 开发最佳实践
203 |
204 | ### 包管理器使用
205 |
206 | - 项目已配置 `packageManager: "pnpm@10.12.4"`
207 | - 建议启用 Corepack: `corepack enable`
208 | - 团队成员应使用相同版本的 pnpm
209 |
210 | ### 代码规范
211 |
212 | ```bash
213 | # 代码检查
214 | pnpm lint
215 |
216 | # 类型检查
217 | pnpm type-check
218 | ```
219 |
220 | ### 多语言开发
221 |
222 | 1. 新增语言支持:
223 | - 在 `i18n/messages/` 添加新的语言文件
224 | - 更新 `i18n/routing.ts` 配置
225 | - 在 `blog/` 和 `content/` 下创建对应语言目录
226 |
227 | 2. 使用翻译:
228 | ```tsx
229 | import { useTranslations } from 'next-intl';
230 |
231 | export default function MyComponent() {
232 | const t = useTranslations('namespace');
233 | return {t('title')}
;
234 | }
235 | ```
236 |
237 | ## 🔧 故障排除
238 |
239 | ### 常见问题
240 |
241 | **1. 包管理器版本不一致**
242 | ```bash
243 | # 删除 node_modules 和 lockfile
244 | rm -rf node_modules pnpm-lock.yaml
245 | # 重新安装
246 | pnpm install
247 | ```
248 |
249 | **2. MDX 文件不显示**
250 | - 检查文件路径是否正确
251 | - 确认 frontmatter 格式正确
252 | - 检查 `visible` 字段是否设置为 `published`
253 |
254 | **3. 多语言路由问题**
255 | - 确保使用 `i18n/routing.ts` 中的 `Link` 组件
256 | - 检查 `middleware.ts` 配置
257 |
258 | **4. 样式不生效**
259 | - 确认 Tailwind CSS 类名拼写正确
260 | - 检查是否需要重启开发服务器
261 |
262 | ### 环境变量问题
263 |
264 | 确保 `.env` 文件包含必要的配置:
265 | ```bash
266 | # 复制示例配置
267 | cp .env.example .env
268 | # 根据需要修改配置
269 | ```
270 |
271 |
272 | ## 📄 许可证
273 |
274 | MIT
275 |
276 | ## 🤝 贡献
277 |
278 | 欢迎提交 Issue 和 Pull Request!
279 |
280 | ## 关于作者
281 |
282 | 专注于 Next.js 全栈开发,欢迎探讨开发、咨询与培训等合作机会,联系微信:bigye_chengpu
283 |
284 | - [Github](https://github.com/weijunext)
285 | - [Twitter/X](https://twitter.com/weijunext)
286 | - [博客 - J实验室](https://weijunext.com)
287 | - [Medium](https://medium.com/@weijunext)
288 | - [掘金](https://juejin.cn/user/26044008768029)
289 | - [知乎](https://www.zhihu.com/people/mo-mo-mo-89-12-11)
290 |
291 |
292 |
293 | [](https://ko-fi.com/G2G6TWWMG)
294 |
295 |
296 |
297 |
298 |
--------------------------------------------------------------------------------
/actions/newsletter.ts:
--------------------------------------------------------------------------------
1 | import { normalizeEmail, validateEmail } from '@/lib/email';
2 | import { Ratelimit } from '@upstash/ratelimit';
3 | import { Redis } from '@upstash/redis';
4 | import { headers } from 'next/headers';
5 | import { Resend } from 'resend';
6 |
7 | // initialize resend
8 | const resend = new Resend(process.env.RESEND_API_KEY);
9 |
10 | // Resend Audience ID
11 | const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID!;
12 |
13 | // initialize Redis
14 | const redis = new Redis({
15 | url: process.env.UPSTASH_REDIS_REST_URL!,
16 | token: process.env.UPSTASH_REDIS_REST_TOKEN!,
17 | });
18 |
19 | const REDIS_RATE_LIMIT_KEY = process.env.UPSTASH_REDIS_NEWSLETTER_RATE_LIMIT_KEY!;
20 | const DAY_MAX_SUBMISSIONS = parseInt(process.env.DAY_MAX_SUBMISSIONS || '10');
21 |
22 | // create rate limiter
23 | const limiter = new Ratelimit({
24 | redis,
25 | limiter: Ratelimit.slidingWindow(DAY_MAX_SUBMISSIONS, '1d'),
26 | prefix: REDIS_RATE_LIMIT_KEY,
27 | });
28 |
29 | // Shared rate limit check
30 | async function checkRateLimit() {
31 | const headersList = await headers();
32 | const ip = headersList.get('x-real-ip') ||
33 | headersList.get('x-forwarded-for') ||
34 | 'unknown';
35 |
36 | const { success } = await limiter.limit(ip);
37 | if (!success) {
38 | throw new Error('Too many submissions, please try again later');
39 | }
40 | }
41 |
42 | export async function subscribeToNewsletter(email: string) {
43 | try {
44 | await checkRateLimit();
45 |
46 | const normalizedEmail = normalizeEmail(email);
47 | const { isValid, error } = validateEmail(normalizedEmail);
48 |
49 | if (!isValid) {
50 | throw new Error(error || 'Invalid email address');
51 | }
52 |
53 | // Check if already subscribed
54 | // const list = await resend.contacts.list({ audienceId: AUDIENCE_ID });
55 | // const user = list.data?.data.find((item) => item.email === normalizedEmail);
56 | // if (user) {
57 | // return { success: true, alreadySubscribed: true };
58 | // }
59 |
60 | // Add to audience
61 | await resend.contacts.create({
62 | audienceId: AUDIENCE_ID,
63 | email: normalizedEmail,
64 | });
65 |
66 | // Send welcome email
67 | const unsubscribeToken = Buffer.from(normalizedEmail).toString('base64');
68 | const unsubscribeLink = `${process.env.NEXT_PUBLIC_SITE_URL}/unsubscribe?token=${unsubscribeToken}`;
69 |
70 | await resend.emails.send({
71 | from: 'NextForge <' + process.env.ADMIN_EMAIL! + '>',
72 | to: normalizedEmail,
73 | subject: 'Welcome to Next Forge',
74 | html: `
75 | Welcome to Next Forge
76 | Thank you for subscribing to the newsletter. You will receive the latest updates and news.
77 |
78 | If you wish to unsubscribe, please click here
79 |
80 | `,
81 | headers: {
82 | "List-Unsubscribe": `<${unsubscribeLink}>`,
83 | "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
84 | }
85 | });
86 |
87 | return { success: true };
88 | } catch (error) {
89 | console.error('Newsletter subscription failed:', error);
90 | throw error;
91 | }
92 | }
93 |
94 | export async function unsubscribeFromNewsletter(token: string) {
95 | try {
96 | await checkRateLimit();
97 |
98 | const email = Buffer.from(token, 'base64').toString();
99 | const normalizedEmail = normalizeEmail(email);
100 | const { isValid, error } = validateEmail(normalizedEmail);
101 |
102 | if (!isValid) {
103 | throw new Error(error || 'Invalid email address');
104 | }
105 |
106 | // Check if subscribed
107 | const list = await resend.contacts.list({ audienceId: AUDIENCE_ID });
108 | const user = list.data?.data.find((item) => item.email === normalizedEmail);
109 |
110 | if (!user) {
111 | throw new Error('This email is not subscribed to our notifications');
112 | }
113 |
114 | // Remove from audience
115 | await resend.contacts.remove({
116 | audienceId: AUDIENCE_ID,
117 | email: normalizedEmail,
118 | });
119 |
120 | return { success: true, email: normalizedEmail };
121 | } catch (error) {
122 | console.error('Newsletter unsubscribe failed:', error);
123 | throw error;
124 | }
125 | }
--------------------------------------------------------------------------------
/app/BaiDuAnalytics.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Script from "next/script";
4 |
5 | const BaiDuAnalytics = () => {
6 | return (
7 | <>
8 | {process.env.NEXT_PUBLIC_BAIDU_TONGJI ? (
9 | <>
10 |
25 | >
26 | ) : (
27 | <>>
28 | )}
29 | >
30 | );
31 | };
32 |
33 | export default BaiDuAnalytics;
34 |
--------------------------------------------------------------------------------
/app/GoogleAdsense.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Script from "next/script";
4 |
5 | const GoogleAdsense = () => {
6 | return (
7 | <>
8 | {process.env.NEXT_PUBLIC_GOOGLE_ADSENSE_ID ? (
9 | <>
10 |
16 | >
17 | ) : (
18 | <>>
19 | )}
20 | >
21 | );
22 | };
23 |
24 | export default GoogleAdsense;
25 |
--------------------------------------------------------------------------------
/app/GoogleAnalytics.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Script from "next/script";
4 | import * as gtag from "../gtag.js";
5 |
6 | const GoogleAnalytics = () => {
7 | return (
8 | <>
9 | {gtag.GA_TRACKING_ID ? (
10 | <>
11 |
15 |
29 | >
30 | ) : (
31 | <>>
32 | )}
33 | >
34 | );
35 | };
36 |
37 | export default GoogleAnalytics;
38 |
--------------------------------------------------------------------------------
/app/PlausibleAnalytics.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Script from "next/script";
4 |
5 | // 你可以将域名放在环境变量中,这里先直接使用你提供的域名
6 | const PLAUSIBLE_DOMAIN = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
7 | const PLAUSIBLE_SRC = process.env.NEXT_PUBLIC_PLAUSIBLE_SRC;
8 |
9 | const PlausibleAnalytics = () => {
10 | return (
11 | <>
12 | {PLAUSIBLE_DOMAIN ? (
13 | <>
14 |
20 |
31 | >
32 | ) : (
33 | <>>
34 | )}
35 | >
36 | );
37 | };
38 |
39 | export default PlausibleAnalytics;
40 |
--------------------------------------------------------------------------------
/app/[locale]/about/page.tsx:
--------------------------------------------------------------------------------
1 | import MDXComponents from "@/components/mdx/MDXComponents";
2 | import { Locale, LOCALES } from "@/i18n/routing";
3 | import { constructMetadata } from "@/lib/metadata";
4 | import fs from "fs/promises";
5 | import { Metadata } from "next";
6 | import { getTranslations } from "next-intl/server";
7 | import { MDXRemote } from "next-mdx-remote-client/rsc";
8 | import path from "path";
9 | import remarkGfm from "remark-gfm";
10 |
11 | const options = {
12 | parseFrontmatter: true,
13 | mdxOptions: {
14 | remarkPlugins: [remarkGfm],
15 | rehypePlugins: [],
16 | },
17 | };
18 |
19 | async function getMDXContent(locale: string) {
20 | const filePath = path.join(
21 | process.cwd(),
22 | "content",
23 | "about",
24 | `${locale}.mdx`
25 | );
26 | try {
27 | const content = await fs.readFile(filePath, "utf-8");
28 | return content;
29 | } catch (error) {
30 | console.error(`Error reading MDX file: ${error}`);
31 | return "";
32 | }
33 | }
34 |
35 | type Params = Promise<{
36 | locale: string;
37 | }>;
38 |
39 | type MetadataProps = {
40 | params: Params;
41 | };
42 |
43 | export async function generateMetadata({
44 | params,
45 | }: MetadataProps): Promise {
46 | const { locale } = await params;
47 | const t = await getTranslations({ locale, namespace: "About" });
48 |
49 | return constructMetadata({
50 | page: "About",
51 | title: t("title"),
52 | description: t("description"),
53 | locale: locale as Locale,
54 | path: `/about`,
55 | canonicalUrl: `/about`,
56 | });
57 | }
58 |
59 | export default async function AboutPage({ params }: { params: Params }) {
60 | const { locale } = await params;
61 | const content = await getMDXContent(locale);
62 |
63 | return (
64 |
65 |
70 |
71 | );
72 | }
73 |
74 | export async function generateStaticParams() {
75 | return LOCALES.map((locale) => ({
76 | locale,
77 | }));
78 | }
79 |
--------------------------------------------------------------------------------
/app/[locale]/blog/BlogCard.tsx:
--------------------------------------------------------------------------------
1 | import { Link as I18nLink } from "@/i18n/routing";
2 | import { BlogPost } from "@/types/blog";
3 | import dayjs from "dayjs";
4 | import Image from "next/image";
5 |
6 | export function BlogCard({ post, locale }: { post: BlogPost; locale: string }) {
7 | return (
8 |
13 |
14 |
20 |
21 |
22 |
23 | {post.title}
24 |
25 |
26 | {dayjs(post.date).format("YYYY-MM-DD")}
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/app/[locale]/blog/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Callout } from "@/components/mdx/Callout";
2 | import MDXComponents from "@/components/mdx/MDXComponents";
3 | import { Locale, LOCALES } from "@/i18n/routing";
4 | import { getPosts } from "@/lib/getBlogs";
5 | import { constructMetadata } from "@/lib/metadata";
6 | import { BlogPost } from "@/types/blog";
7 | import { Metadata } from "next";
8 | import { MDXRemote } from "next-mdx-remote-client/rsc";
9 | import { notFound } from "next/navigation";
10 |
11 | type Params = Promise<{
12 | locale: string;
13 | slug: string;
14 | }>;
15 |
16 | type MetadataProps = {
17 | params: Params;
18 | };
19 |
20 | export async function generateMetadata({
21 | params,
22 | }: MetadataProps): Promise {
23 | const { locale, slug } = await params;
24 | let { posts }: { posts: BlogPost[] } = await getPosts(locale);
25 | const post = posts.find((post) => post.slug === "/" + slug);
26 |
27 | if (!post) {
28 | return constructMetadata({
29 | title: "404",
30 | description: "Page not found",
31 | noIndex: true,
32 | locale: locale as Locale,
33 | path: `/blog/${slug}`,
34 | canonicalUrl: `/blog/${slug}`,
35 | });
36 | }
37 |
38 | return constructMetadata({
39 | page: "blog",
40 | title: post.title,
41 | description: post.description,
42 | images: post.image ? [post.image] : [],
43 | locale: locale as Locale,
44 | path: `/blog/${slug}`,
45 | canonicalUrl: `/blog/${slug}`,
46 | });
47 | }
48 |
49 | export default async function BlogPage({ params }: { params: Params }) {
50 | const { locale, slug } = await params;
51 | let { posts }: { posts: BlogPost[] } = await getPosts(locale);
52 |
53 | const post = posts.find((item) => item.slug === "/" + slug);
54 |
55 | if (!post) {
56 | return notFound();
57 | }
58 |
59 | return (
60 |
61 |
{post.title}
62 | {post.image && (
63 |

64 | )}
65 | {post.tags && post.tags.split(",").length ? (
66 |
67 | {post.tags.split(",").map((tag) => {
68 | return (
69 |
73 | {tag.trim()}
74 |
75 | );
76 | })}
77 |
78 | ) : (
79 | <>>
80 | )}
81 | {post.description &&
{post.description}}
82 |
83 |
84 | );
85 | }
86 |
87 | export async function generateStaticParams() {
88 | let posts = (await getPosts()).posts;
89 |
90 | // Filter out posts without a slug
91 | posts = posts.filter((post) => post.slug);
92 |
93 | return LOCALES.flatMap((locale) =>
94 | posts.map((post) => {
95 | const slugPart = post.slug.replace(/^\//, "").replace(/^blog\//, "");
96 |
97 | return {
98 | locale,
99 | slug: slugPart,
100 | };
101 | })
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/app/[locale]/blog/page.tsx:
--------------------------------------------------------------------------------
1 | import { BlogCard } from "@/app/[locale]/blog/BlogCard";
2 | import { Locale, LOCALES } from "@/i18n/routing";
3 | import { getPosts } from "@/lib/getBlogs";
4 | import { constructMetadata } from "@/lib/metadata";
5 | import { Metadata } from "next";
6 | import { getTranslations } from "next-intl/server";
7 |
8 | type Params = Promise<{ locale: string }>;
9 |
10 | type MetadataProps = {
11 | params: Params;
12 | };
13 |
14 | export async function generateMetadata({
15 | params,
16 | }: MetadataProps): Promise {
17 | const { locale } = await params;
18 | const t = await getTranslations({ locale, namespace: "Blog" });
19 |
20 | return constructMetadata({
21 | page: "Blog",
22 | title: t("title"),
23 | description: t("description"),
24 | locale: locale as Locale,
25 | path: `/blog`,
26 | canonicalUrl: `/blog`,
27 | });
28 | }
29 |
30 | export default async function Page({ params }: { params: Params }) {
31 | const { locale } = await params;
32 | const { posts } = await getPosts(locale);
33 |
34 | const t = await getTranslations("Blog");
35 |
36 | return (
37 |
38 |
{t("title")}
39 |
40 |
41 | {posts.map((post) => (
42 |
43 | ))}
44 |
45 |
46 | );
47 | }
48 |
49 | export async function generateStaticParams() {
50 | return LOCALES.map((locale) => ({ locale }));
51 | }
52 |
--------------------------------------------------------------------------------
/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import BaiDuAnalytics from "@/app/BaiDuAnalytics";
2 | import GoogleAdsense from "@/app/GoogleAdsense";
3 | import GoogleAnalytics from "@/app/GoogleAnalytics";
4 | import PlausibleAnalytics from "@/app/PlausibleAnalytics";
5 | import Footer from "@/components/footer/Footer";
6 | import Header from "@/components/header/Header";
7 | import { LanguageDetectionAlert } from "@/components/LanguageDetectionAlert";
8 | import { TailwindIndicator } from "@/components/TailwindIndicator";
9 | import { siteConfig } from "@/config/site";
10 | import { DEFAULT_LOCALE, Locale, routing } from "@/i18n/routing";
11 | import { constructMetadata } from "@/lib/metadata";
12 | import { cn } from "@/lib/utils";
13 | import "@/styles/globals.css";
14 | import "@/styles/loading.css";
15 | import { Analytics } from "@vercel/analytics/react";
16 | import { Metadata, Viewport } from "next";
17 | import { hasLocale, NextIntlClientProvider } from "next-intl";
18 | import {
19 | getMessages,
20 | getTranslations,
21 | setRequestLocale,
22 | } from "next-intl/server";
23 | import { ThemeProvider } from "next-themes";
24 | import { notFound } from "next/navigation";
25 |
26 | type MetadataProps = {
27 | params: Promise<{ locale: string }>;
28 | };
29 |
30 | export async function generateMetadata({
31 | params,
32 | }: MetadataProps): Promise {
33 | const { locale } = await params;
34 | const t = await getTranslations({ locale, namespace: "Home" });
35 |
36 | return constructMetadata({
37 | page: "Home",
38 | title: t("title"),
39 | description: t("description"),
40 | locale: locale as Locale,
41 | path: `/`,
42 | canonicalUrl: `/`,
43 | });
44 | }
45 |
46 | export const viewport: Viewport = {
47 | themeColor: siteConfig.themeColors,
48 | };
49 |
50 | export default async function LocaleLayout({
51 | children,
52 | params,
53 | }: {
54 | children: React.ReactNode;
55 | params: { locale: string };
56 | }) {
57 | const { locale } = await params;
58 |
59 | // Ensure that the incoming `locale` is valid
60 | if (!hasLocale(routing.locales, locale)) {
61 | notFound();
62 | }
63 |
64 | setRequestLocale(locale);
65 |
66 | // Providing all messages to the client
67 | // side is the easiest way to get started
68 | const messages = await getMessages();
69 |
70 | return (
71 |
72 |
73 |
78 |
79 |
84 | {messages.LanguageDetection && }
85 | {messages.Header && }
86 |
87 |
88 | {children}
89 |
90 |
91 | {messages.Footer && }
92 |
93 |
94 |
95 | {process.env.NODE_ENV === "development" ? (
96 | <>>
97 | ) : (
98 | <>
99 |
100 |
101 |
102 |
103 |
104 | >
105 | )}
106 |
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | import HomeComponent from "@/components/home";
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/[locale]/privacy-policy/page.tsx:
--------------------------------------------------------------------------------
1 | import MDXComponents from "@/components/mdx/MDXComponents";
2 | import { Locale, LOCALES } from "@/i18n/routing";
3 | import { constructMetadata } from "@/lib/metadata";
4 | import fs from "fs/promises";
5 | import { Metadata } from "next";
6 | import { getTranslations } from "next-intl/server";
7 | import { MDXRemote } from "next-mdx-remote-client/rsc";
8 | import path from "path";
9 | import remarkGfm from "remark-gfm";
10 |
11 | const options = {
12 | parseFrontmatter: true,
13 | mdxOptions: {
14 | remarkPlugins: [remarkGfm],
15 | rehypePlugins: [],
16 | },
17 | };
18 |
19 | async function getMDXContent(locale: string) {
20 | const filePath = path.join(
21 | process.cwd(),
22 | "content",
23 | "privacy-policy",
24 | `${locale}.mdx`
25 | );
26 | try {
27 | const content = await fs.readFile(filePath, "utf-8");
28 | return content;
29 | } catch (error) {
30 | console.error(`Error reading MDX file: ${error}`);
31 | return "";
32 | }
33 | }
34 |
35 | type Params = Promise<{
36 | locale: string;
37 | }>;
38 |
39 | type MetadataProps = {
40 | params: Params;
41 | };
42 |
43 | export async function generateMetadata({
44 | params,
45 | }: MetadataProps): Promise {
46 | const { locale } = await params;
47 | const t = await getTranslations({ locale, namespace: "PrivacyPolicy" });
48 |
49 | return constructMetadata({
50 | page: "PrivacyPolicy",
51 | title: t("title"),
52 | description: t("description"),
53 | locale: locale as Locale,
54 | path: `/privacy-policy`,
55 | canonicalUrl: `/privacy-policy`,
56 | });
57 | }
58 |
59 | export default async function AboutPage({ params }: { params: Params }) {
60 | const { locale } = await params;
61 | const content = await getMDXContent(locale);
62 |
63 | return (
64 |
65 |
70 |
71 | );
72 | }
73 |
74 | export async function generateStaticParams() {
75 | return LOCALES.map((locale) => ({
76 | locale,
77 | }));
78 | }
79 |
--------------------------------------------------------------------------------
/app/[locale]/terms-of-service/page.tsx:
--------------------------------------------------------------------------------
1 | import MDXComponents from "@/components/mdx/MDXComponents";
2 | import { Locale, LOCALES } from "@/i18n/routing";
3 | import { constructMetadata } from "@/lib/metadata";
4 | import fs from "fs/promises";
5 | import { Metadata } from "next";
6 | import { getTranslations } from "next-intl/server";
7 | import { MDXRemote } from "next-mdx-remote-client/rsc";
8 | import path from "path";
9 | import remarkGfm from "remark-gfm";
10 |
11 | const options = {
12 | parseFrontmatter: true,
13 | mdxOptions: {
14 | remarkPlugins: [remarkGfm],
15 | rehypePlugins: [],
16 | },
17 | };
18 |
19 | async function getMDXContent(locale: string) {
20 | const filePath = path.join(
21 | process.cwd(),
22 | "content",
23 | "terms-of-service",
24 | `${locale}.mdx`
25 | );
26 | try {
27 | const content = await fs.readFile(filePath, "utf-8");
28 | return content;
29 | } catch (error) {
30 | console.error(`Error reading MDX file: ${error}`);
31 | return "";
32 | }
33 | }
34 |
35 | type Params = Promise<{
36 | locale: string;
37 | }>;
38 |
39 | type MetadataProps = {
40 | params: Params;
41 | };
42 |
43 | export async function generateMetadata({
44 | params,
45 | }: MetadataProps): Promise {
46 | const { locale } = await params;
47 | const t = await getTranslations({ locale, namespace: "TermsOfService" });
48 |
49 | return constructMetadata({
50 | page: "TermsOfService",
51 | title: t("title"),
52 | description: t("description"),
53 | locale: locale as Locale,
54 | path: `/terms-of-service`,
55 | canonicalUrl: `/terms-of-service`,
56 | });
57 | }
58 |
59 | export default async function AboutPage({ params }: { params: Params }) {
60 | const { locale } = await params;
61 | const content = await getMDXContent(locale);
62 |
63 | return (
64 |
65 |
70 |
71 | );
72 | }
73 |
74 | export async function generateStaticParams() {
75 | return LOCALES.map((locale) => ({
76 | locale,
77 | }));
78 | }
79 |
--------------------------------------------------------------------------------
/app/[locale]/unsubscribe/page.tsx:
--------------------------------------------------------------------------------
1 | import { unsubscribeFromNewsletter } from "@/actions/newsletter";
2 |
3 | type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>;
4 |
5 | export default async function UnsubscribePage(props: {
6 | searchParams: SearchParams;
7 | }) {
8 | let status: "error" | "success" = "error";
9 | let email = "";
10 | let errorMessage =
11 | "An error occurred while processing your unsubscribe request";
12 |
13 | const searchParams = await props.searchParams;
14 | const token = searchParams.token as string;
15 |
16 | if (!token) {
17 | errorMessage = "No unsubscribe token provided";
18 | } else {
19 | try {
20 | const result = await unsubscribeFromNewsletter(token);
21 | if (result.success) {
22 | status = "success";
23 | email = result.email;
24 | }
25 | } catch (error) {
26 | errorMessage = error instanceof Error ? error.message : errorMessage;
27 | }
28 | }
29 |
30 | return (
31 |
32 |
Email Subscription Management
33 |
34 | {status === "success" ? (
35 |
36 |
37 | You have successfully unsubscribed from the email notifications.
38 |
39 |
Email: {email}
40 |
41 | If you change your mind, you can re-subscribe at any time.
42 |
43 |
44 | ) : (
45 |
46 |
{errorMessage}
47 |
48 | Please ensure you used the correct unsubscribe link, or contact our
49 | support team for help.
50 |
51 |
52 | )}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/app/api/newsletter/route.ts:
--------------------------------------------------------------------------------
1 | import { subscribeToNewsletter } from '@/actions/newsletter';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function POST(request: Request) {
5 | try {
6 | const { email } = await request.json();
7 |
8 | if (!email) {
9 | return NextResponse.json(
10 | { error: 'email is required' },
11 | { status: 400 }
12 | );
13 | }
14 |
15 | const result = await subscribeToNewsletter(email);
16 | return NextResponse.json(result);
17 | } catch (error) {
18 | const message = error instanceof Error ? error.message : 'Server processing request failed';
19 | return NextResponse.json(
20 | { error: message },
21 | { status: error instanceof Error ? 400 : 500 }
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { siteConfig } from '@/config/site'
2 | import type { MetadataRoute } from 'next'
3 |
4 | const siteUrl = siteConfig.url
5 |
6 | export default function robots(): MetadataRoute.Robots {
7 | return {
8 | rules: {
9 | userAgent: '*',
10 | allow: '/',
11 | disallow: [
12 | '/private/',
13 | '/api/',
14 | '/auth/',
15 | '/dashboard/',
16 | '/_next/',
17 | '/assets/',
18 | '/error',
19 | '/*/404',
20 | '/*/500',
21 | '/*/403',
22 | '/*/401',
23 | '/*/400',
24 | '/cdn-cgi/',
25 | ],
26 | },
27 | sitemap: `${siteUrl}/sitemap.xml`
28 | }
29 | }
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { siteConfig } from '@/config/site'
2 | import { DEFAULT_LOCALE, LOCALES } from '@/i18n/routing'
3 | import { getPosts } from '@/lib/getBlogs'
4 | import { MetadataRoute } from 'next'
5 |
6 | const siteUrl = siteConfig.url
7 |
8 | type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' | undefined
9 |
10 | export default async function sitemap(): Promise {
11 | // Static pages
12 | const staticPages = [
13 | '',
14 | '/blog',
15 | '/about',
16 | '/privacy-policy',
17 | '/terms-of-service',
18 | ]
19 |
20 | // Generate multilingual pages
21 | const pages = LOCALES.flatMap(locale => {
22 | return staticPages.map(page => ({
23 | url: `${siteUrl}${locale === DEFAULT_LOCALE ? '' : `/${locale}`}${page}`,
24 | lastModified: new Date(),
25 | changeFrequency: 'daily' as ChangeFrequency,
26 | priority: page === '' ? 1.0 : 0.8,
27 | }))
28 | })
29 |
30 | const blogPosts = await Promise.all(
31 | LOCALES.map(async (locale) => {
32 | const { posts } = await getPosts(locale)
33 | return posts.map(post => ({
34 | url: `${siteUrl}${locale === DEFAULT_LOCALE ? '' : `/${locale}`}/blog${post.slug}`,
35 | lastModified: post.metadata.updatedAt || post.date,
36 | changeFrequency: 'daily' as const,
37 | priority: 0.7,
38 | }))
39 | })
40 | ).then(results => results.flat())
41 |
42 | return [
43 | ...pages,
44 | ...blogPosts,
45 | ]
46 | }
--------------------------------------------------------------------------------
/blogs/en/1.demo.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: demo
3 | description: it is a description
4 | slug: /demo
5 | tags: nextjs,i18n,mdx,starter,robots,sitemap
6 | date: 2025-02-16
7 | visible: published
8 | # visible: draft/invisible/published (published is default)
9 | pin: pin
10 | ---
11 |
12 | ## Introduction
13 |
14 | demo
15 |
16 | ## How to use
17 |
18 | demo
--------------------------------------------------------------------------------
/blogs/ja/1.demo.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: デモ
3 | description: これはデモプロジェクトの説明です
4 | slug: /demo
5 | tags: nextjs,i18n,mdx,starter,robots,sitemap
6 | date: 2025-02-16
7 | visible: published
8 | # visible: draft/invisible/published、デフォルトはpublished
9 | # pin: pin
10 | ---
11 |
12 | ## はじめに
13 |
14 | デモ
15 |
16 | ## 使い方
17 |
18 | デモ
--------------------------------------------------------------------------------
/blogs/zh/1.demo.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 示例
3 | description: 这是一个描述
4 | slug: /demo
5 | tags: nextjs,i18n,mdx,starter,robots,sitemap
6 | date: 2025-02-16
7 | visible: published
8 | # visible: draft/invisible/published,默认为published
9 | pin: true
10 | ---
11 |
12 | ## 简介
13 |
14 | 示例
15 |
16 | ## 如何使用
17 |
18 | 示例
--------------------------------------------------------------------------------
/blogs/zh/2.demo2.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 示例2
3 | description: 这是一个描述2
4 | slug: /demo2
5 | tags: nextjs,i18n,mdx,starter,robots,sitemap
6 | date: 2025-02-17
7 | visible: published
8 | # visible: draft/invisible/published,默认为published
9 | # pin: true
10 | ---
11 |
12 | ## 简介
13 |
14 | 示例2
15 |
16 | ## 如何使用
17 |
18 | 示例2
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "styles/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/components/BuiltWithButton.tsx:
--------------------------------------------------------------------------------
1 | import { buttonVariants } from "@/components/ui/button";
2 | import { cn } from "@/lib/utils";
3 | import Link from "next/link";
4 |
5 | // Nexty.dev Affiliate Link: https://affiliates.nexty.dev/
6 | // sign up and use your affiliate link on BuiltWithButton to earn money
7 |
8 | export default function BuiltWithButton() {
9 | return (
10 |
21 | Built with
22 |
23 |
24 |
25 |
26 | Nexty.dev
27 |
28 |
29 | );
30 | }
31 |
32 | function LogoNexty({ className }: { className?: string }) {
33 | return (
34 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/LanguageDetectionAlert.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Link as I18nLink, LOCALE_NAMES, routing } from "@/i18n/routing";
5 | import { cn } from "@/lib/utils";
6 | import { useLocaleStore } from "@/stores/localeStore";
7 | import { ArrowRight, Globe, X } from "lucide-react";
8 | import { useLocale } from "next-intl";
9 | import { useCallback, useEffect, useState } from "react";
10 |
11 | export function LanguageDetectionAlert() {
12 | const [countdown, setCountdown] = useState(10); // countdown 10s and dismiss
13 | const [isVisible, setIsVisible] = useState(false);
14 | const locale = useLocale();
15 | const [detectedLocale, setDetectedLocale] = useState(null);
16 | const {
17 | showLanguageAlert,
18 | setShowLanguageAlert,
19 | dismissLanguageAlert,
20 | getLangAlertDismissed,
21 | } = useLocaleStore();
22 |
23 | const handleDismiss = useCallback(() => {
24 | setIsVisible(false);
25 | setTimeout(() => {
26 | dismissLanguageAlert();
27 | }, 300);
28 | }, [dismissLanguageAlert]);
29 |
30 | const handleSwitchLanguage = useCallback(() => {
31 | dismissLanguageAlert();
32 | }, [dismissLanguageAlert]);
33 |
34 | useEffect(() => {
35 | const detectedLang = navigator.language; // Get full language code, e.g., zh_HK
36 | const storedDismiss = getLangAlertDismissed();
37 |
38 | if (!storedDismiss) {
39 | let supportedLang = routing.locales.find((l) => l === detectedLang);
40 |
41 | if (!supportedLang) {
42 | const mainLang = detectedLang.split("-")[0];
43 | supportedLang = routing.locales.find((l) => l.startsWith(mainLang));
44 | }
45 |
46 | if (supportedLang && supportedLang !== locale) {
47 | setDetectedLocale(supportedLang);
48 | setShowLanguageAlert(true);
49 | setTimeout(() => setIsVisible(true), 100);
50 | }
51 | }
52 | }, [locale, getLangAlertDismissed, setShowLanguageAlert]);
53 |
54 | useEffect(() => {
55 | let timer: NodeJS.Timeout;
56 |
57 | if (showLanguageAlert && countdown > 0) {
58 | timer = setInterval(() => {
59 | setCountdown((prev) => prev - 1);
60 | }, 1000);
61 | }
62 |
63 | return () => {
64 | if (timer) clearInterval(timer);
65 | };
66 | }, [showLanguageAlert, countdown]);
67 |
68 | useEffect(() => {
69 | if (countdown === 0 && showLanguageAlert) {
70 | handleDismiss();
71 | }
72 | }, [countdown, showLanguageAlert, handleDismiss]);
73 |
74 | if (!showLanguageAlert || !detectedLocale) return null;
75 |
76 | const messages = require(`@/i18n/messages/${detectedLocale}.json`);
77 | const alertMessages = messages.LanguageDetection;
78 |
79 | return (
80 |
92 |
93 |
102 |
103 |
104 |
105 |
106 |
107 | {alertMessages.title}
108 |
109 |
110 |
111 |
112 | {alertMessages.description}
113 |
114 |
115 |
116 |
135 |
136 |
{countdown}s
137 |
138 |
139 |
140 |
141 |
142 |
143 | );
144 | }
145 |
--------------------------------------------------------------------------------
/components/LocaleSwitcher.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Select,
5 | SelectContent,
6 | SelectItem,
7 | SelectTrigger,
8 | SelectValue,
9 | } from "@/components/ui/select";
10 | import {
11 | Locale,
12 | LOCALE_NAMES,
13 | routing,
14 | usePathname,
15 | useRouter,
16 | } from "@/i18n/routing";
17 | import { useLocaleStore } from "@/stores/localeStore";
18 | import { Globe } from "lucide-react";
19 | import { useLocale } from "next-intl";
20 | import { useParams } from "next/navigation";
21 | import { useEffect, useState, useTransition } from "react";
22 |
23 | export default function LocaleSwitcher() {
24 | const router = useRouter();
25 | const pathname = usePathname();
26 | const params = useParams();
27 | const locale = useLocale();
28 | const { dismissLanguageAlert } = useLocaleStore();
29 | const [, startTransition] = useTransition();
30 | const [currentLocale, setCurrentLocale] = useState("locale");
31 |
32 | useEffect(() => {
33 | setCurrentLocale(locale);
34 | }, [locale, setCurrentLocale]);
35 |
36 | function onSelectChange(nextLocale: Locale) {
37 | setCurrentLocale(nextLocale);
38 | dismissLanguageAlert();
39 |
40 | startTransition(() => {
41 | router.replace(
42 | // @ts-expect-error -- TypeScript will validate that only known `params`
43 | // are used in combination with a given `pathname`. Since the two will
44 | // always match for the current route, we can skip runtime checks.
45 | // { pathname: "/", params: params || {} }, // if your want to redirect to the home page
46 | { pathname, params: params || {} }, // if your want to redirect to the current page
47 | { locale: nextLocale }
48 | );
49 | });
50 | }
51 |
52 | return (
53 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/components/TailwindIndicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator() {
2 | if (process.env.NODE_ENV === "production") return null
3 |
4 | return (
5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon, Sun } from "lucide-react";
4 | import { useTheme } from "next-themes";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu";
13 |
14 | export function ThemeToggle() {
15 | const { setTheme } = useTheme();
16 |
17 | return (
18 |
19 |
20 |
25 |
26 |
27 | setTheme("light")}>
28 | Light
29 |
30 | setTheme("dark")}>
31 | Dark
32 |
33 | setTheme("system")}>
34 | System
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/WebsiteLogo.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { getDomain } from "@/lib/utils";
3 | import { useEffect, useState } from "react";
4 |
5 | interface IProps {
6 | url: string;
7 | size?: number;
8 | className?: string;
9 | timeout?: number;
10 | }
11 |
12 | const WebsiteLogo = ({
13 | url,
14 | size = 32,
15 | className = "",
16 | timeout = 1000, // 1 second
17 | }: IProps) => {
18 | const domain = getDomain(url);
19 | const [imgSrc, setImgSrc] = useState(`https://${domain}/logo.svg`);
20 | const [fallbackIndex, setFallbackIndex] = useState(0);
21 | const [isLoading, setIsLoading] = useState(true);
22 | const [hasError, setHasError] = useState(false);
23 |
24 | const fallbackSources = [
25 | `https://${domain}/logo.svg`,
26 | `https://${domain}/logo.png`,
27 | `https://${domain}/apple-touch-icon.png`,
28 | `https://${domain}/apple-touch-icon-precomposed.png`,
29 | `https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
30 | `https://icons.duckduckgo.com/ip3/${domain}.ico`,
31 | `https://${domain}/favicon.ico`,
32 | ];
33 |
34 | useEffect(() => {
35 | let timeoutId: any;
36 |
37 | if (isLoading) {
38 | timeoutId = setTimeout(() => {
39 | handleError();
40 | }, timeout);
41 | }
42 |
43 | return () => {
44 | if (timeoutId) {
45 | clearTimeout(timeoutId);
46 | }
47 | };
48 | }, [imgSrc, isLoading]);
49 |
50 | const handleError = () => {
51 | const nextIndex = fallbackIndex + 1;
52 | if (nextIndex < fallbackSources.length) {
53 | setFallbackIndex(nextIndex);
54 | setImgSrc(fallbackSources[nextIndex]);
55 | setIsLoading(true);
56 | } else {
57 | setHasError(true);
58 | setIsLoading(false);
59 | }
60 | };
61 |
62 | const handleLoad = () => {
63 | setIsLoading(false);
64 | setHasError(false);
65 | };
66 |
67 | return (
68 |
72 | {/* placeholder */}
73 | {isLoading && (
74 |
77 | )}
78 |
79 |

94 |
95 | {/* Fallback: Display first letter of domain when all image sources fail */}
96 | {hasError && (
97 |
101 | {domain.charAt(0).toUpperCase()}
102 |
103 | )}
104 |
105 | );
106 | };
107 |
108 | export default WebsiteLogo;
109 |
--------------------------------------------------------------------------------
/components/footer/Badges.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export default function Badges() {
4 | return (
5 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import BuiltWithButton from "@/components/BuiltWithButton";
2 | import Badges from "@/components/footer/Badges";
3 | import { Newsletter } from "@/components/footer/Newsletter";
4 | import { TwitterX } from "@/components/social-icons/icons";
5 | import { siteConfig } from "@/config/site";
6 | import { Link as I18nLink } from "@/i18n/routing";
7 | import { FooterLink } from "@/types/common";
8 | import { GithubIcon, MailIcon } from "lucide-react";
9 | import { getMessages, getTranslations } from "next-intl/server";
10 | import Link from "next/link";
11 | import { SiBluesky, SiDiscord } from "react-icons/si";
12 |
13 | export default async function Footer() {
14 | const messages = await getMessages();
15 |
16 | const t = await getTranslations("Home");
17 | const tFooter = await getTranslations("Footer");
18 |
19 | const footerLinks: FooterLink[] = tFooter.raw("Links.groups");
20 | footerLinks.forEach((group) => {
21 | const pricingLink = group.links.find((link) => link.id === "pricing");
22 | if (pricingLink) {
23 | pricingLink.href = process.env.NEXT_PUBLIC_PRICING_PATH!;
24 | }
25 | });
26 |
27 | return (
28 |
29 |
188 |
189 | );
190 | }
191 |
--------------------------------------------------------------------------------
/components/footer/Newsletter.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { normalizeEmail, validateEmail } from "@/lib/email";
5 | import { Send } from "lucide-react";
6 | import { useTranslations } from "next-intl";
7 | import { useState } from "react";
8 |
9 | export function Newsletter() {
10 | const [email, setEmail] = useState("");
11 | const [subscribeStatus, setSubscribeStatus] = useState<
12 | "idle" | "loading" | "success" | "error"
13 | >("idle");
14 | const [errorMessage, setErrorMessage] = useState("");
15 |
16 | const t = useTranslations("Footer.Newsletter");
17 |
18 | const handleSubscribe = async (e: React.FormEvent) => {
19 | e.preventDefault();
20 | if (!email) return;
21 |
22 | const normalizedEmailAddress = normalizeEmail(email);
23 | const { isValid, error } = validateEmail(normalizedEmailAddress);
24 |
25 | if (!isValid) {
26 | setSubscribeStatus("error");
27 | setErrorMessage(error || t("defaultErrorMessage"));
28 | setTimeout(() => setSubscribeStatus("idle"), 5000);
29 | return;
30 | }
31 |
32 | try {
33 | setSubscribeStatus("loading");
34 |
35 | const response = await fetch("/api/newsletter", {
36 | method: "POST",
37 | headers: { "Content-Type": "application/json" },
38 | body: JSON.stringify({ email: normalizedEmailAddress }),
39 | });
40 |
41 | const data = await response.json();
42 |
43 | if (!response.ok) {
44 | throw new Error(data.error || t("errorMessage"));
45 | }
46 |
47 | setSubscribeStatus("success");
48 | setEmail("");
49 | setErrorMessage("");
50 | setTimeout(() => setSubscribeStatus("idle"), 5000);
51 | } catch (error) {
52 | setSubscribeStatus("error");
53 | setErrorMessage(
54 | error instanceof Error ? error.message : t("errorMessage2")
55 | );
56 | setTimeout(() => setSubscribeStatus("idle"), 5000);
57 | }
58 | };
59 | return (
60 |
61 |
{t("title")}
62 |
{t("description")}
63 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/components/header/Header.tsx:
--------------------------------------------------------------------------------
1 | import HeaderLinks from "@/components/header/HeaderLinks";
2 | import MobileMenu from "@/components/header/MobileMenu";
3 | import LocaleSwitcher from "@/components/LocaleSwitcher";
4 | import { ThemeToggle } from "@/components/ThemeToggle";
5 | import { siteConfig } from "@/config/site";
6 | import { Link as I18nLink } from "@/i18n/routing";
7 | import { useTranslations } from "next-intl";
8 | import Image from "next/image";
9 |
10 | const Header = () => {
11 | const t = useTranslations("Home");
12 |
13 | return (
14 |
48 | );
49 | };
50 |
51 | export default Header;
52 |
--------------------------------------------------------------------------------
/components/header/HeaderLinks.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Link as I18nLink, usePathname } from "@/i18n/routing";
4 | import { cn } from "@/lib/utils";
5 | import { HeaderLink } from "@/types/common";
6 | import { ExternalLink } from "lucide-react";
7 | import { useTranslations } from "next-intl";
8 |
9 | const HeaderLinks = () => {
10 | const tHeader = useTranslations("Header");
11 | const pathname = usePathname();
12 |
13 | const headerLinks: HeaderLink[] = tHeader.raw("links");
14 |
15 | return (
16 |
17 | {headerLinks.map((link) => (
18 |
30 | {link.name}
31 | {link.target && link.target === "_blank" && (
32 |
33 |
34 |
35 | )}
36 |
37 | ))}
38 |
39 | );
40 | };
41 |
42 | export default HeaderLinks;
43 |
--------------------------------------------------------------------------------
/components/header/MobileMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import LocaleSwitcher from "@/components/LocaleSwitcher";
4 | import { ThemeToggle } from "@/components/ThemeToggle";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuGroup,
9 | DropdownMenuItem,
10 | DropdownMenuLabel,
11 | DropdownMenuSeparator,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 | import { Link as I18nLink } from "@/i18n/routing";
15 | import { HeaderLink } from "@/types/common";
16 | import { Menu } from "lucide-react";
17 | import { useTranslations } from "next-intl";
18 | import Image from "next/image";
19 |
20 | export default function MobileMenu() {
21 | const t = useTranslations("Home");
22 | const tHeader = useTranslations("Header");
23 |
24 | const headerLinks: HeaderLink[] = tHeader.raw("links");
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
42 |
49 | {t("title")}
50 |
51 |
52 |
53 |
54 | {headerLinks.map((link) => (
55 |
56 |
65 | {link.name}
66 |
67 |
68 | ))}
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/components/home/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import WebsiteLogo from "@/components/WebsiteLogo";
3 | import { siteConfig } from "@/config/site";
4 | import { MousePointerClick } from "lucide-react";
5 | import { useTranslations } from "next-intl";
6 | import Link from "next/link";
7 | import { SiDiscord } from "react-icons/si";
8 |
9 | export default function HomeComponent() {
10 | const t = useTranslations("Home");
11 |
12 | return (
13 | <>
14 |
15 |
16 | Next.js{" "}
17 |
18 |
26 | i18n {" "}
27 |
28 | Starter
29 |
30 |
31 | {t("description")}
32 |
33 |
34 |
35 |
52 |
72 |
73 |
74 |
75 |
76 |
80 | {t("whoIsUsing")}
81 |
82 |
83 |
97 |
98 | >
99 | );
100 | }
101 |
102 | const WEBSITE_LOGO_DEMO = [
103 | { name: "J Blog", url: "https://weijunext.com/" },
104 | { name: "OG Image Generator", url: "https://ogimage.click/" },
105 | { name: "newTab", url: "https://ntab.dev/" },
106 | { name: "NextJS 中文文档", url: "https://nextjscn.org/" },
107 | ];
108 |
--------------------------------------------------------------------------------
/components/icons/expanding-arrow.tsx:
--------------------------------------------------------------------------------
1 | export default function ExpandingArrow({ className }: { className?: string }) {
2 | return (
3 |
4 |
19 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/icons/eye.tsx:
--------------------------------------------------------------------------------
1 | export default function TablerEyeFilled(props: any) {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/icons/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as LoadingDots } from "./loading-dots";
2 | export { default as LoadingCircle } from "./loading-circle";
3 | export { default as LoadingSpinner } from "./loading-spinner";
4 | export { default as ExpandingArrow } from "./expanding-arrow";
5 |
--------------------------------------------------------------------------------
/components/icons/loading-circle.tsx:
--------------------------------------------------------------------------------
1 | export default function LoadingCircle() {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/icons/loading-dots.module.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | display: inline-flex;
3 | align-items: center;
4 | }
5 |
6 | .loading .spacer {
7 | margin-right: 2px;
8 | }
9 |
10 | .loading span {
11 | animation-name: blink;
12 | animation-duration: 1.4s;
13 | animation-iteration-count: infinite;
14 | animation-fill-mode: both;
15 | width: 5px;
16 | height: 5px;
17 | border-radius: 50%;
18 | display: inline-block;
19 | margin: 0 1px;
20 | }
21 |
22 | .loading span:nth-of-type(2) {
23 | animation-delay: 0.2s;
24 | }
25 |
26 | .loading span:nth-of-type(3) {
27 | animation-delay: 0.4s;
28 | }
29 |
30 | @keyframes blink {
31 | 0% {
32 | opacity: 0.2;
33 | }
34 | 20% {
35 | opacity: 1;
36 | }
37 | 100% {
38 | opacity: 0.2;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/components/icons/loading-dots.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./loading-dots.module.css";
2 |
3 | const LoadingDots = ({ color = "#000" }: { color?: string }) => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default LoadingDots;
14 |
--------------------------------------------------------------------------------
/components/icons/loading-spinner.module.css:
--------------------------------------------------------------------------------
1 | .spinner {
2 | color: gray;
3 | display: inline-block;
4 | position: relative;
5 | width: 80px;
6 | height: 80px;
7 | transform: scale(0.3) translateX(-95px);
8 | }
9 | .spinner div {
10 | transform-origin: 40px 40px;
11 | animation: spinner 1.2s linear infinite;
12 | }
13 | .spinner div:after {
14 | content: " ";
15 | display: block;
16 | position: absolute;
17 | top: 3px;
18 | left: 37px;
19 | width: 6px;
20 | height: 20px;
21 | border-radius: 20%;
22 | background: black;
23 | }
24 | .spinner div:nth-child(1) {
25 | transform: rotate(0deg);
26 | animation-delay: -1.1s;
27 | }
28 | .spinner div:nth-child(2) {
29 | transform: rotate(30deg);
30 | animation-delay: -1s;
31 | }
32 | .spinner div:nth-child(3) {
33 | transform: rotate(60deg);
34 | animation-delay: -0.9s;
35 | }
36 | .spinner div:nth-child(4) {
37 | transform: rotate(90deg);
38 | animation-delay: -0.8s;
39 | }
40 | .spinner div:nth-child(5) {
41 | transform: rotate(120deg);
42 | animation-delay: -0.7s;
43 | }
44 | .spinner div:nth-child(6) {
45 | transform: rotate(150deg);
46 | animation-delay: -0.6s;
47 | }
48 | .spinner div:nth-child(7) {
49 | transform: rotate(180deg);
50 | animation-delay: -0.5s;
51 | }
52 | .spinner div:nth-child(8) {
53 | transform: rotate(210deg);
54 | animation-delay: -0.4s;
55 | }
56 | .spinner div:nth-child(9) {
57 | transform: rotate(240deg);
58 | animation-delay: -0.3s;
59 | }
60 | .spinner div:nth-child(10) {
61 | transform: rotate(270deg);
62 | animation-delay: -0.2s;
63 | }
64 | .spinner div:nth-child(11) {
65 | transform: rotate(300deg);
66 | animation-delay: -0.1s;
67 | }
68 | .spinner div:nth-child(12) {
69 | transform: rotate(330deg);
70 | animation-delay: 0s;
71 | }
72 | @keyframes spinner {
73 | 0% {
74 | opacity: 1;
75 | }
76 | 100% {
77 | opacity: 0;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/components/icons/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./loading-spinner.module.css";
2 |
3 | export default function LoadingSpinner() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/icons/moon.tsx:
--------------------------------------------------------------------------------
1 | export default function PhMoonFill(props: any) {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/icons/sun.tsx:
--------------------------------------------------------------------------------
1 | export default function PhSunBold(props: any) {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/mdx/Aside.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | interface AsideProps {
4 | icon?: string;
5 | children?: React.ReactNode;
6 | type?: "default" | "warning" | "danger";
7 | }
8 |
9 | export function Aside({
10 | children,
11 | icon,
12 | type = "default",
13 | ...props
14 | }: AsideProps) {
15 | return (
16 |
23 |
24 | {icon || "💡"}
25 |
26 |
{children}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/components/mdx/Callout.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | interface CalloutProps {
4 | icon?: string;
5 | children?: React.ReactNode;
6 | type?: "default" | "warning" | "danger";
7 | }
8 |
9 | export function Callout({
10 | children,
11 | icon,
12 | type = "default",
13 | ...props
14 | }: CalloutProps) {
15 | return (
16 |
23 | {icon &&
{icon}}
24 |
{children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/components/mdx/MDXComponents.tsx:
--------------------------------------------------------------------------------
1 | import { Aside } from "@/components/mdx/Aside";
2 | import { Callout } from "@/components/mdx/Callout";
3 | import { MdxCard } from "@/components/mdx/MdxCard";
4 | import React, { ReactNode } from "react";
5 |
6 | interface HeadingProps {
7 | level: 1 | 2 | 3 | 4 | 5 | 6;
8 | className: string;
9 | children: ReactNode;
10 | }
11 |
12 | const Heading: React.FC = ({ level, className, children }) => {
13 | const HeadingTag = `h${level}` as keyof React.ElementType;
14 | const headingId = children?.toString() ?? "";
15 |
16 | return React.createElement(
17 | HeadingTag,
18 | { id: headingId, className },
19 | children
20 | );
21 | };
22 |
23 | interface MDXComponentsProps {
24 | [key: string]: React.FC;
25 | }
26 |
27 | const MDXComponents: MDXComponentsProps = {
28 | h1: (props) => (
29 |
30 | ),
31 | h2: (props) => (
32 |
37 | ),
38 | h3: (props) => (
39 |
44 | ),
45 | h4: (props) => (
46 |
47 | ),
48 | h5: (props) => (
49 |
50 | ),
51 | h6: (props) => (
52 |
57 | ),
58 | hr: (props) =>
,
59 | p: (props) => (
60 |
64 | ),
65 | a: (props) => (
66 |
71 | ),
72 | ul: (props) => ,
73 | ol: (props) =>
,
74 | li: (props) => (
75 |
76 | ),
77 | code: (props) => (
78 |
82 | ),
83 | pre: (props) => (
84 |
88 | ),
89 | blockquote: (props) => (
90 |
94 | ),
95 | img: (props) => (
96 |
97 | ),
98 | strong: (props) => ,
99 | table: (props) => (
100 |
106 | ),
107 | tr: (props) =>
,
108 | th: (props) => (
109 | |
113 | ),
114 | td: (props) => (
115 | |
119 | ),
120 | Aside,
121 | Callout,
122 | Card: MdxCard,
123 | };
124 |
125 | export default MDXComponents;
126 |
--------------------------------------------------------------------------------
/components/mdx/MdxCard.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | interface CardProps extends React.HTMLAttributes {
6 | href?: string;
7 | disabled?: boolean;
8 | }
9 |
10 | export function MdxCard({
11 | href,
12 | className,
13 | children,
14 | disabled,
15 | ...props
16 | }: CardProps) {
17 | return (
18 |
26 |
27 |
28 | {children}
29 |
30 |
31 | {href && (
32 |
37 |
View
38 |
39 | )}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/components/social-icons/icons.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 |
3 | // Icons taken from: https://simpleicons.org/
4 | // To add a new icon, add a new function here and add it to components in social-icons/index.tsx
5 |
6 | export function Facebook(svgProps: SVGProps) {
7 | return (
8 |
11 | )
12 | }
13 |
14 | export function Github(svgProps: SVGProps) {
15 | return (
16 |
19 | )
20 | }
21 |
22 | export function Linkedin(svgProps: SVGProps) {
23 | return (
24 |
27 | )
28 | }
29 |
30 | export function Mail(svgProps: SVGProps) {
31 | return (
32 |
36 | )
37 | }
38 |
39 | export function Twitter(svgProps: SVGProps) {
40 | return (
41 |
44 | )
45 | }
46 |
47 | export function TwitterX(svgProps: SVGProps) {
48 | return (
49 |
55 | )
56 | }
57 |
58 | export function Youtube(svgProps: SVGProps) {
59 | return (
60 |
63 | )
64 | }
65 |
66 | export function Mastodon(svgProps: SVGProps) {
67 | return (
68 |
71 | )
72 | }
73 |
74 | export function Threads(svgProps: SVGProps) {
75 | return (
76 |
79 | )
80 | }
81 |
82 | export function Instagram(svgProps: SVGProps) {
83 | return (
84 |
87 | )
88 | }
89 |
90 | export function WeChat(svgProps: SVGProps) {
91 | return (
92 |
102 | )
103 | }
104 |
105 | export function JueJin(svgProps: SVGProps) {
106 | return (
107 |
113 | )
114 | }
115 |
--------------------------------------------------------------------------------
/components/social-icons/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Facebook,
3 | Github,
4 | Instagram,
5 | JueJin,
6 | Linkedin,
7 | Mail,
8 | Mastodon,
9 | Threads,
10 | Twitter,
11 | TwitterX,
12 | WeChat,
13 | Youtube
14 | } from './icons'
15 |
16 | const components = {
17 | mail: Mail,
18 | github: Github,
19 | facebook: Facebook,
20 | youtube: Youtube,
21 | linkedin: Linkedin,
22 | twitter: Twitter,
23 | twitterX: TwitterX,
24 | weChat: WeChat,
25 | jueJin: JueJin,
26 | mastodon: Mastodon,
27 | threads: Threads,
28 | instagram: Instagram
29 | }
30 |
31 | type SocialIconProps = {
32 | kind: keyof typeof components
33 | href: string | undefined
34 | size?: number
35 | }
36 |
37 | const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
38 | if (
39 | !href ||
40 | (kind === 'mail' &&
41 | !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
42 | )
43 | return null
44 |
45 | const SocialSvg = components[kind]
46 |
47 | return (
48 |
54 | {kind}
55 |
58 |
59 | )
60 | }
61 |
62 | export default SocialIcon
63 |
--------------------------------------------------------------------------------
/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
74 |
75 | ))
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
77 |
78 | const DropdownMenuItem = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef & {
81 | inset?: boolean
82 | }
83 | >(({ className, inset, ...props }, ref) => (
84 | svg]:size-4 [&>svg]:shrink-0",
88 | inset && "pl-8",
89 | className
90 | )}
91 | {...props}
92 | />
93 | ))
94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
95 |
96 | const DropdownMenuCheckboxItem = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, children, checked, ...props }, ref) => (
100 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | ))
117 | DropdownMenuCheckboxItem.displayName =
118 | DropdownMenuPrimitive.CheckboxItem.displayName
119 |
120 | const DropdownMenuRadioItem = React.forwardRef<
121 | React.ElementRef,
122 | React.ComponentPropsWithoutRef
123 | >(({ className, children, ...props }, ref) => (
124 |
132 |
133 |
134 |
135 |
136 |
137 | {children}
138 |
139 | ))
140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
141 |
142 | const DropdownMenuLabel = React.forwardRef<
143 | React.ElementRef,
144 | React.ComponentPropsWithoutRef & {
145 | inset?: boolean
146 | }
147 | >(({ className, inset, ...props }, ref) => (
148 |
157 | ))
158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
159 |
160 | const DropdownMenuSeparator = React.forwardRef<
161 | React.ElementRef,
162 | React.ComponentPropsWithoutRef
163 | >(({ className, ...props }, ref) => (
164 |
169 | ))
170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
171 |
172 | const DropdownMenuShortcut = ({
173 | className,
174 | ...props
175 | }: React.HTMLAttributes) => {
176 | return (
177 |
181 | )
182 | }
183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
184 |
185 | export {
186 | DropdownMenu,
187 | DropdownMenuTrigger,
188 | DropdownMenuContent,
189 | DropdownMenuItem,
190 | DropdownMenuCheckboxItem,
191 | DropdownMenuRadioItem,
192 | DropdownMenuLabel,
193 | DropdownMenuSeparator,
194 | DropdownMenuShortcut,
195 | DropdownMenuGroup,
196 | DropdownMenuPortal,
197 | DropdownMenuSub,
198 | DropdownMenuSubContent,
199 | DropdownMenuSubTrigger,
200 | DropdownMenuRadioGroup,
201 | }
202 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 | {children}
132 |
133 | ))
134 | SelectItem.displayName = SelectPrimitive.Item.displayName
135 |
136 | const SelectSeparator = React.forwardRef<
137 | React.ElementRef,
138 | React.ComponentPropsWithoutRef
139 | >(({ className, ...props }, ref) => (
140 |
145 | ))
146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
147 |
148 | export {
149 | Select,
150 | SelectGroup,
151 | SelectValue,
152 | SelectTrigger,
153 | SelectContent,
154 | SelectLabel,
155 | SelectItem,
156 | SelectSeparator,
157 | SelectScrollUpButton,
158 | SelectScrollDownButton,
159 | }
160 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/config/site.ts:
--------------------------------------------------------------------------------
1 | import { SiteConfig } from "@/types/siteConfig";
2 |
3 | export const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://nextforge.dev";
4 |
5 | export const SOURCE_CODE_URL = "https://github.com/weijunext/nextjs-15-starter";
6 | export const PRO_VERSION = "https://nexty.dev";
7 |
8 | const TWITTER_URL = 'https://x.com/weijunext'
9 | const BSKY_URL = 'https://bsky.app/profile/judewei.bsky.social'
10 | const EMAIL_URL = 'mailto:weijunext@gmail.com'
11 | const GITHUB_URL = 'https://github.com/weijunext'
12 | const DISCORD_URL = process.env.NEXT_PUBLIC_DISCORD_INVITE_URL
13 |
14 | export const siteConfig: SiteConfig = {
15 | name: "Next Forge",
16 | tagLine: 'Multilingual Next.js 15 Starter',
17 | description:
18 | "A multilingual Next.js 15 starter with built-in i18n support. Launch your global-ready web application with a clean, efficient, and SEO-friendly foundation.",
19 | url: BASE_URL,
20 | authors: [
21 | {
22 | name: "weijunext",
23 | url: "https://weijunext.com",
24 | }
25 | ],
26 | creator: '@weijunext',
27 | socialLinks: {
28 | discord: DISCORD_URL,
29 | twitter: TWITTER_URL,
30 | github: GITHUB_URL,
31 | bluesky: BSKY_URL,
32 | email: EMAIL_URL
33 | },
34 | themeColors: [
35 | { media: '(prefers-color-scheme: light)', color: 'white' },
36 | { media: '(prefers-color-scheme: dark)', color: 'black' },
37 | ],
38 | defaultNextTheme: 'system', // next-theme option: system | dark | light
39 | icons: {
40 | icon: "/favicon.ico",
41 | shortcut: "/logo.png",
42 | apple: "/logo.png", // apple-touch-icon.png
43 | },
44 | }
45 |
--------------------------------------------------------------------------------
/content/about/en.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: About
3 | description: About Next Forge - Multilingual Next.js 15 Starter
4 | lastUpdated: 2025-02-19
5 | ---
6 |
7 | > Update date: 2025-02-19
8 |
9 | # About Next Forge
10 |
11 | Next Forge is a feature-rich Next.js 15 multilingual starter template designed to help developers quickly build globally-ready websites. It comes with built-in i18n support, modern UI design, dark/light theme toggling, responsive layout, MDX blog system, SEO optimization, and integrated analytics tools.
12 |
13 | ## ✨ Key Features
14 |
15 | - 🌐 **Built-in i18n Support**: Supports English, Chinese, and Japanese out of the box, making it easy to create multilingual websites.
16 | - 🎨 **Modern UI Design**: Clean and modern UI powered by Tailwind CSS.
17 | - 🌙 **Dark/Light Theme Toggle**: Allows users to switch between dark and light themes effortlessly.
18 | - 📱 **Responsive Layout**: Fully responsive design ensures a great experience on both mobile and desktop devices.
19 | - 📝 **MDX Blog System**: Write blog posts using MDX for enhanced flexibility and power.
20 | - 🔍 **SEO Optimization**: Comprehensive SEO features including automatic sitemap.xml generation, robots.txt configuration, and optimized metadata.
21 | - 📊 **Analytics Integration**: Integrated with Google Analytics, Baidu Analytics, Google Adsense, and Vercel Analytics for easy tracking and insights.
22 | - 🌿 **Eco-Friendly Performance**: Achieved A+ rating on [Website Carbon](https://www.websitecarbon.com/website/nextforge-dev/), making it one of the most energy-efficient websites.
23 |
24 | ## 🚀 Quick Start
25 |
26 | 1. Clone the repository:
27 | ```bash
28 | git clone https://github.com/weijunext/nextjs-15-starter.git
29 | ```
30 |
31 | 2. Install dependencies:
32 | ```bash
33 | npm install
34 | ```
35 |
36 | 3. Copy environment variables:
37 | ```bash
38 | cp .env.example .env
39 | ```
40 |
41 | 4. Start the development server:
42 | ```bash
43 | npm run dev
44 | ```
45 |
46 | Visit [http://localhost:3000](http://localhost:3000) to view your application.
47 |
48 | ## ⚙️ Configuration
49 |
50 | 1. **Basic Setup**:
51 | - Edit `config/site.ts` to configure website information.
52 | - Update icons and logo in the `public/` directory.
53 | - Configure `app/sitemap.ts` and `app/robots.ts`.
54 |
55 | 2. **i18n Setup**:
56 | - Add or modify language files in `i18n/messages/`.
57 | - Configure supported languages in `i18n/routing.ts`.
58 | - Set up i18n routing in `middleware.ts`.
59 |
60 | ## 📝 Content Management
61 |
62 | ### Blog Posts
63 | Create MDX files in `blog/[locale]` with the following format:
64 | ````
65 |
66 | ---
67 | title: Post Title
68 | description: Post Description
69 | image: /image.png
70 | slug: /url-path
71 | tags: tag1,tag2
72 | date: 2025-02-20
73 | visible: published
74 | pin: true
75 | ---
76 |
77 | Post content...
78 | ````
79 |
80 | ### Static Pages
81 | Manage static page content in `content/[page]/[locale].mdx`.
82 |
83 | ## 📄 License
84 |
85 | Next Forge is licensed under the MIT License, allowing you to freely use, modify, and distribute the template.
86 |
87 | ## 🤝 Contributing
88 |
89 | We welcome issues and pull requests! Your contributions help us improve this project.
90 |
91 | ## About the Author
92 |
93 | A Next.js full-stack specialist offering expert services in project development, performance optimization, and SEO improvement.
94 |
95 | For consulting and training opportunities, reach out at weijunext@gmail.com
96 |
97 | - [Github](https://github.com/weijunext)
98 | - [Bento](https://bento.me/weijunext)
99 | - [Twitter/X](https://twitter.com/judewei_dev)
100 |
--------------------------------------------------------------------------------
/content/about/ja.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: サイトについて
3 | description: Next Forge - 多言語対応 Next.js 15 スターターについて
4 | lastUpdated: 2025-02-19
5 | ---
6 |
7 | > 更新日: 2025-02-19
8 |
9 | # Next Forge について
10 |
11 | Next Forge は、グローバル対応のウェブサイトを素早く構築するための、機能豊富な Next.js 15 多言語スターターテンプレートです。多言語対応、モダンな UI デザイン、ダーク/ライトテーマ切り替え、レスポンシブデザイン、MDX ブログシステム、SEO 最適化、アナリティクス統合など、多くの機能を備えています。
12 |
13 | ## ✨ 主な機能
14 |
15 | - 🌐 **多言語対応**: 英語、中国語、日本語をサポートし、多言語サイトを簡単に構築できます。
16 | - 🎨 **モダンな UI デザイン**: Tailwind CSS を利用したクリーンでモダンな UI デザイン。
17 | - 🌙 **ダーク/ライトテーマ切り替え**: ユーザーが簡単にダークモードとライトモードを切り替えられる機能。
18 | - 📱 **レスポンシブデザイン**: モバイルとデスクトップの両方で最適なユーザー体験を提供します。
19 | - 📝 **MDX ブログシステム**: MDX を使用してブログ記事を執筆し、柔軟性とパワーを兼ね備えています。
20 | - 🔍 **SEO 最適化**: sitemap.xml の自動生成、robots.txt の設定、最適化されたメタデータなど、包括的な SEO 機能を搭載。
21 | - 📊 **アナリティクス統合**: Google Analytics、Baidu Analytics、Google Adsense、Vercel Analytics を統合し、簡単にデータを追跡できます。
22 | - 🌿 **エコフレンドリーなパフォーマンス**: [Website Carbon](https://www.websitecarbon.com/website/nextforge-dev/) で A+ 評価を獲得し、最もエネルギー効率の良いウェブサイトの一つです。
23 |
24 | ## 🚀 クイックスタート
25 |
26 | 1. リポジトリのクローン:
27 | ```bash
28 | git clone https://github.com/weijunext/nextjs-15-starter.git
29 | ```
30 |
31 | 2. 依存関係のインストール:
32 | ```bash
33 | npm install
34 | ```
35 |
36 | 3. 環境変数の設定:
37 | ```bash
38 | cp .env.example .env
39 | ```
40 |
41 | 4. 開発サーバーの起動:
42 | ```bash
43 | npm run dev
44 | ```
45 |
46 | [http://localhost:3000](http://localhost:3000) にアクセスしてアプリケーションを確認できます。
47 |
48 | ## ⚙️ 設定方法
49 |
50 | 1. **基本設定**:
51 | - `config/site.ts` を編集してウェブサイト情報を設定します。
52 | - `public/` ディレクトリ内のアイコンとロゴを更新します。
53 | - `app/sitemap.ts` と `app/robots.ts` を設定します。
54 |
55 | 2. **多言語設定**:
56 | - `i18n/messages/` 内の言語ファイルを追加または編集します。
57 | - `i18n/routing.ts` でサポートする言語を設定します。
58 | - `middleware.ts` で多言語ルーティングを設定します。
59 |
60 | ## 📝 コンテンツ管理
61 |
62 | ### ブログ投稿
63 | `blog/[locale]` ディレクトリに以下のフォーマットで MDX ファイルを作成します:
64 | ````
65 |
66 | ---
67 | title: 投稿タイトル
68 | description: 投稿の説明
69 | image: /image.png
70 | slug: /url-path
71 | tags: tag1,tag2
72 | date: 2025-02-20
73 | visible: published
74 | pin: true
75 | ---
76 |
77 | 投稿内容...
78 | ````
79 |
80 | ### 静的ページ
81 | `content/[page]/[locale].mdx` で静的ページのコンテンツを管理します。
82 |
83 | ## 📄 ライセンス
84 |
85 | Next Forge は MIT ライセンスで提供されており、自由に使用、変更、配布することができます。
86 |
87 | ## 🤝 コントリビューション
88 |
89 | Issue や Pull Request を歓迎します!皆さんの貢献がこのプロジェクトをより良いものにします。
90 |
91 | ## 作者について
92 |
93 | Next.js のフルスタックスペシャリストとして、プロジェクト開発、パフォーマンス最適化、SEO 改善のエキスパートサービスを提供しています。
94 |
95 | コンサルティングやトレーニングの機会については、weijunext@gmail.com までご連絡ください。
96 |
97 | - [Github](https://github.com/weijunext)
98 | - [Bento](https://bento.me/weijunext)
99 | - [Twitter/X](https://twitter.com/judewei_dev)
100 |
--------------------------------------------------------------------------------
/content/about/zh.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 关于
3 | description: 关于 Next Forge 多语言启动模板
4 | lastUpdated: 2025-02-19
5 | ---
6 |
7 | > 更新日期:2025-02-19
8 |
9 | # 关于 Next Forge
10 |
11 | Next Forge 是一个功能完备的 Next.js 15 多语言启动模板,旨在帮助开发者快速构建面向全球的网站。它内置了多语言支持、现代 UI 设计、深色/浅色主题切换、响应式布局、MDX 博客系统、SEO 优化以及多种统计分析工具。
12 |
13 | ## ✨ 主要特性
14 |
15 | - 🌐 **多语言支持**:内置中文、英文、日语的国际化支持,轻松实现多语言网站。
16 | - 🎨 **现代 UI 设计**:基于 Tailwind CSS 的现代 UI 设计,简洁美观。
17 | - 🌙 **深色/浅色主题切换**:支持用户自由切换深色和浅色主题。
18 | - 📱 **响应式布局**:适配各种设备,确保在移动端和桌面端都有良好的用户体验。
19 | - 📝 **MDX 博客系统**:支持使用 MDX 编写博客文章,灵活且强大。
20 | - 🔍 **SEO 优化**:内置完整的 SEO 优化方案,包括自动生成 sitemap.xml 和 robots.txt。
21 | - 📊 **统计分析**:集成 Google Analytics、Baidu Analytics、Google Adsense 和 Vercel Analytics,方便进行数据追踪。
22 | - 🌿 **环保性能**:在 [Website Carbon](https://www.websitecarbon.com/website/nextforge-dev/) 上获得 A+ 评级,成为最节能的网站之一。
23 |
24 | ## 🚀 快速开始
25 |
26 | 1. 克隆项目:
27 | ```bash
28 | git clone https://github.com/weijunext/nextjs-15-starter.git
29 | ```
30 |
31 | 2. 安装依赖:
32 | ```bash
33 | npm install
34 | ```
35 |
36 | 3. 复制环境变量文件:
37 | ```bash
38 | cp .env.example .env
39 | ```
40 |
41 | 4. 启动开发服务器:
42 | ```bash
43 | npm run dev
44 | ```
45 |
46 | 访问 [http://localhost:3000](http://localhost:3000) 查看你的应用。
47 |
48 | ## ⚙️ 配置
49 |
50 | 1. **基础配置**:
51 | - 修改 `config/site.ts` 配置网站信息。
52 | - 更新 `public/` 下的图标和 logo。
53 | - 配置 `app/sitemap.ts` 和 `app/robots.ts`。
54 |
55 | 2. **多语言配置**:
56 | - 在 `i18n/messages/` 下添加或修改语言文件。
57 | - 在 `i18n/routing.ts` 中配置支持的语言。
58 | - 在 `middleware.ts` 中配置多语言路由。
59 |
60 | ## 📝 内容管理
61 |
62 | ### 博客文章
63 | 在 `blog/[locale]` 目录下创建 MDX 文件,支持以下格式:
64 | ```markdown
65 | ---
66 | title: 文章标题
67 | description: 文章描述
68 | image: /image.png
69 | slug: /url-path
70 | tags: tag1,tag2
71 | date: 2025-02-20
72 | visible: published
73 | pin: true
74 | ---
75 |
76 | 文章内容...
77 | ```
78 |
79 | ### 静态页面
80 | 在 `content/[page]/[locale].mdx` 下管理静态页面内容。
81 |
82 | ## 📄 许可证
83 |
84 | Next Forge 采用 MIT 许可证,您可以自由使用、修改和分发。
85 |
86 | ## 🤝 贡献
87 |
88 | 欢迎提交 Issue 和 Pull Request!您的贡献将帮助我们不断改进这个项目。
89 |
90 | ## 关于作者
91 |
92 | 专注于 Next.js 全栈开发,欢迎探讨开发、咨询与培训等合作机会,联系微信:bigye_chengpu
93 |
94 | - [Github](https://github.com/weijunext)
95 | - [Twitter/X](https://twitter.com/weijunext)
96 | - [博客 - J实验室](https://weijunext.com)
97 | - [Medium](https://medium.com/@weijunext)
98 | - [掘金](https://juejin.cn/user/26044008768029)
99 | - [知乎](https://www.zhihu.com/people/mo-mo-mo-89-12-11)
--------------------------------------------------------------------------------
/content/privacy-policy/en.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Privacy Policy
3 | description: How we collect and use your information
4 | lastUpdated: "2025-02-19"
5 | ---
6 |
7 | > Updated on 2025-02-19
8 |
9 | # Privacy Policy
10 |
11 | This Privacy Policy describes how we collect and use your information.
12 |
13 | ## Information We Collect
14 |
15 | We collect information that you provide directly to us:
16 | - Account information
17 | - Usage data
18 | - Communication preferences
19 |
20 | ## How We Use Your Information
21 |
22 | We use the information we collect to:
23 | - Provide our services
24 | - Improve user experience
25 | - Send important updates
26 |
27 | ## Data Security
28 |
29 | We implement appropriate security measures to protect your data.
30 |
31 | ## Contact Information
32 |
33 | For privacy-related questions, please reach out to our team.
34 |
35 | ## Updates to This Policy
36 |
37 | We may update this policy from time to time. Please review it periodically.
--------------------------------------------------------------------------------
/content/privacy-policy/ja.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: プライバシーポリシー
3 | description: お客様の情報の収集および利用方法について
4 | lastUpdated: "2025-02-19"
5 | ---
6 |
7 | > 更新日:2025年2月19日
8 |
9 | # プライバシーポリシー
10 |
11 | 本プライバシーポリシーでは、当社がお客様の情報をどのように収集し、利用するかについて説明いたします。
12 |
13 | ## 収集する情報
14 |
15 | 当社は、お客様から直接提供いただく以下の情報を収集いたします:
16 | - アカウント情報
17 | - 利用データ
18 | - 通信設定
19 |
20 | ## 情報の利用目的
21 |
22 | 収集した情報は、以下の目的で利用させていただきます:
23 | - サービスの提供
24 | - ユーザー体験の向上
25 | - 重要なお知らせの送信
26 |
27 | ## データセキュリティ
28 |
29 | お客様のデータを保護するため、適切なセキュリティ対策を実施しております。
30 |
31 | ## お問い合わせ先
32 |
33 | プライバシーに関するご質問は、当社サポートチームまでお問い合わせください。
34 |
35 | ## ポリシーの更新
36 |
37 | 本ポリシーは随時更新される場合がございます。定期的にご確認いただきますようお願いいたします。
--------------------------------------------------------------------------------
/content/privacy-policy/zh.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 隐私政策
3 | description: 我们如何收集和使用您的信息
4 | lastUpdated: "2025-02-19"
5 | ---
6 |
7 | > 更新日期:2025年2月19日
8 |
9 | # 隐私政策
10 |
11 | 本隐私政策说明了我们如何收集和使用您的信息。
12 |
13 | ## 我们收集的信息
14 |
15 | 我们收集您直接提供给我们的信息:
16 | - 账户信息
17 | - 使用数据
18 | - 通信偏好设置
19 |
20 | ## 我们如何使用您的信息
21 |
22 | 我们将收集的信息用于:
23 | - 提供服务
24 | - 改善用户体验
25 | - 发送重要更新
26 |
27 | ## 数据安全
28 |
29 | 我们采取适当的安全措施来保护您的数据。
30 |
31 | ## 联系方式
32 |
33 | 如有隐私相关问题,请联系我们的团队。
34 |
35 | ## 政策更新
36 |
37 | 我们可能会不时更新本政策。请定期查看。
--------------------------------------------------------------------------------
/content/terms-of-service/en.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Terms of Service
3 | description: Terms and conditions for using our service
4 | lastUpdated: "2025-02-19"
5 | ---
6 |
7 | > Updated on 2025-02-19
8 |
9 | # Terms of Service
10 |
11 | Welcome to our service. By using our website, you agree to these terms.
12 |
13 | ## 1. Acceptance of Terms
14 |
15 | By accessing and using this website, you accept and agree to be bound by the terms and conditions of this agreement.
16 |
17 | ## 2. User Responsibilities
18 |
19 | You agree to:
20 | - Provide accurate information
21 | - Maintain the security of your account
22 | - Comply with all applicable laws
23 |
24 | ## 3. Service Changes
25 |
26 | We reserve the right to:
27 | - Modify or discontinue services
28 | - Update these terms at any time
29 |
30 | ## Contact Us
31 |
32 | If you have any questions about these terms, please contact us.
--------------------------------------------------------------------------------
/content/terms-of-service/ja.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 利用規約
3 | description: サービス利用に関する規約と条件
4 | lastUpdated: "2025-02-19"
5 | ---
6 |
7 | > 更新日:2025年2月19日
8 |
9 | # 利用規約
10 |
11 | 当社のサービスをご利用いただき、ありがとうございます。本ウェブサイトをご利用になる際は、以下の利用規約に同意いただいたものとみなします。
12 |
13 | ## 1. 規約の同意
14 |
15 | 本ウェブサイトにアクセスし利用することにより、お客様は本規約の条件に同意し、拘束されることを承諾するものとします。
16 |
17 | ## 2. ユーザーの責任
18 |
19 | お客様には以下の事項に同意していただきます:
20 | - 正確な情報の提供
21 | - アカウントの安全管理
22 | - 適用される法令の遵守
23 |
24 | ## 3. サービスの変更
25 |
26 | 当社は以下の権利を留保します:
27 | - サービスの変更または終了
28 | - 本規約の随時更新
29 |
30 | ## お問い合わせ
31 |
32 | 本規約に関するご質問がございましたら、お気軽にお問い合わせください。
--------------------------------------------------------------------------------
/content/terms-of-service/zh.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 服务条款
3 | description: 使用我们服务的条款和条件
4 | lastUpdated: "2025-02-19"
5 | ---
6 |
7 | > 更新日期:2025年2月19日
8 |
9 | # 服务条款
10 |
11 | 欢迎使用我们的服务。使用本网站即表示您同意以下条款。
12 |
13 | ## 1. 条款接受
14 |
15 | 访问和使用本网站即表示您接受并同意受本协议条款和条件的约束。
16 |
17 | ## 2. 用户责任
18 |
19 | 您同意:
20 | - 提供准确信息
21 | - 维护账户安全
22 | - 遵守所有适用法律
23 |
24 | ## 3. 服务变更
25 |
26 | 我们保留以下权利:
27 | - 修改或终止服务
28 | - 随时更新这些条款
29 |
30 | ## 联系我们
31 |
32 | 如果您对这些条款有任何疑问,请与我们联系。
--------------------------------------------------------------------------------
/gtag.js:
--------------------------------------------------------------------------------
1 | export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GOOGLE_ID || null;
2 |
3 | export const pageview = (url) => {
4 | window.gtag("config", GA_TRACKING_ID, {
5 | page_path: url,
6 | });
7 | };
8 |
9 | export const event = ({ action, category, label, value }) => {
10 | window.gtag("event", action, {
11 | event_category: category,
12 | event_label: label,
13 | value: value,
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | // ! Side effects ! - This could be extracted into a dismissToast() action,
97 | // but I'll keep it here for simplicity
98 | if (toastId) {
99 | addToRemoveQueue(toastId)
100 | } else {
101 | state.toasts.forEach((toast) => {
102 | addToRemoveQueue(toast.id)
103 | })
104 | }
105 |
106 | return {
107 | ...state,
108 | toasts: state.toasts.map((t) =>
109 | t.id === toastId || toastId === undefined
110 | ? {
111 | ...t,
112 | open: false,
113 | }
114 | : t
115 | ),
116 | }
117 | }
118 | case "REMOVE_TOAST":
119 | if (action.toastId === undefined) {
120 | return {
121 | ...state,
122 | toasts: [],
123 | }
124 | }
125 | return {
126 | ...state,
127 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
128 | }
129 | }
130 | }
131 |
132 | const listeners: Array<(state: State) => void> = []
133 |
134 | let memoryState: State = { toasts: [] }
135 |
136 | function dispatch(action: Action) {
137 | memoryState = reducer(memoryState, action)
138 | listeners.forEach((listener) => {
139 | listener(memoryState)
140 | })
141 | }
142 |
143 | type Toast = Omit
144 |
145 | function toast({ ...props }: Toast) {
146 | const id = genId()
147 |
148 | const update = (props: ToasterToast) =>
149 | dispatch({
150 | type: "UPDATE_TOAST",
151 | toast: { ...props, id },
152 | })
153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154 |
155 | dispatch({
156 | type: "ADD_TOAST",
157 | toast: {
158 | ...props,
159 | id,
160 | open: true,
161 | onOpenChange: (open) => {
162 | if (!open) dismiss()
163 | },
164 | },
165 | })
166 |
167 | return {
168 | id: id,
169 | dismiss,
170 | update,
171 | }
172 | }
173 |
174 | function useToast() {
175 | const [state, setState] = React.useState(memoryState)
176 |
177 | React.useEffect(() => {
178 | listeners.push(setState)
179 | return () => {
180 | const index = listeners.indexOf(setState)
181 | if (index > -1) {
182 | listeners.splice(index, 1)
183 | }
184 | }
185 | }, [state])
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191 | }
192 | }
193 |
194 | export { useToast, toast }
195 |
--------------------------------------------------------------------------------
/i18n/messages/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "LanguageDetection": {
3 | "title": "Language Suggestion",
4 | "description": "We noticed your browser language differs from the current site language. You can switch languages anytime.",
5 | "countdown": "Closing in {countdown} seconds",
6 | "switchTo": "Switch to"
7 | },
8 | "Header": {
9 | "links": [
10 | {
11 | "name": "Blog",
12 | "href": "/blog"
13 | },
14 | {
15 | "name": "About",
16 | "href": "/about"
17 | },
18 | {
19 | "name": "Source Code",
20 | "href": "https://github.com/weijunext/nextjs-15-starter",
21 | "target": "_blank",
22 | "rel": "noopener noreferrer nofollow"
23 | },
24 | {
25 | "name": "Pro Version",
26 | "href": "https://nexty.dev",
27 | "target": "_blank",
28 | "rel": "noopener noreferrer"
29 | }
30 | ]
31 | },
32 | "Footer": {
33 | "Copyright": "Copyright © {year} {name} All rights reserved.",
34 | "PrivacyPolicy": "Privacy Policy",
35 | "TermsOfService": "Terms of Service",
36 | "Links": {
37 | "groups": [
38 | {
39 | "title": "Languages",
40 | "links": [
41 | {
42 | "href": "/en",
43 | "name": "English",
44 | "useA": true
45 | },
46 | {
47 | "href": "/zh",
48 | "name": "中文",
49 | "useA": true
50 | },
51 | {
52 | "href": "/ja",
53 | "name": "日本語",
54 | "useA": true
55 | }
56 | ]
57 | },
58 | {
59 | "title": "Open Source",
60 | "links": [
61 | {
62 | "href": "https://github.com/weijunext/nextjs-15-starter",
63 | "name": "Next Forge",
64 | "rel": "noopener noreferrer nofollow",
65 | "target": "_blank"
66 | },
67 | {
68 | "href": "https://github.com/weijunext/landing-page-boilerplate",
69 | "name": "Landing Page Boilerplate",
70 | "rel": "noopener noreferrer nofollow",
71 | "target": "_blank"
72 | },
73 | {
74 | "href": "https://github.com/weijunext/weekly-boilerplate",
75 | "name": "Blog Boilerplate",
76 | "rel": "noopener noreferrer nofollow",
77 | "target": "_blank"
78 | }
79 | ]
80 | },
81 | {
82 | "title": "Other Products",
83 | "links": [
84 | {
85 | "href": "https://nexty.dev/",
86 | "name": "Nexty - SaaS Template",
87 | "rel": "noopener noreferrer",
88 | "target": "_blank"
89 | },
90 | {
91 | "href": "https://ogimage.click/",
92 | "name": "OG Image Generator",
93 | "rel": "noopener noreferrer",
94 | "target": "_blank"
95 | },
96 | {
97 | "href": "https://dofollow.tools/",
98 | "name": "Dofollow.Tools",
99 | "rel": "noopener noreferrer",
100 | "target": "_blank"
101 | }
102 | ]
103 | }
104 | ]
105 | },
106 | "Newsletter": {
107 | "title": "Subscribe to our newsletter",
108 | "description": "Get the latest news and updates from Next Forge",
109 | "defaultErrorMessage": "Please enter a valid email address",
110 | "successMessage": "Subscription successful",
111 | "errorMessage": "Subscription failed",
112 | "errorMessage2": "Subscription failed. Please try again later.",
113 | "subscribe": "Subscribe",
114 | "subscribing": "Subscribing...",
115 | "subscribed": "Subscription successful! Thank you for your attention."
116 | }
117 | },
118 | "Home": {
119 | "title": "Next Forge",
120 | "tagLine": "Multilingual Next.js 15 Starter",
121 | "description": "A multilingual Next.js 15 starter with built-in i18n support. Launch your global-ready web application with a clean, efficient, and SEO-friendly foundation.",
122 | "whoIsUsing": "Who is Using This Starter"
123 | },
124 | "Blog": {
125 | "title": "Blog Posts",
126 | "description": "A list of blog posts"
127 | },
128 | "About": {
129 | "title": "About",
130 | "description": "About the site"
131 | },
132 | "TermsOfService": {
133 | "title": "Terms of Service",
134 | "description": "Terms of Service"
135 | },
136 | "PrivacyPolicy": {
137 | "title": "Privacy Policy",
138 | "description": "Privacy Policy"
139 | }
140 | }
--------------------------------------------------------------------------------
/i18n/messages/ja.json:
--------------------------------------------------------------------------------
1 | {
2 | "LanguageDetection": {
3 | "title": "言語の提案",
4 | "description": "ブラウザの言語設定が現在のサイト言語と異なっています。いつでも言語を切り替えることができます。",
5 | "countdown": "閉じるまで {countdown} 秒",
6 | "switchTo": "に切り替える"
7 | },
8 | "Header": {
9 | "links": [
10 | {
11 | "name": "ブログ",
12 | "href": "/blog"
13 | },
14 | {
15 | "name": "サイトについて",
16 | "href": "/about"
17 | },
18 | {
19 | "name": "Source Code",
20 | "href": "https://github.com/weijunext/nextjs-15-starter",
21 | "target": "_blank",
22 | "rel": "noopener noreferrer nofollow"
23 | },
24 | {
25 | "name": "Pro Version",
26 | "href": "https://nexty.dev",
27 | "target": "_blank",
28 | "rel": "noopener noreferrer"
29 | }
30 | ]
31 | },
32 | "Footer": {
33 | "Copyright": "Copyright © {year} {name} All rights reserved.",
34 | "PrivacyPolicy": "プライバシーポリシー",
35 | "TermsOfService": "利用規約",
36 | "Links": {
37 | "groups": [
38 | {
39 | "title": "言語",
40 | "links": [
41 | {
42 | "href": "/en",
43 | "name": "English",
44 | "useA": true
45 | },
46 | {
47 | "href": "/zh",
48 | "name": "中文",
49 | "useA": true
50 | },
51 | {
52 | "href": "/ja",
53 | "name": "日本語",
54 | "useA": true
55 | }
56 | ]
57 | },
58 | {
59 | "title": "オープンソースプロジェクト",
60 | "links": [
61 | {
62 | "href": "https://github.com/weijunext/nextjs-15-starter",
63 | "name": "Next Forge",
64 | "rel": "noopener noreferrer nofollow",
65 | "target": "_blank"
66 | },
67 | {
68 | "href": "https://github.com/weijunext/landing-page-boilerplate",
69 | "name": "Landing Page Boilerplate",
70 | "rel": "noopener noreferrer nofollow",
71 | "target": "_blank"
72 | },
73 | {
74 | "href": "https://github.com/weijunext/weekly-boilerplate",
75 | "name": "Blog Boilerplate",
76 | "rel": "noopener noreferrer nofollow",
77 | "target": "_blank"
78 | }
79 | ]
80 | },
81 | {
82 | "title": "その他の製品",
83 | "links": [
84 | {
85 | "href": "https://nexty.dev/",
86 | "name": "Nexty - SaaS Template",
87 | "rel": "noopener noreferrer",
88 | "target": "_blank"
89 | },
90 | {
91 | "href": "https://ogimage.click/",
92 | "name": "OG Image Generator",
93 | "rel": "noopener noreferrer",
94 | "target": "_blank"
95 | },
96 | {
97 | "href": "https://dofollow.tools/",
98 | "name": "Dofollow.Tools",
99 | "rel": "noopener noreferrer",
100 | "target": "_blank"
101 | }
102 | ]
103 | }
104 | ]
105 | },
106 | "Newsletter": {
107 | "title": "Next Forgeのニュースレターに登録する",
108 | "description": "Next Forgeの最新のニュースと更新情報を入手します。",
109 | "defaultErrorMessage": "有効なメールアドレスを入力してください。",
110 | "successMessage": "登録が完了しました。",
111 | "errorMessage": "登録に失敗しました。",
112 | "errorMessage2": "登録に失敗しました。少し待ってから再試行してください。",
113 | "subscribe": "登録する",
114 | "subscribing": "登録中...",
115 | "subscribed": "登録が完了しました。ありがとうございます。"
116 | }
117 | },
118 | "Home": {
119 | "title": "Next Forge",
120 | "tagLine": "Next.js 多言語スターターテンプレート",
121 | "description": "多言語対応機能を搭載したNext.js 15スターターテンプレート。SEO最適化済みで、グローバル展開可能なウェブアプリを効率的に構築できます。",
122 | "whoIsUsing": "誰がこのスターターを使用していますか"
123 | },
124 | "Blog": {
125 | "title": "ブログ一覧",
126 | "description": "ブログ投稿の一覧"
127 | },
128 | "About": {
129 | "title": "サイトについて",
130 | "description": "サイトについて"
131 | },
132 | "TermsOfService": {
133 | "title": "サービス利用規約",
134 | "description": "サービス利用規約"
135 | },
136 | "PrivacyPolicy": {
137 | "title": "プライバシーポリシー",
138 | "description": "プライバシーポリシー"
139 | }
140 | }
--------------------------------------------------------------------------------
/i18n/messages/zh.json:
--------------------------------------------------------------------------------
1 | {
2 | "LanguageDetection": {
3 | "title": "语言建议",
4 | "description": "检测到你的浏览器语言和当前语言不一样,你随时都可切换语言。",
5 | "countdown": "将在 {countdown} 秒后关闭",
6 | "switchTo": "切换到"
7 | },
8 | "Header": {
9 | "links": [
10 | {
11 | "name": "博客",
12 | "href": "/blog"
13 | },
14 | {
15 | "name": "关于",
16 | "href": "/about"
17 | },
18 | {
19 | "name": "源码",
20 | "href": "https://github.com/weijunext/nextjs-15-starter",
21 | "target": "_blank",
22 | "rel": "noopener noreferrer nofollow"
23 | },
24 | {
25 | "name": "高级版",
26 | "href": "https://nexty.dev",
27 | "target": "_blank",
28 | "rel": "noopener noreferrer"
29 | }
30 | ]
31 | },
32 | "Footer": {
33 | "Copyright": "版权所有 © {year} {name} 保留所有权利。",
34 | "PrivacyPolicy": "隐私政策",
35 | "TermsOfService": "服务条款",
36 | "Links": {
37 | "groups": [
38 | {
39 | "title": "语言",
40 | "links": [
41 | {
42 | "href": "/en",
43 | "name": "English",
44 | "useA": true
45 | },
46 | {
47 | "href": "/zh",
48 | "name": "中文",
49 | "useA": true
50 | },
51 | {
52 | "href": "/ja",
53 | "name": "日本語",
54 | "useA": true
55 | }
56 | ]
57 | },
58 | {
59 | "title": "开源项目",
60 | "links": [
61 | {
62 | "href": "https://github.com/weijunext/nextjs-15-starter",
63 | "name": "Next Forge",
64 | "rel": "noopener noreferrer nofollow",
65 | "target": "_blank"
66 | },
67 | {
68 | "href": "https://github.com/weijunext/landing-page-boilerplate",
69 | "name": "Landing Page Boilerplate",
70 | "rel": "noopener noreferrer nofollow",
71 | "target": "_blank"
72 | },
73 | {
74 | "href": "https://github.com/weijunext/weekly-boilerplate",
75 | "name": "Blog Boilerplate",
76 | "rel": "noopener noreferrer nofollow",
77 | "target": "_blank"
78 | }
79 | ]
80 | },
81 | {
82 | "title": "其他产品",
83 | "links": [
84 | {
85 | "href": "https://nexty.dev/",
86 | "name": "Nexty - SaaS Template",
87 | "rel": "noopener noreferrer",
88 | "target": "_blank"
89 | },
90 | {
91 | "href": "https://ogimage.click/",
92 | "name": "OG Image Generator",
93 | "rel": "noopener noreferrer",
94 | "target": "_blank"
95 | },
96 | {
97 | "href": "https://dofollow.tools/",
98 | "name": "Dofollow.Tools",
99 | "rel": "noopener noreferrer",
100 | "target": "_blank"
101 | }
102 | ]
103 | }
104 | ]
105 | },
106 | "Newsletter": {
107 | "title": "订阅我们的邮件",
108 | "description": "获取最新的 Next.js 资讯和教程",
109 | "defaultErrorMessage": "请输入有效的邮箱地址",
110 | "successMessage": "订阅成功",
111 | "errorMessage": "订阅失败",
112 | "errorMessage2": "订阅失败,请稍后再试",
113 | "subscribe": "订阅",
114 | "subscribing": "订阅中",
115 | "subscribed": "订阅成功!感谢您的关注。"
116 | }
117 | },
118 | "Home": {
119 | "title": "Next Forge",
120 | "tagLine": "Next.js 多语言启动模板",
121 | "description": "内置多语言支持的 Next.js 15 启动模板,助您快速构建面向全球的出海网站。简洁高效,开箱即用,完全优化的SEO基础架构。",
122 | "whoIsUsing": "谁在使用此模板"
123 | },
124 | "Blog": {
125 | "title": "博客列表",
126 | "description": "博客列表"
127 | },
128 | "About": {
129 | "title": "关于",
130 | "description": "关于网站"
131 | },
132 | "TermsOfService": {
133 | "title": "服务条款",
134 | "description": "服务条款"
135 | },
136 | "PrivacyPolicy": {
137 | "title": "隐私政策",
138 | "description": "隐私政策"
139 | }
140 | }
--------------------------------------------------------------------------------
/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from 'next-intl/server';
2 | import { routing } from './routing';
3 |
4 | export default getRequestConfig(async ({ requestLocale }) => {
5 | // This typically corresponds to the `[locale]` segment
6 | let locale = await requestLocale;
7 |
8 | if (locale?.startsWith('zh')) {
9 | locale = 'zh';
10 | } else if (locale?.startsWith('ja')) {
11 | locale = 'ja';
12 | } else {
13 | locale = 'en';
14 | }
15 |
16 | // Ensure that a valid locale is used
17 | if (!locale || !routing.locales.includes(locale as any)) {
18 | return {
19 | locale: routing.defaultLocale,
20 | messages: (await import(`./messages/${routing.defaultLocale}.json`)).default
21 | };
22 | }
23 |
24 | return {
25 | locale,
26 | messages: (await import(`./messages/${locale}.json`)).default
27 | };
28 | });
--------------------------------------------------------------------------------
/i18n/routing.ts:
--------------------------------------------------------------------------------
1 | import { createNavigation } from 'next-intl/navigation';
2 | import { defineRouting } from 'next-intl/routing';
3 |
4 | export const LOCALES = ['en', 'zh', 'ja']
5 | export const DEFAULT_LOCALE = 'en'
6 | export const LOCALE_NAMES: Record = {
7 | 'en': "English",
8 | 'zh': "中文",
9 | 'ja': "日本語",
10 | };
11 |
12 | export const routing = defineRouting({
13 | // A list of all locales that are supported
14 | locales: LOCALES,
15 |
16 | // Used when no locale matches
17 | defaultLocale: DEFAULT_LOCALE,
18 |
19 | // auto detect locale
20 | localeDetection: process.env.NEXT_PUBLIC_LOCALE_DETECTION === 'true',
21 |
22 | localePrefix: 'as-needed',
23 | });
24 |
25 | // Lightweight wrappers around Next.js' navigation APIs
26 | // that will consider the routing configuration
27 | export const {
28 | Link,
29 | redirect,
30 | usePathname,
31 | useRouter,
32 | getPathname,
33 | } = createNavigation(routing);
34 |
35 |
36 | export type Locale = (typeof routing.locales)[number];
37 |
--------------------------------------------------------------------------------
/lib/email.ts:
--------------------------------------------------------------------------------
1 | const DISPOSABLE_EMAIL_DOMAINS = [
2 | 'tempmail.com',
3 | 'throwawaymail.com',
4 | 'tempmail100.com'
5 | ];
6 |
7 | export type EmailValidationError =
8 | | 'invalid_email_format'
9 | | 'email_part_too_long'
10 | | 'disposable_email_not_allowed'
11 | | 'invalid_characters';
12 |
13 | const EMAIL_REGEX = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
14 |
15 | export function validateEmail(email: string): {
16 | isValid: boolean;
17 | error?: string;
18 | } {
19 | // validate email format
20 | if (!EMAIL_REGEX.test(email)) {
21 | return {
22 | isValid: false,
23 | error: 'invalid_email_format'
24 | };
25 | }
26 |
27 | // check domain length
28 | const [localPart, domain] = email.split('@');
29 | if (domain.length > 255 || localPart.length > 64) {
30 | return {
31 | isValid: false,
32 | error: 'email_part_too_long'
33 | };
34 | }
35 |
36 | // check if it's a disposable email
37 | if (DISPOSABLE_EMAIL_DOMAINS.includes(domain.toLowerCase())) {
38 | return {
39 | isValid: false,
40 | error: 'disposable_email_not_allowed'
41 | };
42 | }
43 |
44 | // check for special characters
45 | if (/[<>()[\]\\.,;:\s@"]+/.test(localPart)) {
46 | return {
47 | isValid: false,
48 | error: 'invalid_characters'
49 | };
50 | }
51 |
52 | return { isValid: true };
53 | }
54 |
55 | // email validation (including alias detection)
56 | export function normalizeEmail(email: string): string {
57 | if (!email) return '';
58 |
59 | // convert to lowercase
60 | let normalizedEmail = email.toLowerCase();
61 |
62 | // separate email local part and domain part
63 | const [localPart, domain] = normalizedEmail.split('@');
64 |
65 | // handle different email service provider alias rules
66 | switch (domain) {
67 | case 'gmail.com':
68 | // remove dot and + suffix
69 | const gmailBase = localPart
70 | .replace(/\./g, '')
71 | .split('+')[0];
72 | return `${gmailBase}@${domain}`;
73 |
74 | case 'outlook.com':
75 | case 'hotmail.com':
76 | case 'live.com':
77 | // remove + suffix
78 | const microsoftBase = localPart.split('+')[0];
79 | return `${microsoftBase}@${domain}`;
80 |
81 | case 'yahoo.com':
82 | // remove - suffix
83 | const yahooBase = localPart.split('-')[0];
84 | return `${yahooBase}@${domain}`;
85 |
86 | default:
87 | // for other emails, only remove + suffix
88 | const baseLocalPart = localPart.split('+')[0];
89 | return `${baseLocalPart}@${domain}`;
90 | }
91 | }
--------------------------------------------------------------------------------
/lib/getBlogs.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_LOCALE } from '@/i18n/routing';
2 | import { BlogPost } from '@/types/blog';
3 | import fs from 'fs';
4 | import matter from 'gray-matter';
5 | import path from 'path';
6 |
7 | const POSTS_BATCH_SIZE = 10;
8 |
9 | export async function getPosts(locale: string = DEFAULT_LOCALE): Promise<{ posts: BlogPost[] }> {
10 | const postsDirectory = path.join(process.cwd(), 'blogs', locale);
11 |
12 | // is directory exist
13 | if (!fs.existsSync(postsDirectory)) {
14 | return { posts: [] };
15 | }
16 |
17 | let filenames = await fs.promises.readdir(postsDirectory);
18 | filenames = filenames.reverse();
19 |
20 | let allPosts: BlogPost[] = [];
21 |
22 | // read file by batch
23 | for (let i = 0; i < filenames.length; i += POSTS_BATCH_SIZE) {
24 | const batchFilenames = filenames.slice(i, i + POSTS_BATCH_SIZE);
25 |
26 | const batchPosts: BlogPost[] = await Promise.all(
27 | batchFilenames.map(async (filename) => {
28 | const fullPath = path.join(postsDirectory, filename);
29 | const fileContents = await fs.promises.readFile(fullPath, 'utf8');
30 |
31 | const { data, content } = matter(fileContents);
32 |
33 | return {
34 | locale, // use locale parameter
35 | title: data.title,
36 | description: data.description,
37 | image: data.image || '',
38 | slug: data.slug,
39 | tags: data.tags,
40 | date: data.date,
41 | visible: data.visible || 'published',
42 | pin: data.pin || false,
43 | content,
44 | metadata: data,
45 | };
46 | })
47 | );
48 |
49 | allPosts.push(...batchPosts);
50 | }
51 |
52 | // filter out non-published articles
53 | allPosts = allPosts.filter(post => post.visible === 'published');
54 |
55 | // sort posts by pin and date
56 | allPosts = allPosts.sort((a, b) => {
57 | if (a.pin !== b.pin) {
58 | return (b.pin ? 1 : 0) - (a.pin ? 1 : 0);
59 | }
60 | return new Date(b.date).getTime() - new Date(a.date).getTime();
61 | });
62 |
63 | return {
64 | posts: allPosts,
65 | };
66 | }
--------------------------------------------------------------------------------
/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as winston from 'winston';
3 | import 'winston-daily-rotate-file';
4 |
5 | const logDir: string = process.env.LOG_DIR || 'log';
6 |
7 | if (!fs.existsSync(logDir)) {
8 | fs.mkdirSync(logDir, { recursive: true });
9 | }
10 |
11 | const fileTransport = new winston.transports.DailyRotateFile({
12 | filename: `${logDir}/%DATE%-results.log`,
13 | datePattern: 'YYYY-MM-DD',
14 | zippedArchive: true,
15 | maxSize: '20m',
16 | maxFiles: '3d',
17 | level: 'info',
18 | });
19 |
20 | const logger: winston.Logger = winston.createLogger({
21 | level: 'debug',
22 | format: winston.format.combine(
23 | winston.format.timestamp({
24 | format: 'YYYY-MM-DD HH:mm:ss'
25 | }),
26 | winston.format.json()
27 | ),
28 | transports: [
29 | fileTransport,
30 | new winston.transports.Console({
31 | level: 'debug',
32 | format: winston.format.combine(
33 | winston.format.colorize(),
34 | winston.format.simple()
35 | )
36 | })
37 | ]
38 | });
39 |
40 | export default logger;
41 |
--------------------------------------------------------------------------------
/lib/metadata.ts:
--------------------------------------------------------------------------------
1 | import { siteConfig } from '@/config/site'
2 | import { DEFAULT_LOCALE, LOCALE_NAMES, Locale } from '@/i18n/routing'
3 | import { Metadata } from 'next'
4 | import { getTranslations } from 'next-intl/server'
5 |
6 | type MetadataProps = {
7 | page?: string
8 | title?: string
9 | description?: string
10 | images?: string[]
11 | noIndex?: boolean
12 | locale: Locale
13 | path?: string
14 | canonicalUrl?: string
15 | }
16 |
17 | export async function constructMetadata({
18 | page = 'Home',
19 | title,
20 | description,
21 | images = [],
22 | noIndex = false,
23 | locale,
24 | path,
25 | canonicalUrl,
26 | }: MetadataProps): Promise {
27 | // get translations
28 | const t = await getTranslations({ locale, namespace: 'Home' })
29 |
30 | // get page specific metadata translations
31 | const pageTitle = title || t(`title`)
32 | const pageDescription = description || t(`description`)
33 |
34 | // build full title
35 | const finalTitle = page === 'Home'
36 | ? `${pageTitle} - ${t('tagLine')}`
37 | : `${pageTitle} | ${t('title')}`
38 |
39 | // build image URLs
40 | const imageUrls = images.length > 0
41 | ? images.map(img => ({
42 | url: img.startsWith('http') ? img : `${siteConfig.url}/${img}`,
43 | alt: pageTitle,
44 | }))
45 | : [{
46 | url: `${siteConfig.url}/og.png`,
47 | alt: pageTitle,
48 | }]
49 |
50 | // Open Graph Site
51 | const pageURL = `${locale === DEFAULT_LOCALE ? '' : `/${locale}`}${path}` || siteConfig.url
52 |
53 | // build alternate language links
54 | const alternateLanguages = Object.keys(LOCALE_NAMES).reduce((acc, lang) => {
55 | const path = canonicalUrl
56 | ? `${lang === DEFAULT_LOCALE ? '' : `/${lang}`}${canonicalUrl === '/' ? '' : canonicalUrl}`
57 | : `${lang === DEFAULT_LOCALE ? '' : `/${lang}`}`
58 | acc[lang] = `${siteConfig.url}${path}`
59 |
60 | return acc
61 | }, {} as Record)
62 |
63 | return {
64 | title: finalTitle,
65 | description: pageDescription,
66 | keywords: [],
67 | authors: siteConfig.authors,
68 | creator: siteConfig.creator,
69 | metadataBase: new URL(siteConfig.url),
70 | alternates: {
71 | canonical: canonicalUrl ? `${siteConfig.url}${locale === DEFAULT_LOCALE ? '' : `/${locale}`}${canonicalUrl === '/' ? '' : canonicalUrl}` : undefined,
72 | languages: alternateLanguages,
73 | },
74 | openGraph: {
75 | type: 'website',
76 | title: finalTitle,
77 | description: pageDescription,
78 | url: pageURL,
79 | siteName: t('title'),
80 | locale: locale,
81 | images: imageUrls,
82 | },
83 | twitter: {
84 | card: 'summary_large_image',
85 | title: finalTitle,
86 | description: pageDescription,
87 | site: `${siteConfig.url}${pageURL === '/' ? '' : pageURL}`,
88 | images: imageUrls,
89 | creator: siteConfig.creator,
90 | },
91 | robots: {
92 | index: !noIndex,
93 | follow: !noIndex,
94 | googleBot: {
95 | index: !noIndex,
96 | follow: !noIndex,
97 | },
98 | },
99 | }
100 | }
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | export const getDomain = (url: string) => {
9 | try {
10 | // Add https:// protocol if not present
11 | const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`;
12 | const domain = new URL(urlWithProtocol).hostname;
13 | // Remove 'www.' prefix if exists
14 | return domain.replace(/^www\./, '');
15 | } catch (error) {
16 | // Return original input if URL parsing fails
17 | return url;
18 | }
19 | };
--------------------------------------------------------------------------------
/log/.db69a462fcd0bc6ae6d0cc5220e6aa21ec198c81-audit.json:
--------------------------------------------------------------------------------
1 | {
2 | "keep": {
3 | "days": true,
4 | "amount": 3
5 | },
6 | "auditLog": "log/.db69a462fcd0bc6ae6d0cc5220e6aa21ec198c81-audit.json",
7 | "files": [
8 | {
9 | "date": 1733723462799,
10 | "name": "log/2024-12-09-results.log",
11 | "hash": "05d583fcbceaa268a024a210f7aee5ba516a76a6faaaea634dd5caab904bd55c"
12 | }
13 | ],
14 | "hashType": "sha256"
15 | }
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from 'next-intl/middleware';
2 | import { routing } from './i18n/routing';
3 |
4 | export default createMiddleware(routing);
5 |
6 | export const config = {
7 | matcher: [
8 | // Enable a redirect to a matching locale at the root
9 | '/',
10 |
11 | // Set a cookie to remember the previous locale for
12 | // all requests that have a locale prefix
13 | '/(en|zh|ja)/:path*',
14 |
15 | // Enable redirects that add missing locales
16 | // (e.g. `/pathnames` -> `/en/pathnames`)
17 | '/((?!api|_next|_vercel|.*\\.|favicon.ico).*)'
18 | ]
19 | };
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import createNextIntlPlugin from "next-intl/plugin";
2 |
3 | const withNextIntl = createNextIntlPlugin();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const nextConfig = {
7 | images: {
8 | remotePatterns: [
9 | ...(process.env.R2_PUBLIC_URL
10 | ? [
11 | {
12 | hostname: process.env.R2_PUBLIC_URL.replace("https://", ""),
13 | },
14 | ]
15 | : []),
16 | ],
17 | },
18 | compiler: {
19 | removeConsole:
20 | process.env.NODE_ENV === "production"
21 | ? {
22 | exclude: ["error"],
23 | }
24 | : false,
25 | },
26 | };
27 |
28 | export default withNextIntl(nextConfig);
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-forge",
3 | "version": "2.0.0",
4 | "packageManager": "pnpm@10.12.4",
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-dropdown-menu": "^2.1.4",
13 | "@radix-ui/react-select": "^2.1.6",
14 | "@radix-ui/react-slot": "^1.1.0",
15 | "@radix-ui/react-toast": "^1.2.6",
16 | "@types/js-cookie": "^3.0.6",
17 | "@upstash/ratelimit": "^2.0.5",
18 | "@upstash/redis": "^1.34.5",
19 | "@vercel/analytics": "^1.4.1",
20 | "axios": "^1.7.9",
21 | "class-variance-authority": "^0.7.1",
22 | "clsx": "^2.1.1",
23 | "dayjs": "^1.11.13",
24 | "gray-matter": "^4.0.3",
25 | "js-cookie": "^3.0.5",
26 | "lucide-react": "^0.468.0",
27 | "next": "15.2.4",
28 | "next-intl": "^4.0.2",
29 | "next-mdx-remote-client": "2",
30 | "next-themes": "^0.4.4",
31 | "react": "^19.0.0",
32 | "react-dom": "^19.0.0",
33 | "react-hot-toast": "^2.4.1",
34 | "react-icons": "^5.4.0",
35 | "remark-gfm": "^4.0.0",
36 | "resend": "^4.1.2",
37 | "tailwind-merge": "^2.5.5",
38 | "tailwindcss-animate": "^1.0.7",
39 | "winston": "^3.17.0",
40 | "winston-daily-rotate-file": "^5.0.0",
41 | "zod": "^3.24.1",
42 | "zustand": "^5.0.3"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^20",
46 | "@types/react": "^19",
47 | "@types/react-dom": "^19",
48 | "autoprefixer": "^10.4.19",
49 | "eslint": "^9",
50 | "eslint-config-next": "15.0.4",
51 | "postcss": "^8.4.38",
52 | "tailwindcss": "^3.4.3",
53 | "typescript": "^5"
54 | }
55 | }
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/ads.txt:
--------------------------------------------------------------------------------
1 | google.com, pub-xxxxxxxxxxxxxx, DIRECT, yyyyyyyyyyyyyyyyyy
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/logo.png
--------------------------------------------------------------------------------
/public/logo_nexty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/logo_nexty.png
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/og.png
--------------------------------------------------------------------------------
/public/og.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/og.webp
--------------------------------------------------------------------------------
/public/placeholder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/try-nexty.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/try-nexty.webp
--------------------------------------------------------------------------------
/public/zs.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/weijunext/nextjs-15-starter/1f760356b159c73a67f88428bc9b147a66ac82c8/public/zs.jpeg
--------------------------------------------------------------------------------
/stores/localeStore.ts:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie';
2 | import { create } from 'zustand';
3 |
4 | interface LocaleState {
5 | showLanguageAlert: boolean
6 | setShowLanguageAlert: (show: boolean) => void
7 | dismissLanguageAlert: () => void
8 | getLangAlertDismissed: () => boolean
9 | }
10 |
11 | export const useLocaleStore = create((set) => ({
12 | showLanguageAlert: false,
13 | setShowLanguageAlert: (show) => set({ showLanguageAlert: show }),
14 | dismissLanguageAlert: () => {
15 | // cookie expires 30 days
16 | Cookies.set("langAlertDismissed", "true", { expires: 30 });
17 | set({ showLanguageAlert: false });
18 | },
19 | getLangAlertDismissed: () => {
20 | return Cookies.get("langAlertDismissed") === "true";
21 | },
22 | }))
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | scroll-behavior: smooth;
7 | }
8 |
9 | @layer base {
10 | * {
11 | @apply border-border;
12 | }
13 |
14 | body {
15 | @apply bg-background text-foreground;
16 | }
17 | }
18 |
19 | @layer base {
20 | /* Change global styles at: https://tweakcn.com/editor/theme */
21 | :root {
22 | --background: 223.8136 -172.5242% 100.0000%;
23 | --foreground: 223.8136 0.0000% 3.9388%;
24 | --card: 223.8136 -172.5242% 100.0000%;
25 | --card-foreground: 223.8136 0.0000% 3.9388%;
26 | --popover: 223.8136 -172.5242% 100.0000%;
27 | --popover-foreground: 223.8136 0.0000% 3.9388%;
28 | --primary: 223.8136 0.0000% 9.0527%;
29 | --primary-foreground: 223.8136 0.0004% 98.0256%;
30 | --secondary: 223.8136 0.0002% 96.0587%;
31 | --secondary-foreground: 223.8136 0.0000% 9.0527%;
32 | --muted: 223.8136 0.0002% 96.0587%;
33 | --muted-foreground: 223.8136 0.0000% 45.1519%;
34 | --accent: 223.8136 0.0002% 96.0587%;
35 | --accent-foreground: 223.8136 0.0000% 9.0527%;
36 | --destructive: 351.7303 123.6748% 40.5257%;
37 | --destructive-foreground: 223.8136 -172.5242% 100.0000%;
38 | --border: 223.8136 0.0001% 89.8161%;
39 | --input: 223.8136 0.0001% 89.8161%;
40 | --ring: 223.8136 0.0000% 63.0163%;
41 | --chart-1: 211.7880 101.9718% 78.6759%;
42 | --chart-2: 217.4076 91.3672% 59.5787%;
43 | --chart-3: 221.4336 86.3731% 54.0624%;
44 | --chart-4: 223.6587 78.7180% 47.8635%;
45 | --chart-5: 226.5426 70.0108% 39.9224%;
46 | --sidebar: 223.8136 0.0004% 98.0256%;
47 | --sidebar-foreground: 223.8136 0.0000% 3.9388%;
48 | --sidebar-primary: 223.8136 0.0000% 9.0527%;
49 | --sidebar-primary-foreground: 223.8136 0.0004% 98.0256%;
50 | --sidebar-accent: 223.8136 0.0002% 96.0587%;
51 | --sidebar-accent-foreground: 223.8136 0.0000% 9.0527%;
52 | --sidebar-border: 223.8136 0.0001% 89.8161%;
53 | --sidebar-ring: 223.8136 0.0000% 63.0163%;
54 | --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
55 | --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
56 | --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
57 | --radius: 0.625rem;
58 | --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
59 | --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
60 | --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
61 | --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
62 | --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
63 | --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
64 | --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
65 | --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
66 | --tracking-normal: 0em;
67 | --spacing: 0.25rem;
68 | }
69 |
70 | .dark {
71 | --background: 223.8136 0.0000% 3.9388%;
72 | --foreground: 223.8136 0.0004% 98.0256%;
73 | --card: 223.8136 0.0000% 9.0527%;
74 | --card-foreground: 223.8136 0.0004% 98.0256%;
75 | --popover: 223.8136 0.0000% 14.9382%;
76 | --popover-foreground: 223.8136 0.0004% 98.0256%;
77 | --primary: 223.8136 0.0001% 89.8161%;
78 | --primary-foreground: 223.8136 0.0000% 9.0527%;
79 | --secondary: 223.8136 0.0000% 14.9382%;
80 | --secondary-foreground: 223.8136 0.0004% 98.0256%;
81 | --muted: 223.8136 0.0000% 14.9382%;
82 | --muted-foreground: 223.8136 0.0000% 63.0163%;
83 | --accent: 223.8136 0.0000% 25.0471%;
84 | --accent-foreground: 223.8136 0.0004% 98.0256%;
85 | --destructive: 358.7594 101.8439% 69.8357%;
86 | --destructive-foreground: 223.8136 0.0004% 98.0256%;
87 | --border: 223.8136 0.0000% 15.5096%;
88 | --input: 223.8136 0.0000% 20.3885%;
89 | --ring: 223.8136 0.0000% 45.1519%;
90 | --chart-1: 211.7880 101.9718% 78.6759%;
91 | --chart-2: 217.4076 91.3672% 59.5787%;
92 | --chart-3: 221.4336 86.3731% 54.0624%;
93 | --chart-4: 223.6587 78.7180% 47.8635%;
94 | --chart-5: 226.5426 70.0108% 39.9224%;
95 | --sidebar: 223.8136 0.0000% 9.0527%;
96 | --sidebar-foreground: 223.8136 0.0004% 98.0256%;
97 | --sidebar-primary: 225.3451 84.0953% 48.9841%;
98 | --sidebar-primary-foreground: 223.8136 0.0004% 98.0256%;
99 | --sidebar-accent: 223.8136 0.0000% 14.9382%;
100 | --sidebar-accent-foreground: 223.8136 0.0004% 98.0256%;
101 | --sidebar-border: 223.8136 0.0000% 15.5096%;
102 | --sidebar-ring: 223.8136 0.0000% 32.1993%;
103 | --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
104 | --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
105 | --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
106 | --radius: 0.625rem;
107 | --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
108 | --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
109 | --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
110 | --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
111 | --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
112 | --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
113 | --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
114 | --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/styles/loading.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | display: inline-flex;
3 | align-items: center;
4 | }
5 |
6 | .loading .spacer {
7 | margin-right: 2px;
8 | }
9 |
10 | .loading span {
11 | animation-name: blink;
12 | animation-duration: 1.4s;
13 | animation-iteration-count: infinite;
14 | animation-fill-mode: both;
15 | width: 5px;
16 | height: 5px;
17 | border-radius: 50%;
18 | display: inline-block;
19 | margin: 0 1px;
20 | }
21 |
22 | .loading span:nth-of-type(2) {
23 | animation-delay: 0.2s;
24 | }
25 |
26 | .loading span:nth-of-type(3) {
27 | animation-delay: 0.4s;
28 | }
29 |
30 | @keyframes blink {
31 | 0% {
32 | opacity: 0.2;
33 | }
34 | 20% {
35 | opacity: 1;
36 | }
37 | 100% {
38 | opacity: 0.2;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: '2rem',
16 | screens: {
17 | '2xl': '1400px'
18 | }
19 | },
20 | extend: {
21 | colors: {
22 | border: 'hsl(var(--border))',
23 | input: 'hsl(var(--input))',
24 | ring: 'hsl(var(--ring))',
25 | background: 'hsl(var(--background))',
26 | foreground: 'hsl(var(--foreground))',
27 | primary: {
28 | DEFAULT: 'hsl(var(--primary))',
29 | foreground: 'hsl(var(--primary-foreground))'
30 | },
31 | secondary: {
32 | DEFAULT: 'hsl(var(--secondary))',
33 | foreground: 'hsl(var(--secondary-foreground))'
34 | },
35 | destructive: {
36 | DEFAULT: 'hsl(var(--destructive))',
37 | foreground: 'hsl(var(--destructive-foreground))'
38 | },
39 | muted: {
40 | DEFAULT: 'hsl(var(--muted))',
41 | foreground: 'hsl(var(--muted-foreground))'
42 | },
43 | accent: {
44 | DEFAULT: 'hsl(var(--accent))',
45 | foreground: 'hsl(var(--accent-foreground))'
46 | },
47 | popover: {
48 | DEFAULT: 'hsl(var(--popover))',
49 | foreground: 'hsl(var(--popover-foreground))'
50 | },
51 | card: {
52 | DEFAULT: 'hsl(var(--card))',
53 | foreground: 'hsl(var(--card-foreground))'
54 | },
55 | chart: {
56 | '1': 'hsl(var(--chart-1))',
57 | '2': 'hsl(var(--chart-2))',
58 | '3': 'hsl(var(--chart-3))',
59 | '4': 'hsl(var(--chart-4))',
60 | '5': 'hsl(var(--chart-5))'
61 | }
62 | },
63 | borderRadius: {
64 | lg: 'var(--radius)',
65 | md: 'calc(var(--radius) - 2px)',
66 | sm: 'calc(var(--radius) - 4px)'
67 | },
68 | keyframes: {
69 | 'accordion-down': {
70 | from: {
71 | height: '0'
72 | },
73 | to: {
74 | height: 'var(--radix-accordion-content-height)'
75 | }
76 | },
77 | 'accordion-up': {
78 | from: {
79 | height: 'var(--radix-accordion-content-height)'
80 | },
81 | to: {
82 | height: '0'
83 | }
84 | }
85 | },
86 | animation: {
87 | 'accordion-down': 'accordion-down 0.2s ease-out',
88 | 'accordion-up': 'accordion-up 0.2s ease-out'
89 | }
90 | }
91 | },
92 | plugins: [require("tailwindcss-animate")],
93 | } satisfies Config
94 |
95 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "baseUrl": ".",
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/types/blog.ts:
--------------------------------------------------------------------------------
1 |
2 | export type BlogPost = {
3 | locale?: string
4 | title: string
5 | description?: string
6 | image?: string
7 | slug: string
8 | tags?: string
9 | date: Date
10 | visible?: 'draft' | 'invisible' | 'published'
11 | pin?: boolean
12 | content: string
13 | metadata: {
14 | [key: string]: any
15 | },
16 | }
17 |
--------------------------------------------------------------------------------
/types/common.ts:
--------------------------------------------------------------------------------
1 | export interface HeaderLink {
2 | id?: string;
3 | name: string;
4 | href: string;
5 | target?: string;
6 | rel?: string;
7 | };
8 |
9 | export interface FooterLink {
10 | title: string;
11 | links: Link[];
12 | };
13 |
14 | interface Link {
15 | id?: string;
16 | href: string;
17 | name: string;
18 | target?: string;
19 | rel?: string;
20 | useA?: boolean;
21 | };
--------------------------------------------------------------------------------
/types/siteConfig.ts:
--------------------------------------------------------------------------------
1 |
2 | export type AuthorsConfig = {
3 | name: string
4 | url: string
5 | }
6 | export type ThemeColor = {
7 | media: string
8 | color: string
9 | }
10 | export type SiteConfig = {
11 | name: string
12 | tagLine?: string
13 | description?: string
14 | url: string
15 | authors: AuthorsConfig[]
16 | socialLinks?: {
17 | github?: string
18 | bluesky?: string
19 | twitter?: string
20 | twitterZh?: string
21 | discord?: string
22 | email?: string
23 | }
24 | creator: string
25 | themeColors?: string | ThemeColor[]
26 | defaultNextTheme?: string
27 | icons: {
28 | icon: string
29 | shortcut?: string
30 | apple?: string
31 | }
32 | }
33 |
--------------------------------------------------------------------------------