Criando um Blog com contador de visitas usando NextJS e MongoDB

Neste artigo vamos botar a mão na massa e iremos aprender a criar um Blog com NextJS, usando o MongoDB para gerenciar um contador de visitas em cada post e exibir no preview da home page. Utilizando a Fetch API para buscar os dados e o SWR para nos auxiliar nas revalidações dos mesmos. No final vamos hospedar em produção usando a Vercel.

Pré-requisitos#

Esse post vai ser um pouco mais complexo, fique a vontade para ler e seguir os passos. Se você tiver conhecimento prévio vai ficar mais tranquilo, também temos vários conteúdos no YouTube e no blog sobre os assuntos abaixo.

Introdução#

A arquitetura do NextJS é robusta, feita para criar aplicações em React. A API Routes permite criar API com NextJS, onde o NodeJS brilha ao rodar por baixo dos panos. Com isso podemos usar o MongoDB em uma aplicação usando um Front End NextJS.

Criação de blogs é um bom exemplo para o uso do NextJS. Ele tem seu próprio gerenciador de rotas, baseado em sistema de arquivos construído no conceito de páginas. Podemos criar rotas dinâmicas também.

Para implementar em nosso projeto vamos utilizar dois exemplos dos links abaixo:

No código de exemplo já tem um blog bem bacana feito com NextJS, TypeScript, usando Tailwind para estilização e Markdown para criação estática dos posts.

Vantagem dessa estrutura é que você não vai precisar de um Back-End para buscar os posts (API NodeJS, Prismic, Ghost, etc). A desvantagem é que a leitura de todos os posts de dentro do código vai ficar lenta.

Passo 1 - Baixando o projeto#

Comece baixando o projeto na sua workstation — pasta onde você deixa seus códigos:

npx create-next-app --example blog-starter-typescript blog-exemplo
// ou
yarn create next-app --example blog-starter-typescript blog-exemplo

Esse comando vai baixar o boilerplate do blog. Você pode dar o nome que quiser, basta trocar ali o blog-exemplo.

Passo 2 - Estrutura inicial do Blog#

O projeto do blog tem a seguinte estrutura:

blog-exemplo on  master took 3s
❯ tree -h
.
├── [ 96] @types
│ └── [ 29] remark-html.d.ts
├── [2.2K] README.md
├── [ 160] _posts
│ ├── [2.2K] dynamic-routing.md
│ ├── [2.2K] hello-world.md
│ └── [2.2K] preview.md
├── [ 640] components
│ ├── [1.2K] alert.tsx
│ ├── [ 322] avatar.tsx
│ ├── [ 253] container.tsx
│ ├── [ 639] cover-image.tsx
│ ├── [ 278] date-formatter.tsx
│ ├── [1.2K] footer.tsx
│ ├── [ 307] header.tsx
│ ├── [1.2K] hero-post.tsx
│ ├── [ 681] intro.tsx
│ ├── [ 407] layout.tsx
│ ├── [ 251] markdown-styles.module.css
│ ├── [1.2K] meta.tsx
│ ├── [ 783] more-stories.tsx
│ ├── [ 352] post-body.tsx
│ ├── [ 961] post-header.tsx
│ ├── [ 981] post-preview.tsx
│ ├── [ 333] post-title.tsx
│ └── [ 124] section-separator.tsx
├── [ 160] lib
│ ├── [1.1K] api.ts
│ ├── [ 327] constants.ts
│ └── [ 214] markdownToHtml.ts
├── [ 75] next-env.d.ts
├── [ 715] package.json
├── [ 192] pages
│ ├── [ 174] _app.tsx
│ ├── [ 290] _document.tsx
│ ├── [1.3K] index.tsx
│ └── [ 96] posts
│ └── [2.3K] [slug].tsx
├── [ 71] postcss.config.js
├── [ 128] public
│ ├── [ 96] assets
│ │ └── [ 192] blog
│ │ ├── [ 160] authors
│ │ │ ├── [6.0K] jj.jpeg
│ │ │ ├── [7.0K] joe.jpeg
│ │ │ └── [6.0K] tim.jpeg
│ │ ├── [ 96] dynamic-routing
│ │ │ └── [115K] cover.jpg
│ │ ├── [ 96] hello-world
│ │ │ └── [103K] cover.jpg
│ │ └── [ 96] preview
│ │ └── [ 43K] cover.jpg
│ └── [ 384] favicon
│ ├── [4.7K] android-chrome-192x192.png
│ ├── [ 14K] android-chrome-512x512.png
│ ├── [1.3K] apple-touch-icon.png
│ ├── [ 255] browserconfig.xml
│ ├── [ 595] favicon-16x16.png
│ ├── [ 880] favicon-32x32.png
│ ├── [ 15K] favicon.ico
│ ├── [3.5K] mstile-150x150.png
│ ├── [1.9K] safari-pinned-tab.svg
│ └── [ 392] site.webmanifest
├── [ 96] styles
│ └── [ 276] index.css
├── [ 692] tailwind.config.js
├── [ 484] tsconfig.json
├── [ 128] types
│ ├── [ 74] author.ts
│ └── [ 229] post.ts
└── [275K] yarn.lock
16 directories, 55 files

