Uma rede social para Devs. Projeto desenvolvido em Next.js versão 14. Uma lista de posts é exibida, sendo possível clicar em um post para ver o conteúdo completo. Há também um mecanismo de busca por título de um post. Além disso, é possível "curtir" um post e adicionar comentários.
🪧 Vitrine.Dev | |
---|---|
✨ Nome | Code Connect |
🏷️ Tecnologias | Next, Prisma |
🚀 URL | https://code-connect-eosin.vercel.app |
🔥 Curso | https://www.alura.com.br/formacao-next-js-14-aplicacoes-robustas-alta-produtividade |
Este projeto é resultado dos cursos da Alura para a formação Next.js 14: desenvolvendo aplicações robustas com alta produtividade, ministrados pela Patrícia Silva e pelo Vinicios Neves.
Este é meu primeiro projeto FullStack trabalhando com Next. Ele é o resultado dos cursos introdutórios em que eu aprendi Next e tecnologias associadas.
Atualmente, o Code Connect consiste em duas telas: Home e Post.
A tela Home é a página inicial, que exibe todos os posts cadastrados na base de dados, utilizando de paginação para exibir somente 4 posts por página, como pode ser visto na captura de tela abaixo:
Quando a pessoal clica em um post, é redirecionada para a tela de Post, exibe o conteúdo da postagem, incluindo uma imagem, título e conteúdo do post. Há também uma seção para exibir código em formato markdown - isso é feito com a biblioteca remark e você pode ver mais detalhes sobre ela nessa Seção. Segue uma captura de tela de exibição de um post:
Além das duas telas, há uma funcionalidade de busca por post, por meio de uma barra de pesquisa que fica no topo das telas. Ao digitar algum termo, a tela exibe posts que tenham o conteúdo da busca no título.
O projeto está em evolução, com novas tecnologias e soluções sendo adicionadas a cada atualização. Para isso, cada evolução se encontra em branches separadas.
A branch main contém a versão inicial do projeto, que utiliza um Back End mockado pelo JSON Server.
A branch postgres_prisma fornece uma solução FullStack completa, com Front e Back End. Nela, criamos um contêiner no Docker para subir um banco Postgres, e utilizamos o Prisma para popular o banco e fazer as consultas. Por fim, é feito o deploy na Vercel, e o projeto pode ser visto neste link. Nessa branch também estão inclusas as mecânicas para curtir um post e também adicionar comentários e respostas a comentários.
Informações sobre cada tecnologia utilizada podem ser vistas na Seção Detalhes Técnicos.
O projeto foi desenvolvido em Next.js utlizando o create-next-app
.
Após cloná-lo ou baixá-lo, abra um terminal, navegue até a pasta do projeto e rode o comando abaixo para instalar as dependências necessárias.
npm install
Feito isso, o app pode ser iniciado em modo de desenvolvimento com o seguinte comando:
npm run dev
O app irá rodar na URL http://localhost:3000.
Detalhes sobre como fazer o deploy na Vercel podem ser vistos nesta Seção.
A versão inicial que se encontra na branch main.
A primeira versão do projeto utiliza um back end mockado por meio do JSON Server.
Para poder rodá-lo localmente, crie uma pasta fora da pasta da aplicação e salve nela este arquivo JSON. O arquivo servirá como o banco de dados de postagens a serem exibidas na aplicação.
Na linha de comando, navegue até a pasta criada e faça a instalação local do JSON Server na versão específica:
npm i [email protected]
O comando abaixo irá rodar o servidor com o arquivo baixado e na porta 3042 (você pode escolher a porta que quiser):
npx json-server posts.json -p 3042
Você pode consultar a resposta da API na URL: http://localhost:3042/posts. Ela irá retornar um array de objetos.
O JSON Server oferece uma solução de paginação utilizando dois parâmetros (a query string da URL): _page
e _per_page
. Exemplo:
http://localhost:3042/posts?_page=2&_per_page=2
Quando se trabalha com paginação, a resposta da API é diferente, trazendo um objeto ao invés de um array de objetos post.
{
"first": 1,
"prev": 1,
"next": 2,
"last": 6,
"pages": 6,
"items": 12,
"data": [{}]
}
Agora os dados de post estão na propriedade data
, com os items baseados no que foi passado em page
e per_page
.
As propriedades prev
e next
podem nos ajudar a navegar pelas páginas e obter os dados correspondentes.
Exemplo de código utilizando paginação para exibir os dados:
export default async function Home({ searchParams }) {
const currentPage = searchParams?.page || 1;
const { data: posts, prev, next } = await getAllPosts(currentPage);
return (
<main className={styles.posts}>
{posts.map(post => <CardPost post={post} key={post.id} />)}
{prev && <Link href={`/?page=${prev}`}>Página anterior</Link>}
{next && <Link href={`/?page=${next}`}>Próxima página</Link>}
</main>
);
}
A prop searchParams
é fornecida pelo Next para acessarmos os parâmetros contidos na query string da URL da página. O acesso é feito como se fosse um objeto.
O "winston" é uma biblioteca especializada em criar diferentes tipos de logs para uma aplicação.
O repositório do projeto no GitHub possui exemplos e a documentação.
Criando um logger:
import { createLogger, format, transports } from 'winston';
const logger = createLogger({
level: 'info',
format: format.json(),
transports: [
//
// - Write all logs with importance level of `error` or less to `error.log`
// - Write all logs with importance level of `info` or less to `combined.log`
//
new transports.File({ filename: 'error.log', level: 'error' }),
new transports.File({ filename: 'combined.log' }),
],
});
export default logger;
O winston trabalha com níveis de log: error, warn, info, http, verbose, debug, silly. O nível "error" é o mais severo e importante (valor 0) e o "silly" é o menos importante (valor 6). Quando informado o nível no createLogger
(level
), ele só irá criar logs daquele nível para baixo.
A propriedade transports
são os arquivos que serão usados para registrar os logs. Quando informado o level
, o transport correspondente irá registrar somente os logs daquele nível para baixo.
Você precisa criar os arquivos em que os logs serão gravados. Eles devem ser criados na raiz do projeto com o nome que você definiu para cada um no código.
Exemplo de uso:
import logger from "@/logger";
const getAllPosts = async () => {
const resp = await fetch('http://localhost:3042/posts');
if (!resp.ok) {
// using winston for logging
logger.error('Função getAllPosts --> erro ao obter as postagens da API');
return [];
}
logger.info('Função getAllPosts --> posts obtidos com sucesso');
return resp.json();
}
O Code Connect exibe postagens de tecnologia e, dentre o conteúdo em cada postagem, há uma seção que exibe códigos. Esses códigos são escritos em formato markdown.
O remark
é uma biblioteca sugerida pelo Next para renderizar conteúdo markdown. Ele possui um plugin remark-html
para conversão do conteúdo markdown para HTML.
Instalação:
npm i remark remark-html
Exemplo de uso convertendo um conteúdo markdown para HTML:
// -- app/posts/[slug]/page.js
import { remark } from "remark";
import html from "remark-html";
const markdownToHtml = async (data) => {
const processedContent = await remark()
.use(html) // html plugin for remark
.process(data) // markdown data
return processedContent.toString();
}
const getPostBySlug = async (slug) => {
// code omitted
// assume data is an array of objects
// retrieved from the API
const post = data[0];
post.markdown = await markdownToHtml(post.markdown);
return post;
}
const PagePost = async ({ params }) => {
const post = await getPostBySlug(params.slug);
return (
<div dangerouslySetInnerHTML={{ __html: post.markdown }} />
);
}
export default PagePost;
A versão FullStack se encontra na branch postgres_prisma.
A segunda versão do projeto utiliza o Docker Compose para subir um banco de dados Postgres (versão 15). Para interação com este banco por meio do Next, utilizamos o ORM Prisma.
A configuração para subir o contêiner com o serviço do Postgres está no arquivo docker-compose.yaml
. Utilizamos o comando docker compose up -d
para baixar os arquivos necessários e criar o contêiner.
No caso de reiniciar o contêiner (por exemplo, se a máquina foi reiniciada e o Docker Desktop também tenha sido reiniciado), você poder usar o comando docker compose start
.
O Prisma é um ORM (Object Relational Mapper). Isso significa que ele atua, no caso do projeto, como um intermediador entre as linguagens SQL e JavaScript (ele também trabalha com outras linguagens). Assim, podemos focar nas estruturas e códigos no Next, criando tabelas e consultas utilizando objetos em JS, e deixar que o Prisma se responsabilize por se comunicar com o banco de dados e "traduzir" em SQL aquilo que queremos.
-
Para adicionar o Prisma ao projeto, usamos o comando
npm i prisma
. -
Para criar os arquivos iniciais para utilização do Prisma, o comando é
npx prisma init
. Caso ele ainda não esteja instalado na máquina, este comando também irá fazer a instalação.
Iniciado o Prisma, uma pasta prisma
será criada na raiz do projeto com um arquivo schema.prisma
. Neste arquivo definimos qual SGBD será utilizado e também criamos os objetos que representarão as tabelas e seus relacionamentos (daí o nome Object Relational Mapper).
Também será criado um arquivo .env
, onde são definidas variáveis de ambiente. O Prisma irá consultar esse arquivo para obter as credenciais de conexão ao banco.
O arquivo
.env
contém dados sensíveis de acesso ao projeto, então não o versione nem o compartilhe em ambiente de produção.
Supondo que vamos criar o banco do zero, definimos as tabelas e relacionamentos no arquivo schema.prisma
e rodamos o comando abaixo para efetuar a chamada "migração" (migration). Esta é a ação que irá criar de fato o banco de dados e suas tabelas no Postgres.
npx prisma migrate dev --name init
-
dev
indica que estamos em um ambiente de desenvolvimento; -
--name init
é a forma de darmos um nome a essa migração, de modo a facilitar identificá-la quando houver outras migrações. Você pode escolher o nome que quiser; -
a pasta
prisma/migrations
contém pastas com os arquivos SQL criados pelo Prisma.
Exemplo de como criar tabelas (models) no schema.prisma
, incluindo chaves primárias e estrangeira, e relacionamentos:
// @id indicates this property as primary key
// @default is a default value; in this case, it will use the autoincrement function to generate an integer
// @unique indicates that the value cannot be repeated between records (rows)
// Post Post[] indicates a 1:N relationship between User and Post
model User {
id Int @id @default(autoincrement())
name String
username String @unique
avatar String
Post Post[]
}
// @updatedAt automatically updates the time when a record is updated
// @relation configures foreign keys to indicate a connection between tables
model Post {
id Int @id @default(autoincrement())
cover String
title String
slug String @unique
body String
markdown String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId Int
author User @relation(fields: [authorId], references: [id])
}
É uma classe disponibilizada pelo Prisma para fazer consultas e as demais operações de CRUD na base de dados, sem usar SQL.
O client é criado pelo seguinte comando:
npx prisma generate
Esse comando deve ser utilizado toda vez que houver alguma mudança no banco (alguma alteração no schema.prisma
), para garantir que o client esteja atualizado com qualquer alteração dos modelos, tipos e relacionamentos.
Para disponibilizar o client para uso na aplicação, uma sugestão é criar um arquivo db.js
na pasta prisma e exportar o client.
// --- prisma/db.js
import { PrismaClient } from '@prisma/client';
const db = new PrismaClient();
export default db;
Link do Prisma sobre seeding, incluindo exemplos em JS e TS: https://www.prisma.io/docs/orm/prisma-migrate/workflows/seeding.
Você pode usar o Prisma para popular (semear) o banco de dados. Para isso, criamos um comando seed
no package.json
, e usamos o comando npx prisma db seed
para popular o banco.
O exemplo a seguir mostra como seria o comando incluído no package.json
. O arquivo prisma/seed.js
será executado pelo node e irá popular o banco de dados.
{
"prisma": {
"seed": "node prisma/seed.js"
}
}
O próximo exemplo mostra a inserção de um novo dado à tabela "Author":
// --- prisma/seed.js
const { PrismaClient } = require('@prisma/client'); // neste caso, precisamos usar require
const prisma = new PrismaClient();
async function main() {
// --- creating author "Ana Beatriz"
const author = {
name: "Ana Beatriz",
username: "anabeatriz_dev",
avatar: "https://raw.githubusercontent.com/viniciosneves/code-connect-assets/main/authors/anabeatriz_dev.png",
};
// upsert will perform an insert or update in the database
// based on a condition passed to the where property
const ana = await prisma.user.upsert({
where: { username: author.username },
update: {}, // we won't perform any updates for now
create: author
});
console.log('Author created: ', ana);
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
Por meio do prisma client, podemos acessar as tabelas do banco (os models no schema.prisma
) como se fossem objetos do client. Esses objetos possuem métodos para fazer consultas.
No exemplo a seguir, usamos o método findMany
para recuperar os dados da tabela post. Essa tabela possui uma relação com a tabela author (uma chave estrangeira para o id do autor), então podemos passar via parâmetro um objeto de configuração com a propriedade include
para também recuperar dados da tabela author. Além disso, também está implementada a lógica para paginação (utilizando as propriedades take
e skip
) e a ordenação pela data de criação do post (propriedade orderBy
);
import db from '../../prisma/db';
const ITEMS_PER_PAGE = 6;
// server-side fetch using Prisma client
const getAllPosts = async (page) => {
try {
// logic for previous page
const prev = page > 1 ? page - 1 : null;
// logic for next page, based on the number of items in the database
const totalItems = await db.post.count(); // SELECT count(*) FROM post
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
const next = page < totalPages ? page + 1 : null;
// logic to get the items for the next page
const skip = (page - 1) * ITEMS_PER_PAGE;
// similar to SELECT * FROM post
const posts = await db.post.findMany({
// use include property to also return data of another table
// when there's a relationship between them (similar to a JOIN)
include: {
author: true
},
// pagination
take: ITEMS_PER_PAGE,
skip,
// sorting
orderBy: { createdAt: 'desc' }
});
return { data: posts, prev, next };
}
catch (error) {
logger.error(`[${new Date().toString()}] Função getAllPosts --> erro de conexão com a API: ${error}`);
return { data: [], prev: null, next: null };
}
}
A Vercel possibilita criarmos um banco de dados e integrá-lo ao deploy da aplicação. Para isso, precisamos fazer alguns ajustes no projeto e também na Vercel.
Para o uso do Prisma, a Vercel solicita duas variáveis de ambiente, então precisamos alterar o schema.prisma
:
datasource db {
provider = "postgresql"
// Uses connection pooling
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}
Para manter o sincronismo com o projeto rodando localmente, adicionamos essas variáveis também ao .env
:
POSTGRES_PRISMA_URL="postgresql://postgres@localhost:5432/codeconnect_dev"
POSTGRES_URL_NON_POOLING="postgresql://postgres@localhost:5432/codeconnect_dev"
Por fim, ajustamos o package.json
, adicionando os passos do Prisma para o script de build
(a Vercel irá chamar esse comando durante o deploy):
{
"scripts": {
"dev": "next dev",
"build": "prisma generate && prisma migrate dev && prisma db seed && next build",
"start": "next start",
"lint": "next lint"
},
}
Na Vercel, precisamos adicionar um banco Postgres, disponibilizado pela plataforma. Para o caso deste projeto, a versão free (hobby) está disponível.
-
Acesse a página do projeto na Vercel;
-
Vá até a aba "Storage";
-
Clique no botão "Create" ao lado do item "Postgres";
-
Use todas as opções padrão nas próximas janelas.
A página do projeto na Vercel só aparece após o primeiro deploy. Então faça o primeiro deploy, que irá gerar um erro por não ter um banco de dados, e aí então crie um banco Postgres e faça um redeploy.
Server Actions são funções assíncronas que você pode criar em seu projeto Next, e que podem ser invocadas tanto por client components quanto por server components. Essas funções são executadas no lado do servidor.
Um exemplo comum é invocá-las na submissão de um formulário, usando o atributo action
do elemento form
. Um aspecto interessante do Next é que essa submissão não irá causar um recarregamento da página.
-
Podemos passar argumentos para uma Server Action usando a função
bind
. Isso é necessário, pois a função está rodando no servidor, então temos que "emprestá-la" do servidor para o componente que vai invocá-la; -
Caso uma Server Action resulte em uma ação que atualiza algum campo da página, você pode usar a função
revalidatePath
para que o Next faça as alterações necessárias na UI (sem recarregar a página inteira). -
Server Actions também podem ser invocadas da maneira tradicional, por meio de eventos ou hooks como
useEffect
. -
Podemos utilizar o hook
useFormStatus
do React para verificar se uma ação está pendente. Isso é útil para exibirmos um ícone de carregamento enquanto a ação não termina, por exemplo.-
até 2024, este hook se encontra disponível de forma experimental no React;
-
o hook só funciona se o componente for renderizado dentro de um elemento
form
; -
o componente que utiliza o hook deve ser um client component. Você pode, por exemplo, abstrair o pedaço de código que usa o hook em um componente separado e aí informar que será um client component.
-
Ao criar uma server action, é uma boa prática deixar explícito no arquivo que a função deve ser executada no servidor, utilizando a diretiva 'use server'
.
Outra boa prática é colocar as actions em uma pasta separada. Por exemplo, criar um arquivo src/actions/index.js
e dentro dele exportar as actions.
Exemplo de Server Action para incrementar o número de curtidas. Observe que usamos o método update
do Prisma:
// explicit tell Next that this file is to run in the server
'use server';
import { revalidatePath } from "next/cache";
import db from "../../prisma/db";
// server action to increment the number of likes for a post
export async function incrementThumbsUp(post){
await db.post.update({
where: {
id: post.id
},
data: {
// we can pass an object with an "increment" property
// to let Prisma increment the current value of a field
// by a value of X (increment likes by 1 in this case)
likes: {
increment: 1
}
}
});
// clear cache to update the UI of pages affected by this action
revalidatePath('/');
revalidatePath(`/${post.slug}`);
}
Exemplo de chamada utilizando a action
de um elemento form
. Parte não relevante do código foi omitida para economizar espaço.
import { incrementThumbsUp } from '@/actions';
export const CardPost = ({ post }) => {
// using bind to pass additional arguments to the Server Action
const submitThumbsUp = incrementThumbsUp.bind(null, post);
return (
<form action={submitThumbsUp}>
<ThumbsUpButton />
<p>{post.likes}</p>
</form>
);
}
Mesmo exemplo, dessa vez utilizando o evento de submit do form
. Neste caso, é necessário transformar o componente em um client component e, por conta disso, impedir o recarregamento da página com preventDefault
:
'use client'
import { incrementThumbsUp } from '@/actions';
export const CardPost = ({ post }) => {
// using bind to pass additional arguments to the Server Action
const submitThumbsUp = incrementThumbsUp.bind(null, post);
const handleSubmit = e => {
e.preventDefault();
submitThumbsUp();
}
return (
<form onSubmit={handleSubmit}>
<ThumbsUpButton />
<p>{post.likes}</p>
</form>
);
}
O ThumbsUpButton
é um client component que vai renderizar um botão ou um spinner, baseado no estado da ação. Parte não relevante do código foi omitida para economizar espaço. :
'use client';
import { useFormStatus } from "react-dom"
export const ThumbsUpButton = () => {
const { pending } = useFormStatus();
return <IconButton disabled={pending}>
{pending ? <Spinner /> : <ThumbsUp />}
</IconButton>
}
Quando uma Server Action é chamada via action
de um elemento form
, o Next automaticamente injeta um objeto formData
como último argumento da função. Este objeto contém os valores de cada elemento dentro do formulário, que podem ser acessados por um método get
passando o atributo name
desses elementos.
O exemplo abaixo é parte de um código de uma Server Action que faz a inserção de comentários em um post. Observe que formData
aparece como último parâmetro da função. Esse parâmetro não é passado pelo componente que chama a função, e sim injetado automaticamente pelo Next. Também observe que usamos o método create
do Prisma para fazer a inserção no banco de dados:
export async function postComment(post, formData) {
await db.comment.create({
data: {
text: formData.get('text'),
authorId: author.id,
postId: post.id
}
});
}
Além de exibir páginas com o arquivo pages.js
, você também pode usar a App Router para criar endpoints no servidor para retornar ou receber dados, ou seja, usar o Next como uma API para tratar requests e responses.
Para isso, você cria um arquivo route.js
. Dentro deste arquivo, você pode criar funções para os verbos HTTP, como GET, POST, etc. Estas funções possuem dois parâmetros opcionais: o request
, um objeto representando a Request, e o context
, um objeto cuja única propriedade atualmente é a params
, que por sua vez é o objeto que o Next disponibiliza para acessar as rotas dinâmicas.
Um exemplo de organização de projeto é, dentro da pasta app
, criar uma pasta api
e nela definir as rotas (endpoints) para lidar com requisições.
O exemplo abaixo cria uma função que irá retornar as respostas a um comentário usando o endpoint api/comment/[id]/replies
, cujo id é acessado por uma rota dinâmica ([id]
).
// -- api/comment/[id]/replies/route.js
import db from "../../../../../../prisma/db"
// we add underline to indicate that the
// request parameter will not be used
export const GET = async (_request, { params }) => {
const replies = await db.comment.findMany({
where: {
parentId: parseInt(params.id)
},
include: {
author: true
}
});
// Response is an interface of the Fetch API
return Response.json(replies);
}