├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── blog │ ├── [slug] │ │ └── page.tsx │ ├── blog.css │ ├── category │ │ └── [slug] │ │ │ └── page.tsx │ ├── page.tsx │ ├── sitemap.xml │ │ └── route.tsx │ └── tag │ │ └── [slug] │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components ├── ArticleCard.tsx ├── HighlightCode.tsx ├── NotFound.tsx └── Pagination.tsx ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tsconfig.json └── utils ├── cache.ts └── seoBot.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MarsX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [SEObot](https://seobotai.com/?utm_source=github) Blog API for Next.js Website 2 | 3 | ## Overview 4 | 5 | Elevate your Next.js website by integrating SEObot's Blog API. This setup allows you to fetch and render real-time, SEO-optimized blog content directly into your website. 6 | 7 | ## Demo 8 | 9 | Visit the [DevHunt Blog](https://devhunt.org/blog?utm_source=github) to check out an example of SEObot Blog API integration. 10 | 11 | ## Prerequisites 12 | 13 | - Node.js installed on your machine 14 | - SEObot API Key (you can find it in your website settings on [app.seobotai.com](https://app.seobotai.com/?utm_source=github)) 15 | 16 | ## Environment Setup 17 | 18 | First, set up your SEObot API Key environment variable. Create a `.env.local` file for local development: 19 | 20 | ```bash 21 | SEOBOT_API_KEY= 22 | ``` 23 | 24 | ⚠️ You can use following **DEMO** SEOBOT_API_KEY for local development: 25 | 26 | ```bash 27 | SEOBOT_API_KEY=a8c58738-7b98-4597-b20a-0bb1c2fe5772 28 | ``` 29 | 30 | ## Running the Development Server 31 | 32 | 1. Install the required packages: 33 | 34 | ```bash 35 | npm install 36 | # or 37 | yarn install 38 | # or 39 | pnpm install 40 | ``` 41 | 42 | 2. Execute one of the following commands to start the development server: 43 | 44 | ```bash 45 | npm run dev 46 | # or 47 | yarn dev 48 | # or 49 | pnpm dev 50 | ``` 51 | 52 | Once the server is running, navigate to [http://localhost:3000/blog](http://localhost:3000/blog) in your browser to view the application. 53 | 54 | ## API client library 55 | 56 | This project incorporates the SEObot Blog API Client Library. For more details and to install the npm package, visit [seobot on npmjs](https://www.npmjs.com/package/seobot). 57 | 58 | ## Editing the Blog Design 59 | 60 | You can begin customizing your blog design by editing the files under the `app/blog/` route. Your changes will be automatically reflected in the app thanks to Next.js's hot reloading feature. 61 | 62 | ## Sitemap Configuration for SEO 63 | 64 | ### Automatic Integration 65 | 66 | This project comes with a separate `blog/sitemap.xml` generated dynamically. If you have an existing dynamic sitemap, you can programmatically merge the blog sitemap into it. The exact steps depend on how you're generating your main sitemap, but the goal is to combine them seamlessly. 67 | 68 | ### Standalone Blog Sitemap 69 | 70 | If you prefer not to merge, it's crucial to submit the standalone `blog/sitemap.xml` to your Google Search Console for SEO. 71 | 72 | - Open Google Search Console. 73 | - Select 'Sitemaps' from the menu. 74 | - Enter the URL of your `blog/sitemap.xml`. 75 | - Click 'Submit'. 76 | 77 | ⚠️ **Google Limits**: keep this in mind that Google restricts sitemaps to 50,000 URLs and a file size of up to 50MB. 78 | 79 | ## Contributing 80 | 81 | If you find any bugs or have feature suggestions, please open an issue or submit a pull request. 82 | 83 | ## License 84 | 85 | This project is licensed under the MIT License. 86 | 87 | ## Contact 88 | 89 | For additional assistance or information, feel free to reach out. 90 | 91 | --- 92 | 93 | Revolutionize your website's content strategy with real-time, automated, SEO-optimized blog posts. Get started with [SEObot](https://seobotai.com/?utm_source=github) AI Blog Autopilot integration today! -------------------------------------------------------------------------------- /app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | import { BlogClient } from 'seobot'; 5 | 6 | import NotFound from '@/components/NotFound'; 7 | import HighlightCode from '@/components/HighlightCode'; 8 | import '../blog.css'; 9 | 10 | async function getPost(slug: string) { 11 | const key = process.env.SEOBOT_API_KEY; 12 | if (!key) throw Error('SEOBOT_API_KEY enviroment variable must be set. You can use the DEMO key a8c58738-7b98-4597-b20a-0bb1c2fe5772 for testing - please set it in the root .env.local file'); 13 | 14 | const client = new BlogClient(key); 15 | return client.getArticle(slug); 16 | } 17 | 18 | export const fetchCache = 'force-no-store'; 19 | 20 | export async function generateMetadata({ params: { slug } }: { params: { slug: string } }): Promise { 21 | const post = await getPost(slug); 22 | if (!post) return {}; 23 | 24 | const title = post.headline; 25 | const description = post.metaDescription; 26 | return { 27 | title, 28 | description, 29 | metadataBase: new URL('https://devhunt.org'), 30 | alternates: { 31 | canonical: `/blog/${slug}`, 32 | }, 33 | openGraph: { 34 | type: 'article', 35 | title, 36 | description, 37 | images: [post.image], 38 | url: `https://devhunt.org/blog/${slug}`, 39 | }, 40 | twitter: { 41 | title, 42 | description, 43 | card: 'summary_large_image', 44 | images: [post.image], 45 | }, 46 | }; 47 | } 48 | 49 | export default async function Article({ params: { slug } }: { params: { slug: string } }) { 50 | const post = await getPost(slug); 51 | if (!post) return ; 52 | 53 | return ( 54 |
55 | {post.category 56 | ? ( 57 |
58 | Home 59 | 60 | 64 | 65 | Blog 66 | 67 | 71 | 72 | {post.category.title} 73 |
74 | ) 75 | : null} 76 |
77 | 78 | Published{' '} 79 | {new Date(post.publishedAt || post.createdAt).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' })} 80 | 81 | {post.readingTime ? {` ⦁ ${post.readingTime}`} min read : null} 82 |
83 |
84 | {post.headline} 85 |
86 |
87 |
88 | {(post.tags || []).map((t: any, ix: number) => ( 89 | 90 | {t.title} 91 | 92 | ))} 93 |
94 | {post.relatedPosts?.length 95 | ? ( 96 |
97 |

Related posts

98 |
    99 | {post.relatedPosts.map((p: any, ix: number) => ( 100 |
  • 101 | {p.headline} 102 |
  • 103 | ))} 104 |
105 |
106 | ) 107 | : null} 108 | 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /app/blog/blog.css: -------------------------------------------------------------------------------- 1 | .article { 2 | -webkit-text-size-adjust: 100%; 3 | line-height: 1.6; 4 | -moz-tab-size: 4; 5 | -o-tab-size: 4; 6 | tab-size: 4; 7 | font-size: 20px !important; 8 | max-width: 720px; 9 | margin-bottom: 96px; 10 | padding: 48px 1rem; 11 | } 12 | 13 | #table-of-contents-list { 14 | font-size: 16px; 15 | width: 100%; 16 | border: 1px solid #ddd; 17 | padding-top: 1em; 18 | padding-bottom: 1em; 19 | } 20 | 21 | .article a { 22 | color: #f97316; 23 | text-decoration: none; 24 | } 25 | 26 | .article ol, 27 | .article ul { 28 | padding-left: 2em; 29 | margin-bottom: 1rem; 30 | } 31 | 32 | .article ol li, 33 | .article ul li { 34 | padding: 3px 2px; 35 | } 36 | 37 | .article ol p, 38 | .article ul p { 39 | margin: 0; 40 | display: inline; 41 | } 42 | 43 | .article ol { 44 | list-style-type: none; 45 | counter-reset: item; 46 | } 47 | 48 | .article ol li { 49 | counter-increment: item; 50 | } 51 | 52 | .article ol li:before { 53 | content: counters(item, ".") ". "; 54 | } 55 | 56 | .article ol li:marker { 57 | display: none; 58 | } 59 | 60 | .article ul { 61 | list-style: disc; 62 | } 63 | 64 | .article ul ul { 65 | list-style: circle; 66 | } 67 | 68 | .article ul ul ul { 69 | list-style-type: square; 70 | } 71 | 72 | .article h1 { 73 | font-size: 2rem; 74 | font-weight: 800; 75 | margin-top: 1rem; 76 | margin-bottom: 1rem; 77 | } 78 | 79 | .article h2 { 80 | font-size: 1.5rem; 81 | font-weight: 700; 82 | margin-top: 1.8rem; 83 | margin-bottom: 0.8rem; 84 | } 85 | 86 | .article h3 { 87 | font-size: 1.2rem; 88 | font-weight: 600; 89 | margin-top: 1.5rem; 90 | margin-bottom: 0.5rem; 91 | } 92 | 93 | .article h4 { 94 | font-size: 1rem; 95 | font-weight: 600; 96 | margin-top: 1.5rem; 97 | margin-bottom: 0.5rem; 98 | } 99 | 100 | .article p { 101 | margin-bottom: 1rem; 102 | } 103 | 104 | .article img { 105 | max-width: 100%; 106 | margin: 1em auto; 107 | } 108 | 109 | .article { 110 | position: relative; 111 | } 112 | 113 | .article > .tags { 114 | display: flex; 115 | justify-content: start; 116 | flex-wrap: wrap; 117 | gap: 10px; 118 | width: 100%; 119 | } 120 | 121 | .article .tags > a { 122 | color: black; 123 | background-color: #eee; 124 | border-radius: 6px; 125 | padding: 2px 8px; 126 | font-size: 16px; 127 | font-weight: 500; 128 | } 129 | 130 | .article hr { 131 | margin-top: 1.5rem; 132 | margin-bottom: 1.5rem; 133 | } 134 | 135 | .article pre { 136 | display: grid; 137 | overflow: hidden; 138 | border-radius: 0.5rem; 139 | font-size: 16px; 140 | margin: 1.5em 0; 141 | } 142 | 143 | .article p > code, 144 | .article li > code { 145 | background-color: #444; 146 | border-radius: 6px; 147 | padding: 0.1em 0.25em; 148 | font-size: 18px; 149 | } 150 | 151 | table { 152 | font-size: 15px; 153 | border-collapse: collapse; 154 | width: 100%; 155 | } 156 | 157 | th, 158 | td { 159 | text-align: left; 160 | padding: 8px; 161 | border: 1px solid #ddd; 162 | } 163 | 164 | th { 165 | background-color: #f2f2f2; 166 | } 167 | 168 | tr:nth-child(even) { 169 | background-color: #f2f2f2; 170 | } 171 | 172 | blockquote { 173 | background: #f1f1f1; 174 | border-left: 10px solid #777; 175 | margin: 1.5em 10px; 176 | padding: 0.5em 10px; 177 | quotes: "\201C" "\201D" "\2018" "\2019"; 178 | } 179 | 180 | blockquote:before { 181 | color: #777; 182 | content: open-quote; 183 | font-size: 4em; 184 | line-height: 0.1em; 185 | margin-right: 0.25em; 186 | vertical-align: -0.4em; 187 | } 188 | 189 | blockquote p { 190 | display: inline; 191 | font-style: italic; 192 | } 193 | 194 | @media (max-width: 768px) { 195 | .article h1 { 196 | font-size: 1.6rem; 197 | } 198 | 199 | .article h2 { 200 | font-size: 1.3rem; 201 | } 202 | 203 | .article h3 { 204 | font-size: 1rem; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /app/blog/category/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import ArticleCard from '@/components/ArticleCard'; 2 | import Pagination from '@/components/Pagination'; 3 | import { type Metadata } from 'next'; 4 | import Link from 'next/link'; 5 | import { BlogClient } from 'seobot'; 6 | 7 | async function getPosts(slug: string, page: number) { 8 | const key = process.env.SEOBOT_API_KEY; 9 | if (!key) throw Error('SEOBOT_API_KEY enviroment variable must be set. You can use the DEMO key a8c58738-7b98-4597-b20a-0bb1c2fe5772 for testing - please set it in the root .env.local file'); 10 | 11 | const client = new BlogClient(key); 12 | return client.getCategoryArticles(slug, page, 10); 13 | } 14 | 15 | function deslugify(str: string) { 16 | return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); 17 | } 18 | 19 | export const fetchCache = 'force-no-store'; 20 | 21 | export async function generateMetadata({ params: { slug } }: { params: { slug: string } }): Promise { 22 | const title = `${deslugify(slug)} - DevHunt Blog`; 23 | return { 24 | title, 25 | metadataBase: new URL('https://devhunt.org'), 26 | alternates: { 27 | canonical: `/blog/category/${slug}`, 28 | }, 29 | openGraph: { 30 | type: 'article', 31 | title, 32 | // description: '', 33 | // images: [], 34 | url: `https://devhunt.org/blog/category/${slug}`, 35 | }, 36 | twitter: { 37 | title, 38 | // description: '', 39 | // card: 'summary_large_image', 40 | // images: [], 41 | }, 42 | }; 43 | } 44 | 45 | export default async function Category({ 46 | params: { slug }, 47 | searchParams: { page }, 48 | }: { 49 | params: { slug: string }; 50 | searchParams: { page: number }; 51 | }) { 52 | const pageNumber = Math.max((page || 0) - 1, 0); 53 | const { total, articles } = await getPosts(slug, pageNumber); 54 | const posts = articles || []; 55 | const lastPage = Math.ceil(total / 10); 56 | 57 | return ( 58 |
59 |
60 | Home 61 | 62 | 66 | 67 | Blog 68 |
69 |

Category: {slug}

70 |
    71 | {posts.map((article: any) => ( 72 | 73 | ))} 74 |
75 | {lastPage > 1 && } 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import ArticleCard from '@/components/ArticleCard'; 2 | import Pagination from '@/components/Pagination'; 3 | import { type Metadata } from 'next'; 4 | import { BlogClient } from 'seobot'; 5 | 6 | export async function generateMetadata(): Promise { 7 | const title = 'SeoBot Blog'; 8 | const description = 'Get the inside scoop on SeoBot - the AI-powered SEO solution for content creation, optimization, and automated traffic growth on Autopilot.'; 9 | return { 10 | title, 11 | description, 12 | metadataBase: new URL('https://seobotai.com'), 13 | alternates: { 14 | canonical: '/blog', 15 | }, 16 | openGraph: { 17 | type: 'website', 18 | title, 19 | description, 20 | // images: [], 21 | url: 'https://seobotai.com/blog', 22 | }, 23 | twitter: { 24 | title, 25 | description, 26 | // card: 'summary_large_image', 27 | // images: [], 28 | }, 29 | }; 30 | } 31 | 32 | async function getPosts(page: number) { 33 | const key = process.env.SEOBOT_API_KEY; 34 | if (!key) throw Error('SEOBOT_API_KEY enviroment variable must be set. You can use the DEMO key a8c58738-7b98-4597-b20a-0bb1c2fe5772 for testing - please set it in the root .env.local file'); 35 | 36 | const client = new BlogClient(key); 37 | return client.getArticles(page, 10); 38 | } 39 | 40 | export const fetchCache = 'force-no-store'; 41 | 42 | export default async function Blog({ searchParams: { page } }: { searchParams: { page: number } }) { 43 | const pageNumber = Math.max((page || 0) - 1, 0); 44 | const { total, articles } = await getPosts(pageNumber); 45 | const posts = articles || []; 46 | const lastPage = Math.ceil(total / 10); 47 | 48 | return ( 49 |
50 |

SeoBot Blog

51 |
    52 | {posts.map((article: any) => ( 53 | 54 | ))} 55 |
56 | {lastPage > 1 && } 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /app/blog/sitemap.xml/route.tsx: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://devhunt.org'; 2 | 3 | async function getSitemap() { 4 | const key = process.env.SEOBOT_API_KEY; 5 | if (!key) throw Error('SEOBOT_API_KEY enviroment variable must be set. You can use the DEMO key a8c58738-7b98-4597-b20a-0bb1c2fe5772 for testing - please set it in the root .env.local file.'); 6 | 7 | try { 8 | const res = await fetch(`https://app.seobotai.com/api/sitemap?key=${key}`, { cache: 'no-store' }); 9 | const result = await res.json(); 10 | return result?.data; 11 | } catch { 12 | return { articles: [], categories: [], tags: [] }; 13 | } 14 | } 15 | 16 | function toSitemapRecord(loc: string, updatedAt: string) { 17 | return `${new URL(loc, BASE_URL).toString()}${updatedAt}`; 18 | } 19 | 20 | type SitemapItem = { slug: string; lastmod: string }; 21 | 22 | async function generateSiteMap() { 23 | const blogSitemap = await getSitemap(); 24 | return ` 25 | 26 | 27 | https://devhunt.org/blog 28 | 29 | ${blogSitemap.articles.map((i: SitemapItem) => toSitemapRecord(`/blog/${i.slug}`, i.lastmod))} 30 | ${blogSitemap.categories.map((i: SitemapItem) => toSitemapRecord(`/blog/category/${i.slug}`, i.lastmod))} 31 | ${blogSitemap.tags.map((i: SitemapItem) => toSitemapRecord(`/blog/tag/${i.slug}`, i.lastmod))} 32 | 33 | `; 34 | } 35 | 36 | export async function GET() { 37 | const body = await generateSiteMap(); 38 | 39 | return new Response(body, { 40 | status: 200, 41 | headers: { 42 | 'Cache-control': 'public, s-maxage=86400, stale-while-revalidate', 43 | 'content-type': 'application/xml', 44 | }, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /app/blog/tag/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import ArticleCard from '@/components/ArticleCard'; 2 | import Pagination from '@/components/Pagination'; 3 | import { type Metadata } from 'next'; 4 | import Link from 'next/link'; 5 | import { BlogClient } from 'seobot'; 6 | 7 | async function getPosts(slug: string, page: number) { 8 | const key = process.env.SEOBOT_API_KEY; 9 | if (!key) throw Error('SEOBOT_API_KEY enviroment variable must be set. You can use the DEMO key a8c58738-7b98-4597-b20a-0bb1c2fe5772 for testing - please set it in the root .env.local file.'); 10 | 11 | const client = new BlogClient(key); 12 | return client.getTagArticles(slug, page, 10); 13 | } 14 | 15 | function deslugify(str: string) { 16 | return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); 17 | } 18 | 19 | export const fetchCache = 'force-no-store'; 20 | 21 | export async function generateMetadata({ params: { slug } }: { params: { slug: string } }): Promise { 22 | const title = `${deslugify(slug)} - DevHunt Blog`; 23 | return { 24 | title, 25 | metadataBase: new URL('https://devhunt.org'), 26 | alternates: { 27 | canonical: `/blog/tag/${slug}`, 28 | }, 29 | openGraph: { 30 | type: 'article', 31 | title, 32 | // description: '', 33 | // images: [], 34 | url: `https://devhunt.org/blog/tag/${slug}`, 35 | }, 36 | twitter: { 37 | title, 38 | // description: '', 39 | // card: 'summary_large_image', 40 | // images: [], 41 | }, 42 | }; 43 | } 44 | 45 | export default async function Tag({ 46 | params: { slug }, 47 | searchParams: { page }, 48 | }: { 49 | params: { slug: string }; 50 | searchParams: { page: number }; 51 | }) { 52 | const pageNumber = Math.max((page || 0) - 1, 0); 53 | const { total, articles } = await getPosts(slug, pageNumber); 54 | const posts = articles || []; 55 | const lastPage = Math.ceil(total / 10); 56 | 57 | return ( 58 |
59 |
60 | Home 61 | 62 | 66 | 67 | Blog 68 |
69 |

Tag: {slug}

70 |
    71 | {posts.map((article: any) => ( 72 | 73 | ))} 74 |
75 | {lastPage > 1 && } 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarsX-dev/seobot-nextjs-blog/e0ad589522d1d5059d90e5bcb571585af7caf6ce/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import type { Metadata } from 'next' 3 | import { Inter } from 'next/font/google' 4 | 5 | const inter = Inter({ subsets: ['latin'] }) 6 | 7 | export const metadata: Metadata = { 8 | title: 'Create Next App', 9 | description: 'Generated by create next app', 10 | } 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return ( 3 |
4 |
5 |

6 | Get started by editing  7 | app/blog/ route 8 |

9 |
10 | 11 |
12 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 44 | 45 | 46 |
47 | 48 | 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /components/ArticleCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | interface ArticleProps { 4 | article: any; 5 | }; 6 | 7 | const ArticleCard: React.FC = ({ article }) => { 8 | return ( 9 |
  • 13 |
    14 | 15 | Published{" "} 16 | {new Date( 17 | article.publishedAt || article.createdAt 18 | ).toLocaleDateString("en-US", { 19 | day: "numeric", 20 | month: "short", 21 | year: "numeric", 22 | })} 23 | 24 | {article.readingTime ? ( 25 | {` ⦁ ${article.readingTime}`} min read 26 | ) : null} 27 |
    28 | 32 | {article.headline} 33 | 34 |
    35 | {article.metaDescription} 36 |
    37 |
    38 |
    39 | {(article.tags || []).splice(0, 3).map((t: any, ix: number) => ( 40 | 45 | {t.title} 46 | 47 | ))} 48 |
    49 | 53 | Read More → 54 | 55 |
    56 |
  • 57 | ); 58 | }; 59 | 60 | export default ArticleCard; 61 | -------------------------------------------------------------------------------- /components/HighlightCode.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import hljs from "highlight.js"; 5 | import "highlight.js/styles/vs2015.min.css"; 6 | 7 | export default function HighlightCode() { 8 | useEffect(() => { 9 | hljs.highlightAll(); 10 | }, []); 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default async function NotFound() { 4 | return ( 5 |
    6 |
    7 |
    8 |

    404 Error

    9 |

    10 | Page not found 11 |

    12 |

    13 | Sorry, the page you are looking for could not be found or has been 14 | removed. 15 |

    16 | 20 | Go back 21 | 27 | 32 | 33 | 34 |
    35 |
    36 |
    37 | ); 38 | }; 39 | 40 | -------------------------------------------------------------------------------- /components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | interface PaginationProps { 2 | slug: string; 3 | pageNumber: number; 4 | lastPage: number; 5 | }; 6 | 7 | const Pagination: React.FC = ({ slug, pageNumber, lastPage }) => { 8 | return ( 9 | 28 | ); 29 | }; 30 | 31 | export default Pagination; 32 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'mars-images.imgix.net', 8 | port: '', 9 | pathname: '/**' 10 | } 11 | ] 12 | } 13 | } 14 | 15 | module.exports = nextConfig 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seobot-nextjs-blog", 3 | "version": "0.1.0", 4 | "private": false, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "highlight.js": "^11.9.0", 13 | "next": "13.5.4", 14 | "react": "^18", 15 | "react-dom": "^18", 16 | "seobot": "^1.3.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "autoprefixer": "^10", 23 | "eslint": "^8", 24 | "eslint-config-next": "13.5.4", 25 | "postcss": "^8", 26 | "tailwindcss": "^3", 27 | "typescript": "^5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 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 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /utils/cache.ts: -------------------------------------------------------------------------------- 1 | export class Cache { 2 | private cache: T | null = null; 3 | private expiresAt: number | null = null; 4 | 5 | constructor(private ttl: number = 60000) {} 6 | 7 | async get(fetchFunction: () => Promise): Promise { 8 | const now = Date.now(); 9 | 10 | if (this.cache && this.expiresAt && now < this.expiresAt) { 11 | return this.cache; 12 | } 13 | 14 | this.cache = await fetchFunction(); 15 | this.expiresAt = now + this.ttl; 16 | return this.cache; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utils/seoBot.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./cache"; 2 | 3 | export class SeoBotClient { 4 | private key: string; 5 | private fetchBaseCache: Cache = new Cache(60000); 6 | private fetchPostCache: Cache = new Cache(60000); 7 | 8 | constructor(key: string, postsPerPage: number = 10) { 9 | this.key = key; 10 | } 11 | 12 | private async fetchBase() { 13 | const base = await this.fetchBaseCache.get(async () => { 14 | const response = await fetch( 15 | `https://seobot-blogs.s3.eu-north-1.amazonaws.com/${this.key}/system/base.json`, 16 | { cache: "no-store" } 17 | ); 18 | const base = await response.json(); 19 | return base; 20 | }); 21 | 22 | return base; 23 | } 24 | 25 | private async fetchPost(id: string) { 26 | const post = await this.fetchPostCache.get(async () => { 27 | const postData = await fetch( 28 | `https://seobot-blogs.s3.eu-north-1.amazonaws.com/${this.key}/blog/${id}.json`, 29 | { cache: "no-store" } 30 | ); 31 | const post = await postData.json(); 32 | return post; 33 | }); 34 | 35 | return post; 36 | } 37 | 38 | async getPosts(page: number, limit: number = 10) { 39 | try { 40 | const base = await this.fetchBase(); 41 | const start = page * limit; 42 | const end = start + limit; 43 | const articles = await Promise.all(base.slice(start, end).map(async (item: any) => { 44 | if (item.id) return await this.fetchPost(item.id); 45 | })); 46 | 47 | return { 48 | articles: articles.filter((item) => item?.published), 49 | total: base.length, 50 | }; 51 | } catch { 52 | return { total: 0, articles: [] }; 53 | } 54 | } 55 | 56 | async getCategoryPosts(slug: string, page: number, limit: number = 10) { 57 | try { 58 | const base = await this.fetchBase(); 59 | const categoryIds = base.filter( 60 | (item: any) => item?.category?.slug == slug 61 | ); 62 | const start = page * limit; 63 | const end = start + limit; 64 | const articles = await Promise.all(categoryIds 65 | .slice(start, end) 66 | .map(async (item: any) => { 67 | if (item?.id) return await this.fetchPost(item?.id); 68 | })); 69 | 70 | return { 71 | articles: articles.filter((item) => item?.published), 72 | total: categoryIds.length, 73 | }; 74 | } catch { 75 | return { total: 0, articles: [] }; 76 | } 77 | } 78 | 79 | async getTagPosts(slug: string, page: number, limit: number = 10) { 80 | try { 81 | const base = await this.fetchBase(); 82 | const tags = base.filter((obj: any) => { 83 | const itemTags = obj?.tags; 84 | return itemTags?.some((item: any) => item?.slug === slug); 85 | }); 86 | const start = page * limit; 87 | const end = start + limit; 88 | const articles = await Promise.all(tags.slice(start, end).map(async (item: any) => { 89 | if (item?.id) return await this.fetchPost(item?.id); 90 | })); 91 | 92 | return { 93 | articles: articles.filter((item) => item?.published), 94 | total: tags.length, 95 | }; 96 | } catch { 97 | return { total: 0, articles: [] }; 98 | } 99 | } 100 | 101 | async getPost(slug: string) { 102 | try { 103 | const base = await this.fetchBase(); 104 | const id = base.find((item: any) => item.slug === slug).id; 105 | const post = await this.fetchPost(id); 106 | return post; 107 | } catch { 108 | return null; 109 | } 110 | } 111 | } 112 | --------------------------------------------------------------------------------