Passo 3 - Instalando as Dependências e inicializando o Projeto#

Execute yarn install só para garantir que a node_modules já está no projeto com as dependências instaladas.

Depois execute yarn dev para executar a aplicação e você terá o seguinte resultado:

Localhost:3000

Seguindo, vamos instalar as dependências necessárias. Basicamente são duas:

yarn add mongodb swr
yarn add @types/mongodb -D

Passo 4 - Configurando a conexão com Mongo Atlas#

Vamos configurar a conexão com o banco de dados no Atlas. Antes disso você precisa criar sua conta gratuita no serviço deles e criar um projeto — Projeto → Cluster → Collection.

Adicionar IP Access List Entry: 0.0.0.0/0 (pode colocar o endereço onde o app está executando — caso esteja hospedado):

E também precisa de um usuário e senha para se conectar ao banco de dados:

DataBase Access

Depois você vai precisar obter e copiar a string de conexão com mongoDB:

mongodb+srv://meu_usuario:<password>@cluster.kwhje.mongodb.net/<dbname>?retryWrites=true&w=majority

Para conseguir essa string, acesse: Clusters → Connect → Connect your application (segunda opção) → Copy. Pronto!

Esses dados são sensíveis, então vamos precisar configurar variáveis de ambiente para salva-los.

Na raiz do projeto crie o arquivo .env.local:

MONGODB_URI=mongodb+srv://meu_usuario:<password>@cluster.kwhje.mongodb.net/<dbname>?retryWrites=true&w=majority
MONGODB_DB=blog_post_page_view # aqui é o nome do banco de dados

Com banco de dados e string de conexão na variável de ambiente, podemos avançar e configurar o driver de conexão entre MongoDB ↔ NodeJS.

Passo 5 - Configurando o driver de conexão do MongoDB#

Na raiz do projeto crie uma pasta config e o arquivo mongodb.ts. Nele adicione esse código:

