image

Accede a bootcamps ilimitados y a más de 750 cursos para siempre

70
%OFF
Article image
Pedro Sebben
Pedro Sebben25/06/2026 14:53
Compartir

Full Stack : Construindo uma Aplicação Completa com Next.js 14 + Node.js do Zero ao Deploy

  • #Node.js
  • #Railway
  • #Next.js
  • #Vercel
Neste artigo você vai construir uma aplicação full stack moderna usando Next.js 14 (App Router) no front e Node.js + Express + Prisma no back. Do banco de dados ao deploy na Vercel e Railway.

Por que essa stack está dominando o mercado?

Se você abrir o LinkedIn agora e filtrar vagas de full stack no Brasil, vai encontrar uma combinação se repetindo: Next.js no front, Node.js no back, PostgreSQL no banco. Não é hype é o mercado dizendo o que funciona.

Next.js 14 com App Router trouxe uma mudança de paradigma: o front e o back começaram a se misturar de um jeito inteligente, com Server Components, Server Actions e cache granular. E quando você combina isso com uma API Node.js bem estruturada, você tem uma stack que escala, performa e é fácil de manter.

Neste artigo vou mostrar como montar isso de verdade, com código real e as decisões que um desenvolvedor sênior tomaria no dia a dia.

O que vamos construir

Uma API de gerenciamento de tarefas (Task Manager) com:

  • ✅ Autenticação JWT
  • ✅ CRUD de tarefas com filtros
  • ✅ Frontend consumindo a API com React Query
  • ✅ Banco de dados PostgreSQL via Prisma ORM
  • ✅ Deploy na Vercel (front) + Railway (back)

Estrutura do projeto

taskmaster/
├── apps/
│   ├── web/          # Next.js 14 - App Router
│   └── api/          # Node.js + Express
├── packages/
│   └── types/        # Tipos compartilhados (TypeScript)
└── package.json      # Monorepo com npm workspaces

Monorepo com npm workspaces: simples, sem a complexidade do Turborepo quando você não precisa dela. Tipos compartilhados entre front e back, essa é a virada de chave que separa junior de sênior.

Backend: Node.js + Express + Prisma

1. Setup inicial

mkdir taskmaster && cd taskmaster
npm init -w apps/api -y
cd apps/api
npm install express prisma @prisma/client jsonwebtoken bcryptjs zod
npm install -D typescript ts-node @types/express @types/node nodemon

2. Schema do Prisma

// apps/api/prisma/schema.prisma

generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql"
url      = env("DATABASE_URL")
}

model User {
id        String   @id @default(cuid())
email     String   @unique
password  String
name      String
tasks     Task[]
createdAt DateTime @default(now())
}

model Task {
id          String     @id @default(cuid())
title       String
description String?
status      TaskStatus @default(PENDING)
priority    Priority   @default(MEDIUM)
userId      String
user        User       @relation(fields: [userId], references: [id])
createdAt   DateTime   @default(now())
updatedAt   DateTime   @updatedAt
}

enum TaskStatus {
PENDING
IN_PROGRESS
DONE
}

enum Priority {
LOW
MEDIUM
HIGH
}

3. Estrutura de pastas da API

apps/api/src/
├── controllers/
│   ├── auth.controller.ts
│   └── task.controller.ts
├── middlewares/
│   ├── auth.middleware.ts
│   └── validate.middleware.ts
├── routes/
│   ├── auth.routes.ts
│   └── task.routes.ts
├── services/
│   ├── auth.service.ts
│   └── task.service.ts
├── lib/
│   └── prisma.ts
└── server.ts

Essa separação é importante: controller recebe request e devolve response, service contém a lógica de negócio. Não misture as responsabilidades.

4. Singleton do Prisma Client

// apps/api/src/lib/prisma.ts

import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}

export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query'] : [],
})

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Sem isso, em desenvolvimento com hot reload você vai criar uma nova conexão com o banco a cada save. Detalhe pequeno, problema grande em escala.

5. Middleware de autenticação

// apps/api/src/middlewares/auth.middleware.ts

import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'

export interface AuthRequest extends Request {
userId?: string
}

export function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
) {
const token = req.headers.authorization?.split(' ')[1]

if (!token) {
  return res.status(401).json({ error: 'Token não fornecido' })
}

try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
    userId: string
  }
  req.userId = decoded.userId
  next()
} catch {
  return res.status(401).json({ error: 'Token inválido ou expirado' })
}
}

6. Task Service — a lógica que importa

// apps/api/src/services/task.service.ts

import { prisma } from '../lib/prisma'
import { TaskStatus, Priority } from '@prisma/client'

