├── .eslintrc.json ├── .github ├── renovate.json └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── app ├── api │ └── og │ │ └── route.tsx ├── blog │ ├── [slug] │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── page │ │ └── [[...count]] │ │ │ └── page.tsx │ └── tags │ │ └── [[...tag]] │ │ └── page.tsx ├── layout.tsx ├── not-found.tsx ├── page.tsx ├── robots.ts └── sitemap.ts ├── components ├── blog-card.tsx ├── brand-icon.tsx ├── callout.tsx ├── dropdown-menu.tsx ├── footer.tsx ├── header.tsx ├── image.tsx ├── layout │ └── blog-page-layout.tsx ├── link.tsx ├── mdx │ ├── mdx-code-titles.tsx │ ├── mdx-components.tsx │ ├── mdx-content.tsx │ ├── mdx-copy-code-btn.tsx │ ├── mdx-image.tsx │ ├── mdx-link.tsx │ ├── mdx-pre-code.tsx │ └── mdx-table.tsx ├── post-paginator.tsx ├── post-tag.tsx ├── prose-layout.tsx ├── render-posts.tsx ├── scroll-button.tsx ├── theme-toggle.tsx └── toc.tsx ├── content └── blog │ └── lorem-ipsum.mdx ├── contentlayer.config.ts ├── contexts └── theme-provider.tsx ├── global.d.ts ├── lib ├── contentlayer.ts ├── fonts.ts ├── icons.tsx ├── mdx │ ├── contentlayer-extract-headings.ts │ └── remark-normalize-headings.ts ├── siteConfig.ts └── utils.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public ├── favicon.ico ├── fonts │ ├── Biotif-Bold.woff │ ├── Biotif-Bold.woff2 │ ├── Biotif-BoldItalic.woff2 │ ├── Biotif-Medium.woff2 │ ├── Biotif-MediumItalic.woff2 │ ├── Biotif-Regular.woff2 │ ├── Biotif-RegularItalic.woff2 │ └── NeuzeitGrotesk-Bold.woff2 └── images │ ├── announcement-banner.png │ ├── arrow-tr.svg │ └── react.jpeg ├── schema └── contentlayer │ └── blog-post.ts ├── styles ├── config.css ├── global.css ├── markdown.css └── tailwind.css ├── tailwind.config.js └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "npm:unpublishSafe", 6 | ":preserveSemverRanges" 7 | ], 8 | "labels": ["dependencies"] 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | - "demo" 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup pnpm 18 | uses: pnpm/action-setup@v2 19 | with: 20 | version: 8 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 18 26 | cache: "pnpm" 27 | 28 | - name: Install deps 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Run Lint 32 | run: pnpm lint 33 | 34 | typecheck: 35 | name: Typecheck 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | 41 | - name: Setup pnpm 42 | uses: pnpm/action-setup@v2 43 | with: 44 | version: 8 45 | 46 | - name: Setup Node 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: 18 50 | cache: "pnpm" 51 | 52 | - name: Install deps 53 | run: pnpm install --frozen-lockfile 54 | 55 | # needs contentlayer to generate types before we typecheck 56 | - name: Run Contentlayer build 57 | run: pnpm build:contentlayer 58 | 59 | - name: Run typecheck 60 | run: pnpm typecheck 61 | 62 | build: 63 | name: Build 64 | needs: [lint, typecheck] 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | 70 | - name: Setup pnpm 71 | uses: pnpm/action-setup@v2 72 | with: 73 | version: 8 74 | 75 | - name: Setup Node 76 | uses: actions/setup-node@v4 77 | with: 78 | node-version: 18 79 | cache: "pnpm" 80 | 81 | - name: Install deps 82 | run: pnpm install --frozen-lockfile 83 | 84 | - name: Run Build 85 | run: pnpm build 86 | -------------------------------------------------------------------------------- /.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 | /.contentlayer/ 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.next 2 | /*-lock.* 3 | /.contentlayer/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Saurabh Charde 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 |
2 | 3 |

Next.js Tailwind Contentlayer Blog

4 | 5 | 6 | 7 |
8 |

A simple and minimalistic blog template built using the latest Next.js app router and Contentlayer.