import { MongoClient } from 'mongodb'
let uri = process.env.MONGODB_URI || ""
let dbName = process.env.MONGODB_DB
let cachedClient: any = null
let cachedDb: any = null
if (!uri) {
throw new Error(
'Please define the MONGODB_URI environment variable inside .env.local'
)
}
if (!dbName) {
throw new Error(
'Please define the MONGODB_DB environment variable inside .env.local'
)
}
export async function connectToDatabase() {
if (cachedClient && cachedDb) {
return { client: cachedClient, db: cachedDb }
}
const client = await MongoClient.connect(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
const db = await client.db(dbName)
cachedClient = client
cachedDb = db
return { client, db }
}

Esse código lida com a conexão com o MongoDB, usando as variáveis de ambiente que definimos, e também gerencia o cache da conexão.

A cada requisição, ao invés de criar uma nova conexão, o que demanda memória e tempo de comunicação entre a aplicação e o mongodb, ela simplesmente retorna a conexão existente.

Passo 6 - Construindo a ponte entre o MongoDB e o NextJS — API Router#

Vamos criar agora a API NextJS que será chamada pelos componentes React e que irá realizar a busca de dados no MongoDB.

Os arquivos começados com _, como _app.tsx por exemplo, não são considerados rotas na aplicação.

No arquivo tsconfig.json configure os módulos, isso vai facilitar muito na hora de importar os arquivos:

{
"baseUrl": "./",
"paths": {
"@/*": ["./*"]
}
}

O seu arquivo deve ficar assim no final:

{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": "./",
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["node_modules"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

Dessa forma agora podemos importar um arquivo dessa maneira:

import {connectToDatabase} from '@/config/mongodb';

Ao invés dessa:

import {connectToDatabase} from '../../../config/mongodb';

Nossa API tem que estar na pasta pages dentro da pasta api. Crie a pasta api dentro da pasta pages e um arquivo page-views.ts com o seguinte código:

import {connectToDatabase} from '@/config/mongodb';
import { NextApiRequest, NextApiResponse } from 'next';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const slug = req.query.id;
if(!slug) return res.json("Página não encontrada!")
const { db, client } = await connectToDatabase();
if(client.isConnected()) {
const pageViewBySlug = await db
.collection("pageviews")
.findOne({ slug })
let total = 0;
if(pageViewBySlug) {
total = pageViewBySlug.total + 1;
await db.collection('pageviews').updateOne({ slug }, { $set: { total }})
} else {
total = 1;
await db.collection('pageviews').insertOne({ slug, total })
}
return res.status(200).json({ total })
}
return res.status(500).json({ error: 'client DB is not connected' })
}

Esse arquivo se parece muito com uma rota no server NodeJS com Express, que recebe requisição e envia resposta.

server.get("/users", (req, res) => {
const name = req.query.name;
return res.json({ message: `Hello ${name}` });
});

E é isso que ele faz, podemos ver ele expondo uma função que recebe requisições e devolve alguma resposta no retorno da função.

Essa função basicamente recebe um slug (título do blog) como parâmetro. Primeiro verifique se existe o slug no banco de dados. Se existir pegue o total e acrescente mais um e salve esse valor na coleção pageviews, onde o slug é igual ao slug informado. Se não existir, retorne um total igual a um, ou seja, primeira visita. Em seguida vai salvar o registro, a resposta vai ser o total de visitas que o post teve.

Essa rota da API page-views será chamada sempre que entrar no post do blog.

Mas esse blog tem um preview de posts, então vamos criar uma rota que vai servir só para página home do blog e exibir o total de visitas em cada post.

Crie o arquivo page-views-preview.ts na pasta api com o seguinte conteúdo:

import {connectToDatabase} from '@/config/mongodb';
import { NextApiRequest, NextApiResponse } from 'next';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const slug = req.query.id;
if(!slug) return res.json("Página não encontrada!")
const { db, client } = await connectToDatabase();
if(client.isConnected()) {
const pageViewBySlug = await db
.collection("pageviews")
.findOne({ slug })
let total = 0;
if(pageViewBySlug) {
total = pageViewBySlug.total;
}
return res.status(200).json({ total })
}
return res.status(500).json({ error: 'client DB is not connected' })
}

Este arquivo faz basicamente a mesma coisa que o código anterior, a diferença é que este retorna um total igual a zero se o post não teve visualizações.

Esses dois códigos estão sujeitos a muitas melhorias, quis deixar o mais simples possível para entender bem o fluxo.

Pronto! Temos o banco configurado e a API criada, agora falta pouco para finalizar.

Vamos implementar o acesso à API, ou seja, a parte que efetua as requisições na API.

Passo 7 - Configurando a busca de dados com SWR#

Agora vamos criar o fetcher buscador de dados avançado que será reutilizado nos components. vamos utilizar o SWR para gerenciar as buscas, cache e validação das consultas. Vamos usar também a Fetch API do JavaScript que efetivamente irá fazer as buscas na API quando o hook SWR solicitar.

Dentro da pasta lib que já existe no projeto, crie o arquivo fetcher.ts e adicione o código abaixo:

import useSWR from "swr";
export function useFetch(url: string, revalidateOnFocus: boolean = false) {
const { data, error } = useSWR(url, async (url) => {
const response = await fetch(url);
const data = await response.json();
return data;
}, {revalidateOnFocus });
return { data, error };
}

Toda chamada da nossa API será através desse nosso hook useFetch, onde encapsulamos a lógica da execução do useSWR e Fetch API.

Passo 8 - Personalizando o conteúdo estático do Blog#

No arquivo components/header.tsx altere o texto de Blog para Seu Blog.

No arquivo components/intro.tsx altere o texto e o CSS do primeiro <h1>:

<h1 className="text-4xl md:text-5xl font-bold tracking-tighter leading-tight md:pr-8">
Blog.
</h1>

Esse CSS inline é do Tailwind framework CSS para construir designs customizáveis rapidamente.

Passo 9 - Consumindo a API, adaptando e criando componentes#

No arquivo pages/posts/[slug].tsx vamos implementar a busca do total de page view (visitas na página).

No arquivo completo abaixo, observe como é feito a busca para dentro da própria API que construimos, passando o caminho: api/page-views e o parâmetro id={post.slug}, que vai formar uma requisição:

http://localhost:3000/api/page-views?id=hello-world

const { data } = useFetch(`/api/page-views?id=${post.slug}`);

O objeto data traz consigo a propriedade total que a API respondeu, e qual é passada para o componente PageHeader.

<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
views={data?.total}
/>

Dê uma analisada no arquivo completo e substitua por esse código:

import { useRouter } from "next/router";
import ErrorPage from "next/error";
import Container from "../../components/container";
import PostBody from "../../components/post-body";
import Header from "../../components/header";
import PostHeader from "../../components/post-header";
import Layout from "../../components/layout";
import { getPostBySlug, getAllPosts } from "../../lib/api";
import PostTitle from "../../components/post-title";
import Head from "next/head";
import { CMS_NAME } from "../../lib/constants";
import markdownToHtml from "../../lib/markdownToHtml";
import PostType from "../../types/post";
import { useFetch } from "@/lib/fetcher";
type Props = {
post: PostType;
morePosts: PostType[];
preview?: boolean;
};
const Post = ({ post, morePosts, preview }: Props) => {
const router = useRouter();
if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />;
}
const { data } = useFetch(`/api/page-views?id=${post.slug}`);
return (
<Layout preview={preview}>
<Container>
<Header />
{router.isFallback ? (
<PostTitle>Loading…</PostTitle>
) : (
<>
<article className="mb-32">
<Head>
<title>
{post.title} | Next.js Blog Example with {CMS_NAME}
</title>
<meta property="og:image" content={post.ogImage.url} />
</Head>
<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
views={data?.total}
/>
<PostBody content={post.content} />
</article>
</>
)}
</Container>
</Layout>
);
};
export default Post;
type Params = {
params: {
slug: string;
};
};
export async function getStaticProps({ params }: Params) {
const post = getPostBySlug(params.slug, [
"title",
"date",
"slug",
"author",
"content",
"ogImage",
"coverImage",
]);
const content = await markdownToHtml(post.content || "");
return {
props: {
post: {
...post,
content,
},
},
};
}
export async function getStaticPaths() {
const posts = getAllPosts(["slug"]);
return {
paths: posts.map((posts) => {
return {
params: {
slug: posts.slug,
},
};
}),
fallback: false,
};
}