interface CreateTaskInput {
title: string
description?: string
priority?: Priority
userId: string
}

interface ListTasksInput {
userId: string
status?: TaskStatus
priority?: Priority
page?: number
limit?: number
}

export class TaskService {
async create(data: CreateTaskInput) {
  return prisma.task.create({ data })
}

async list({ userId, status, priority, page = 1, limit = 10 }: ListTasksInput) {
  const where = {
    userId,
    ...(status && { status }),
    ...(priority && { priority }),
  }

  const [tasks, total] = await Promise.all([
    prisma.task.findMany({
      where,
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
    }),
    prisma.task.count({ where }),
  ])

  return {
    tasks,
    pagination: {
      total,
      page,
      pages: Math.ceil(total / limit),
    },
  }
}

async update(id: string, userId: string, data: Partial<CreateTaskInput & { status: TaskStatus }>) {
  return prisma.task.update({
    where: { id, userId }, // garante que o usuário só edita as próprias tasks
    data,
  })
}

async delete(id: string, userId: string) {
  return prisma.task.delete({
    where: { id, userId },
  })
}
}

Note o where: { id, userId } no update e delete. Isso é autorização em nível de dado — um erro clássico de segurança é validar autenticação mas esquecer que o usuário A pode editar tarefas do usuário B.

Frontend: Next.js 14 com App Router

1. Setup

npx create-next-app@latest apps/web \
--typescript \
--tailwind \
--app \
--no-src-dir

2. Configurando o React Query

// apps/web/app/providers.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60 * 1000, // 1 minuto
        },
      },
    })
)

return (
  <QueryClientProvider client={queryClient}>
    {children}
  </QueryClientProvider>
)
}

3. Camada de API no front (lib/api.ts)

// apps/web/lib/api.ts

const API_URL = process.env.NEXT_PUBLIC_API_URL!

async function fetchWithAuth(path: string, options?: RequestInit) {
const token = localStorage.getItem('token')

const res = await fetch(`${API_URL}${path}`, {
  ...options,
  headers: {
    'Content-Type': 'application/json',
    ...(token && { Authorization: `Bearer ${token}` }),
    ...options?.headers,
  },
})

if (!res.ok) {
  const error = await res.json()
  throw new Error(error.message ?? 'Erro na requisição')
}

return res.json()
}

export const api = {
tasks: {
  list: (params?: { status?: string; page?: number }) =>
    fetchWithAuth(`/tasks?${new URLSearchParams(params as any)}`),
  create: (data: { title: string; description?: string; priority?: string }) =>
    fetchWithAuth('/tasks', { method: 'POST', body: JSON.stringify(data) }),
  update: (id: string, data: object) =>
    fetchWithAuth(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
  delete: (id: string) =>
    fetchWithAuth(`/tasks/${id}`, { method: 'DELETE' }),
},
}

4. Hook de tarefas com React Query

// apps/web/hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'

export function useTasks(filters?: { status?: string }) {
return useQuery({
  queryKey: ['tasks', filters],
  queryFn: () => api.tasks.list(filters),
})
}

export function useCreateTask() {
const queryClient = useQueryClient()

return useMutation({
  mutationFn: api.tasks.create,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['tasks'] })
  },
})
}

export function useDeleteTask() {
const queryClient = useQueryClient()

return useMutation({
  mutationFn: api.tasks.delete,
  onMutate: async (id) => {
    // Optimistic update: remove da UI antes de confirmar no servidor
    await queryClient.cancelQueries({ queryKey: ['tasks'] })
    const previous = queryClient.getQueryData(['tasks'])
    queryClient.setQueryData(['tasks'], (old: any) => ({
      ...old,
      tasks: old.tasks.filter((t: any) => t.id !== id),
    }))
    return { previous }
  },
  onError: (_, __, context) => {
    queryClient.setQueryData(['tasks'], context?.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['tasks'] })
  },
})
}

O optimistic update no delete é o tipo de detalhe que faz uma aplicação parecer rápida e profissional. O usuário vê a task sumir instantaneamente; se der erro, ela volta.

5. Componente de listagem

// apps/web/app/dashboard/page.tsx
'use client'

import { useTasks, useDeleteTask } from '@/hooks/useTasks'
import { useState } from 'react'

const STATUS_LABELS = {
PENDING: 'Pendente',
IN_PROGRESS: 'Em andamento',
DONE: 'Concluído',
}

const PRIORITY_COLORS = {
LOW: 'bg-green-100 text-green-800',
MEDIUM: 'bg-yellow-100 text-yellow-800',
HIGH: 'bg-red-100 text-red-800',
}