9 |
10 | 11 | ## Motive 12 | 13 | I created this blog template as a learning experience (and to get my hands dirty) with [Next.js' new shiny app router](https://nextjs.org/docs/app). I also wanted few specific reusable functionalities for my personal portfolio blog that I can also easily copy-paste to my other projects. Developing a generic template seemed like a better way of achieving that. 14 | 15 | ## Features 16 | 17 | - **Mobile-First Approach**: Built with a mobile-first mindset, the template ensures your blog looks fantastic on any device. 18 | 19 | - **Highly Customizable**: Flexibility is key! The template offers extensive customizability, allowing you to tailor it to your unique style and preferences. 20 | 21 | - **CSS Variables for Easier Customizations**: Making changes to your blog's appearance becomes a breeze with the help of CSS variables. 22 | 23 | - **Keyboard Accessibility**: Ensuring an inclusive experience, the template is keyboard accessible. 24 | 25 | - **Light and Dark Mode Support**: Embrace the latest design trends by offering both light and dark mode options. 26 | 27 | - **Full Type-Safety**: Rest assured that your codebase will be free of type-related headaches. 28 | 29 | - **JSX in Markdown Support**: Unleash the power of JSX right within your Markdown content. 30 | 31 | - **Near Perfect Lighthouse Scores**: Your blog will be optimized for blazing-fast performance and excellent user experience. 32 | 33 | - **VS Code-like Code Highlighting**: Code snippets are displayed beautifully with line numbers, line highlighting, words highlighting, and inline code highlighting, powered by [Rehype Pretty Code](https://rehype-pretty-code.netlify.app/). 34 | 35 | - **Github-Flavored Markdown Support**: Write your content in familiar Markdown, including GitHub-flavored goodness. 36 | 37 | - **Separate Pages for Tags**: Easily categorize your posts with separate tag pages. 38 | 39 | - **Out-of-the-Box SEO Support**: Ensure your blog ranks well on search engines with built-in SEO features. 40 | 41 | - **Dynamic Open-Graph (OG) Image Generation**: Each post will have a sleek OG image automatically generated. 42 | 43 | - **Automatic Image Optimizations**: The template takes care of image optimizations using `next/image` for optimal performance. 44 | 45 | - **Bleeding Edge Technologies and Best Practices**: Stay ahead of the curve with the latest technologies and industry best practices (I hope so). 46 | 47 | ## Features Not Implemented (yet) 48 | 49 | Although packed with awesome features, there are some ideas on the horizon that I'm eager to implement in future updates: 50 | 51 | - **Views Counter / Analytics**: Keep track of your blog's popularity and engagement. 52 | 53 | - **Cloudinary Support**: Easily manage and optimize your images with Cloudinary integration. 54 | 55 | - **Comments**: Foster discussions and interactions by adding comment functionality. 56 | 57 | - **Search Blog Posts / Tags**: Help users find specific content with a powerful search feature. 58 | 59 | - **Mermaid Support**: Visualize data and concepts with the help of Mermaid charts. 60 | 61 | - **Author Support**: Properly attribute authors to each post. 62 | 63 | - **Custom Layout**: Create a unique layout for individual posts. 64 | 65 | ## Theme 66 | 67 | The template comes with a default theme based on **Stone** color palette from Tailwind CSS. But don't worry, you have the freedom to create your own theme by customising and tweaking `config.css`. 68 | 69 | ## Getting Started 70 | 71 | 1. Clone the starter template: 72 | 73 | ```bash 74 | git clone https://github.com/schardev/nextjs-contentlayer-blog 75 | 76 | # or using `gh` 77 | gh repo clone schardev/nextjs-contentlayer-blog 78 | ``` 79 | 80 | 2. Add site information and relevant data to `lib/siteConfig.ts`. 81 | 3. Add your blog posts to `content/blog` directory with proper front-matter (see available fields [here](https://github.com/schardev/nextjs-contentlayer-blog/blob/main/schema/contentlayer/blog-post.ts)) 82 | 4. Build your blog with: 83 | 84 | ```bash 85 | pnpm build 86 | ``` 87 | 88 | 5. Deploy to your hosting provider 🎉 89 | 90 | Starting a development server isn't different either, just run: 91 | 92 | ```bash 93 | pnpm dev 94 | ``` 95 | 96 | Now open `http://localhost:3000` to view changes to your blog as it happens. 97 | 98 | ## Directory and File Structure 99 | 100 | | Directory/File | Notes | 101 | | ------------------------ | --------------------------------------------------------------------------------------------------------------- | 102 | | `app/` | Defines your site/blog's routes | 103 | | `components/` | All react component code lives here | 104 | | `content/` | Directory where your MDX or Markdown file lives | 105 | | `public/` | Static assets goes here (e.g., put all your images inside `public/images` and fonts inside `public/fonts` etc) | 106 | | `styles/` | Find all styling files here | 107 | | `schema/` | Contains schema-related files | 108 | | `contentlayer.config.ts` | [Contentlayer](https://www.contentlayer.dev/) configuration file (you can change your `content` directory here) | 109 | | `lib/siteConfig.ts` | Holds config related to the site itself | 110 | 111 | ## Additional notes 112 | 113 | - If you have worked with [`next/image`](https://nextjs.org/docs/app/api-reference/components/image) before you know that working with external images is a pain in the ass, as you have to manually inspect and pass width and height properties to the `` component. Sure you can use `fill` property but then you have to have a fixed aspect ratio and/or deal with z-indexes. However, working with external images is now a breeze. Use the custom `` component from the `components/` directory that takes care of all of it. No more manual width and height hassles, as image optimizations are automatically handled. 114 | 115 | - Additionally, code blocks support everything [`rehype-pretty-code`](https://rehype-pretty-code.netlify.app/) supports. You even get the bonus of file type icons! You can also extend or customize the default file type icons from [here](https://github.com/schardev/nextjs-contentlayer-blog/blob/main/components/brand-icon.tsx). 116 | 117 | - As mentioned already, styling is mostly controlled via CSS variables so it's pretty easy to tweak and configure. See [`config.css`](https://github.com/schardev/nextjs-contentlayer-blog/blob/main/styles/config.css). 118 | 119 | > **Note**: 120 | > 121 | > Image optimisations using custom `` component only works if your blog is statically generated. **Do not use it as a client or server component.** 122 | 123 | If you encounter any issues or have any feature requests, feel free to report them in the [main repository](https://github.com/schardev/nextjs-contentlayer-blog/tree/main). 124 | 125 | ## Inspirations 126 | 127 | - [Tailwind Nextjs Starter Blog](https://github.com/timlrx/tailwind-nextjs-starter-blog) 128 | - [Stackoverflow Blog](https://stackoverflow.blog/) 129 | - [Material UI Blog](https://mui.com/blog/) 130 | - [Web Bulletin](https://web-bulletin.vercel.app/) 131 | 132 | ## Licence 133 | 134 | MIT © [Saurabh Charde](https://schar.dev) 135 | 136 | Feel free to use this in whatever projects you like. Make sure to star the [repo](https://github.com/schardev/nextjs-contentlayer-blog) if you like it. 137 | -------------------------------------------------------------------------------- /app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import config from "@/lib/siteConfig"; 2 | import { ImageResponse } from "next/og"; 3 | 4 | export const runtime = "edge"; 5 | 6 | const fontBold = fetch( 7 | new URL("../../../public/fonts/Biotif-Bold.woff", import.meta.url), 8 | ).then((res) => res.arrayBuffer()); 9 | 10 | export async function GET(request: Request) { 11 | const fontData = await fontBold; 12 | const url = new URL(request.url); 13 | const title = url.searchParams.get("title"); 14 | const date = url.searchParams.get("date"); 15 | 16 | return new ImageResponse( 17 | ( 18 |
30 |
39 |