Ainda não podemos ver o resultado em tela, pois precisamos adaptar o componente page-header.tsx para receber essa nova prop que passamos e outras coisas que veremos logo adiante.

Esse projeto está bem organizado, vamos manter este padrão. Dentro da pasta components, tem vários arquivos post-body.tsx, post-title.tsx, etc. Vamos criar o arquivo post-views.tsx que, além de ser o componente responsável pela exibição da quantidade de visualizações, também vai ser reaproveitado em outros componentes: hero-post.tsx, post-header.tsx e post-preview.tsx.

Crie esse arquivo com o seguinte componente post-views.tsx:

import { ReactNode } from "react";
type Props = {
children?: ReactNode;
};
const PostViews = ({ children }: Props) => {
return <small className="text-lg">{children}</small>;
};
export default PostViews;

Vamos utilizar esse componente em três lugares: hero-posts.tsx que é o post principal em destaque na home page; more-stories.tsx também na home onde aparece os restantes dos previews dos posts; e, por fim, no próprio post, dentro de post-header.tsx.

Para podermos ver o efeito em tela logo, vamos colocar no post-header.tsx primeiro:

Altere o arquivo com esse conteúdo post-header.tsx:

import Avatar from "./avatar";
import DateFormatter from "./date-formatter";
import CoverImage from "./cover-image";
import PostTitle from "./post-title";
import PostViews from "./post-views";
import Author from "../types/author";
type Props = {
title: string;
coverImage: string;
date: string;
author: Author;
views: number;
};
const PostHeader = ({ title, coverImage, date, author, views }: Props) => {
return (
<>
<PostTitle>{title}</PostTitle>
<div className="hidden md:block md:mb-12">
<Avatar name={author.name} picture={author.picture} />
</div>
<div className="mb-8 md:mb-16 sm:mx-0">
<CoverImage title={title} src={coverImage} />
</div>
<div className="max-w-2xl mx-auto">
<div className="block md:hidden mb-6">
<Avatar name={author.name} picture={author.picture} />
</div>
<div className="mb-6 text-lg">
<DateFormatter dateString={date} /> -{" "}
<PostViews>{`${views >= 0 ? views : "..."} views`}</PostViews>
</div>
</div>
</>
);
};
export default PostHeader;