export default function DashboardPage() {
const [filter, setFilter] = useState<string | undefined>()
const { data, isLoading } = useTasks({ status: filter })
const deleteTask = useDeleteTask()

if (isLoading) return <div className="animate-pulse">Carregando...</div>

return (
  <div className="max-w-4xl mx-auto p-6">
    <div className="flex gap-2 mb-6">
      {['Todos', 'PENDING', 'IN_PROGRESS', 'DONE'].map((s) => (
        <button
          key={s}
          onClick={() => setFilter(s === 'Todos' ? undefined : s)}
          className={`px-4 py-2 rounded-full text-sm font-medium transition-colors
            ${filter === (s === 'Todos' ? undefined : s)
              ? 'bg-blue-600 text-white'
              : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
            }`}
        >
          {s === 'Todos' ? 'Todos' : STATUS_LABELS[s as keyof typeof STATUS_LABELS]}
        </button>
      ))}
    </div>

    <ul className="space-y-3">
      {data?.tasks.map((task: any) => (
        <li
          key={task.id}
          className="flex items-center justify-between p-4 bg-white rounded-xl shadow-sm border border-gray-100"
        >
          <div>
            <p className="font-medium text-gray-900">{task.title}</p>
            {task.description && (
              <p className="text-sm text-gray-500 mt-1">{task.description}</p>
            )}
            <span className={`mt-2 inline-block px-2 py-0.5 rounded text-xs font-medium
              ${PRIORITY_COLORS[task.priority as keyof typeof PRIORITY_COLORS]}`}>
              {task.priority}
            </span>
          </div>
          <button
            onClick={() => deleteTask.mutate(task.id)}
            className="text-red-400 hover:text-red-600 transition-colors ml-4"
          >
            Remover
          </button>
        </li>
      ))}
    </ul>

    {data?.pagination && (
      <p className="text-sm text-gray-400 mt-4 text-center">
        {data.pagination.total} tarefas no total
      </p>
    )}
  </div>
)
}

Deploy: Vercel + Railway

Backend no Railway

O Railway é a melhor opção custo-benefício para hospedar Node.js + PostgreSQL hoje.

# railway.toml na raiz de apps/api
[build]
builder = "nixpacks"

[deploy]
startCommand = "npm run start"
healthcheckPath = "/health"
healthcheckTimeout = 300

Variáveis de ambiente no Railway:

DATABASE_URL=postgresql://...
JWT_SECRET=seu-secret-aqui-use-openssl-rand-base64-32
NODE_ENV=production
PORT=3001

Frontend na Vercel

vercel --cwd apps/web

Variável de ambiente na Vercel:

NEXT_PUBLIC_API_URL=https://sua-api.railway.app

Boas práticas que fazem a diferença

1. Validação com Zod em ambos os lados

Não confie só no TypeScript — ele some no runtime. Use Zod para validar inputs tanto na API quanto no front:

import { z } from 'zod'

export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
})

2. Rate limiting na API

npm install express-rate-limit
import rateLimit from 'express-rate-limit'

const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100,
message: { error: 'Muitas requisições, tente novamente mais tarde.' },
})

app.use('/api/', limiter)

3. Variáveis de ambiente tipadas

// apps/api/src/env.ts
import { z } from 'zod'

const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3001),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
})

export const env = envSchema.parse(process.env)

Se uma variável de ambiente obrigatória estiver faltando, a aplicação não sobe — e você descobre em desenvolvimento, não em produção.

O que aprender a seguir

Você tem uma aplicação full stack funcionando. O próximo nível é:

  • Testes: Vitest para unit tests nos services, Playwright para E2E
  • Cache: Redis para sessões e rate limiting distribuído
  • Filas: Bull/BullMQ para processar tarefas em background (envio de emails, notificações)
  • Observabilidade: Sentry para erros, Axiom ou Datadog para logs estruturados
  • CI/CD: GitHub Actions rodando lint, testes e deploy automático

Cada um desses tópicos é um artigo em si mas dominar essa base é o que permite você evoluir para eles.

Conclusão

Construir uma aplicação full stack de verdade não é só juntar tecnologias — é fazer escolhas conscientes: onde fica a lógica de negócio, como garantir segurança nos dados, como a UI responde rápido mesmo com latência na rede.

Next.js 14 + Node.js não é a única stack boa, mas é uma das mais demandadas no mercado brasileiro hoje. E agora você sabe não só como usar, mas por que cada peça está onde está.

Pedro Sebben — Desenvolvedor Full Stack

Compartir
Recomendado para ti
Sem Parar Corpay - Back-end do Zero a Prática
AWS - Agentes de IA em Campo
Riachuelo - Criando produtos com IA
Comentarios (0)