53 | {config.title} 54 |

55 |

62 | {date} 63 |

64 |

74 | {title} 75 |

76 |
77 | ), 78 | { 79 | width: 1200, 80 | height: 600, 81 | fonts: [ 82 | { 83 | name: "Biotif", 84 | data: fontData, 85 | style: "normal", 86 | }, 87 | ], 88 | }, 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "@/components/image"; 2 | import Link from "@/components/link"; 3 | import MDXContent from "@/components/mdx/mdx-content"; 4 | import PostTag from "@/components/post-tag"; 5 | import ProseLayout from "@/components/prose-layout"; 6 | import ScrollButton from "@/components/scroll-button"; 7 | import TocDesktop, { TocMobile } from "@/components/toc"; 8 | import { allSortedBlogs } from "@/lib/contentlayer"; 9 | import { 10 | cn, 11 | formatDate, 12 | generateCommonMeta, 13 | isArrayNotEmpty, 14 | slugify, 15 | } from "@/lib/utils"; 16 | import { Calendar, NavArrowLeft } from "iconoir-react"; 17 | import { Metadata } from "next"; 18 | import { notFound } from "next/navigation"; 19 | 20 | export const generateStaticParams = () => { 21 | return allSortedBlogs.map((post) => { 22 | return { 23 | slug: post.slug, 24 | }; 25 | }); 26 | }; 27 | 28 | export const generateMetadata = ({ 29 | params, 30 | }: { 31 | params: { slug: string }; 32 | }): Metadata => { 33 | const post = allSortedBlogs.find((post) => post.slug === params.slug); 34 | if (!post) notFound(); 35 | 36 | const { title, description, date } = post; 37 | const imgParams = new URLSearchParams({ title, date: formatDate(date) }); 38 | const image = post.image ?? `/api/og?${imgParams.toString()}`; 39 | return generateCommonMeta({ title, description, image }); 40 | }; 41 | 42 | const Page = ({ params }: { params: { slug: string } }) => { 43 | const post = allSortedBlogs.find((post) => post.slug === params.slug); 44 | if (!post) notFound(); 45 | 46 | const moreThanOneHeading = post.headings && post.headings.length > 1; 47 | 48 | return ( 49 |
53 | 61 | 62 | Back to blog 63 | 64 |
*]:flex-1", 69 | )}> 70 | 71 |
*]:flex [&>*]:gap-2", 76 | )}> 77 | 81 |
82 |