Acessando a rota: http://localhost:3000/posts/dynamic-routing

Agora vamos mostrar na home a quantidade de views de cada post.

No arquivo components/hero-post.tsx altere o código:

import Avatar from "./avatar";
import DateFormatter from "./date-formatter";
import CoverImage from "./cover-image";
import Link from "next/link";
import Author from "../types/author";
import { useFetch } from "@/lib/fetcher";
import PostViews from "./post-views";
type Props = {
title: string;
coverImage: string;
date: string;
excerpt: string;
author: Author;
slug: string;
};
const HeroPost = ({
title,
coverImage,
date,
excerpt,
author,
slug,
}: Props) => {
const { data } = useFetch(`/api/page-views-preview?id=${slug}`, true);
const views = data?.total;
return (
<section>
<div className="mb-8 md:mb-16">
<CoverImage title={title} src={coverImage} slug={slug} />
</div>
<div className="md:grid md:grid-cols-2 md:col-gap-16 lg:col-gap-8 mb-20 md:mb-28">
<div>
<h3 className="mb-4 text-4xl lg:text-6xl leading-tight">
<Link as={`/posts/${slug}`} href="/posts/[slug]">
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="mb-4 md:mb-0 text-lg">
<DateFormatter dateString={date} /> -{" "}
<PostViews>{`${views >= 0 ? views : "..."} views`}</PostViews>
</div>
</div>
<div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
</div>
</div>
</section>
);
};
export default HeroPost;

Nesse arquivo podemos notar que existem duas diferenças principais: a rota que estamos requisitando e o parâmetro true sendo informado:

const { data } = useFetch(`/api/page-views-preview?id=${slug}`, true);

Além de estar passando a URL, que agora é page-views-preview, para acessar a outra rota da API, estou passando true como segundo parâmetro para que seja feita a revalidação dos dados quando a tela recebe um foco (clique do mouse na página home por exemplo).

Agora para finalizar com chave de ouro, vamos adicionar o contador no restante do preview da home.

No arquivo components/post-preview.tsx altere o arquivo:

import Avatar from "./avatar";
import DateFormatter from "./date-formatter";
import CoverImage from "./cover-image";
import Link from "next/link";
import Author from "../types/author";
import PostViews from "@/components/post-views";
import { useFetch } from "@/lib/fetcher";
type Props = {
title: string;
coverImage: string;
date: string;
excerpt: string;
author: Author;
slug: string;
};
const PostPreview = ({
title,
coverImage,
date,
excerpt,
author,
slug,
}: Props) => {
const { data } = useFetch(`/api/page-views-preview?id=${slug}`, true);
const views = data?.total;
return (
<div>
<div className="mb-5">
<CoverImage slug={slug} title={title} src={coverImage} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<Link as={`/posts/${slug}`} href="/posts/[slug]">
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="text-lg mb-4">
<DateFormatter dateString={date} /> -{" "}
<PostViews>{`${views >= 0 ? views : "..."} views`}</PostViews>
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
</div>
);
};
export default PostPreview;

Segue com a mesma implementação. Simplesmente fizemos o fetch do total de visitas por slug:

const { data } = useFetch(`/api/page-views-preview?id=${slug}`, true);

E também adicionamos o componente:

<PostViews>{`${views >= 0 ? views : "..."} views`}</PostViews>

Prontinho! agora podemos fazer o deploy do blog para produção.

Passo 10 - Subindo o projeto em produção na Vercel#

Primeiro, subir o código para o GitHub lembre de não enviar o arquivo .env.

Depois colocar o link do repositório no site da Vercel e configurar a variável de ambiente.

Segunda maneira é fazer o deploy via vercel cli.

No site da vercel tem instruções de como baixar o CLI.

Basta executar o comando:

npm i -g vercel

O pacote global vai ser instalado na máquina.

Crie sua conta na vercel. Use sua conta do GitHub para fazer o sign-up/sign-in.

Voltando para o terminal digite:

vercel login

Você vai precisar informar um e-mail, o mesmo que usou para criar a conta. Feito isso, basta clicar em confirmar quando receber.

blog-linuxupdate on  main took 14s
❯ vercel login
Vercel CLI 20.1.2
We sent an email to @gmail.com. Please follow the steps provided inside it and make sure the security code matches Bla Bla.
✔ Email confirmed
Congratulations! You are now logged in. In order to deploy something, run `vercel`.
💡 Connect your Git Repositories to deploy every branch push automatically (<https://vercel.link/git>).
blog-linuxupdate on  main took 40s

Para finalizar, basta digitar outro comando para fazer o deploy.

Antes recomendo gerar uma build na máquina local para ver se está tudo certo mesmo!

yarn build

Agora, para colocar em produção na Vercel, execute o comando:

vercel
❯ vercel
Vercel CLI 20.1.2
? Set up and deploy “~/Developer/blog-linuxupdate”? [Y/n] y
? Which scope do you want to deploy to? palamar
? Link to existing project? [y/N] n
? What’s your project’s name? blog-linuxupdate
? In which directory is your code located? ./
Auto-detected Project Settings (Next.js):
- Build Command: `npm run build` or `next build`
- Output Directory: Next.js default
- Development Command: next dev --port $PORT
? Want to override the settings? [y/N] n
🔗 Linked to tgmarinho/blog-linusupdate (created .vercel)
🔍 Inspect: <https://vercel.com/palamar/blog-linuxupdate/kd9113c6m> [1s]
✅ Production: <https://blog-linuxupdate.vercel.app> [copied to clipboard] [1m]
📝 Deployed to production. Run `vercel --prod` to overwrite later (<https://vercel.link/2F>).
💡 To change the domain or build command, go to <https://vercel.com/palamar/blog-linusupdate/settings>
blog-linuxupdate on  main took 2m 14s

Pronto, blog está em produção: https://blog-linuxupdate-palamar.vercel.app/

Mas as views ficam só com ..., não está funcionando!

https://vercel.com/<seu_user_vercel>/<seu_projeto>/settings/environment-variables

E insira a chave e o valor das suas variáveis que estão no .env do seu projeto.

Feito isso, agora vai funcionar!

Faça um novo deploy para que as variáveis sejam implantadas no projeto. No terminal da sua máquina digite:

vercel --prod

Conclusão#

O resultado ficou muito bacana! E agora você tem um template bacana de um blog que você pode personalizar e começar a criar seus conteúdos. Mas, se quiser, pode criar um blog totalmente do zero usando no NextJS, até porque esse já está com Tailwind configurado e talvez você curta outra ferramenta para estilização.

Com certeza depois desse artigo você deve ter ficado com vontade de estudar mais sobre o NextJS. E a minha dica que eu deixo não pare de estudar continue aprendendo sempre correndo atras dos seus objetivos.

Espero que tenha curtido. 💙