{post.title}

83 |

{post.description}

84 | {isArrayNotEmpty(post.tags) && ( 85 |
86 | {post.tags.map((tag) => ( 87 | 88 | {tag} 89 | 90 | ))} 91 |
92 | )} 93 |
94 | {post.image && ( 95 |
96 | {post.title} 103 |
104 | )} 105 |
106 |
111 | {moreThanOneHeading && ( 112 | 118 | )} 119 | 124 | {moreThanOneHeading && ( 125 | 126 | )} 127 | 128 | 129 | 130 |
131 |
132 | ); 133 | }; 134 | 135 | export default Page; 136 | -------------------------------------------------------------------------------- /app/blog/layout.tsx: -------------------------------------------------------------------------------- 1 | const BlogLayout = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 | {children} 5 |
6 | ); 7 | }; 8 | 9 | export default BlogLayout; 10 | -------------------------------------------------------------------------------- /app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import BlogCard from "@/components/blog-card"; 2 | import BlogPageLayout from "@/components/layout/blog-page-layout"; 3 | import Link from "@/components/link"; 4 | import PostPaginator from "@/components/post-paginator"; 5 | import RenderPosts from "@/components/render-posts"; 6 | import { allSortedBlogs } from "@/lib/contentlayer"; 7 | import config from "@/lib/siteConfig"; 8 | import { cn, generateCommonMeta } from "@/lib/utils"; 9 | import { Metadata } from "next"; 10 | 11 | export const metadata: Metadata = generateCommonMeta({ 12 | title: "Blog", 13 | description: "Latest blog posts", 14 | image: "/api/og", 15 | }); 16 | 17 | const Page = () => { 18 | if (!allSortedBlogs.length) 19 | return ( 20 |
21 |

No posts found!

22 |

23 | See how to get started{" "} 24 | 28 | here 29 | 30 |

31 |
32 | ); 33 | 34 | const blogs = [...allSortedBlogs]; 35 | const recentBlogs = blogs.splice(0, 4); 36 | const latestPost = recentBlogs.shift(); 37 | const allPostsCount = config.blog.postPerPage - 4; 38 | 39 | return ( 40 |
41 | 42 | {latestPost && ( 43 | = 3 && [ 55 | "lg:grid gap-8 grid-cols-2 lg:col-span-full", 56 | "lg:[&>img]:mb-0 lg:text-lg lg:[&_h3]:text-2xl lg:[&_h3+p]:mt-[1em]", 57 | ], 58 | )} 59 | priority 60 | /> 61 | )} 62 | 63 | 64 | 65 |
66 | 67 | {blogs.length > 0 && ( 68 | 69 | 70 | 71 | )} 72 |
73 | ); 74 | }; 75 | 76 | export default Page; 77 | -------------------------------------------------------------------------------- /app/blog/page/[[...count]]/page.tsx: -------------------------------------------------------------------------------- 1 | import BlogPageLayout from "@/components/layout/blog-page-layout"; 2 | import PostPaginator from "@/components/post-paginator"; 3 | import config from "@/lib/siteConfig"; 4 | import { allSortedBlogs } from "@/lib/contentlayer"; 5 | import { notFound } from "next/navigation"; 6 | 7 | const totalPages = Math.ceil(allSortedBlogs.length / config.blog.postPerPage); 8 | 9 | export const generateStaticParams = () => { 10 | const params = Array.from({ length: totalPages }).map((_, idx) => ({ 11 | count: [`${idx + 1}`], 12 | })); 13 | return params; 14 | }; 15 | 16 | const Page = ({ params }: { params: { count?: string[] } }) => { 17 | const count = params.count ? +params.count : 1; 18 | if (!Number.isInteger(count) || count > totalPages) return notFound(); 19 | 20 | return ( 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default Page; 28 | -------------------------------------------------------------------------------- /app/blog/tags/[[...tag]]/page.tsx: -------------------------------------------------------------------------------- 1 | import BlogPageLayout from "@/components/layout/blog-page-layout"; 2 | import PostPaginator from "@/components/post-paginator"; 3 | import config from "@/lib/siteConfig"; 4 | import { allSortedBlogs } from "@/lib/contentlayer"; 5 | import { isArrayNotEmpty, nonNullable, slugify } from "@/lib/utils"; 6 | import { notFound } from "next/navigation"; 7 | 8 | export const generateStaticParams = () => { 9 | const allTags = allSortedBlogs 10 | .flatMap((post) => post.tags) 11 | .filter(nonNullable) 12 | .map((t) => slugify(t)); 13 | return allTags.map((t) => ({ tag: [t] })); 14 | }; 15 | 16 | const Page = ({ params }: { params: { tag?: string[] } }) => { 17 | if (!isArrayNotEmpty(params.tag)) notFound(); 18 | 19 | const tag = params.tag[0]; 20 | const posts = allSortedBlogs.filter( 21 | (post) => post.tags && post.tags.map((tag) => slugify(tag)).includes(tag), 22 | ); 23 | 24 | return ( 25 | 28 | Showing posts with tag:{" "} 29 | 30 | {tag} 31 | 32 | 33 | }> 34 | 40 | 41 | ); 42 | }; 43 | 44 | export default Page; 45 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/footer"; 2 | import Header from "@/components/header"; 3 | import { fontSans } from "@/lib/fonts"; 4 | import config from "@/lib/siteConfig"; 5 | import { cn } from "@/lib/utils"; 6 | import "@/styles/global.css"; 7 | import { Metadata } from "next"; 8 | 9 | export const metadata: Metadata = { 10 | title: { 11 | default: config.title, 12 | template: `%s | ${config.title}`, 13 | }, 14 | description: config.description, 15 | authors: { name: config.author, url: config.socials.site }, 16 | icons: { 17 | icon: [ 18 | { url: "/favicon.ico", sizes: "any" }, 19 | // { url: "/icon.svg", type: "image/svg+xml" }, 20 | ], 21 | // apple: "/apple-touch-icon.png", 22 | }, 23 | metadataBase: new URL(config.url), 24 | openGraph: { 25 | type: "website", 26 | title: { 27 | default: config.title, 28 | template: `%s | ${config.title}`, 29 | }, 30 | description: config.description, 31 | siteName: config.title, 32 | url: config.url, 33 | images: [config.siteImage], 34 | }, 35 | robots: { 36 | index: true, 37 | follow: true, 38 | googleBot: { 39 | index: true, 40 | follow: true, 41 | "max-image-preview": "large", 42 | "max-snippet": -1, 43 | }, 44 | }, 45 | twitter: { 46 | card: "summary_large_image", 47 | creator: `${config.socials.twitter.replace("https://twitter.com/", "@")}`, 48 | }, 49 | }; 50 | 51 | export default function RootLayout({ 52 | children, 53 | }: { 54 | children: React.ReactNode; 55 | }) { 56 | return ( 57 | 58 | 64 |
65 | {children} 66 |