Django + InertiaJS (Vue) - palestra
Publicado em 09/09/2026.
Testado com: Django 6.1, Inertia v3, Vue 3 e Python 3.14.
Palestra apresentada no DevConverge LATAM em 05/09/2026: Django e VueJS com InertiaJS.
Projeto da palestra: https://github.com/rg3915/django-inertia-vuejs
Slides: https://slides.com/regissantos/inertiajs
Neste tutorial vamos montar o CRUD de filmes da palestra: Django no back-end, Vue 3 no front-end e o Inertia.js fazendo a ponte entre os dois. O resultado é uma SPA de verdade, sem API REST, sem Vue Router, sem Pinia e sem CORS.
O problema que o Inertia resolve
Na arquitetura separada, uma tela de filmes tem duas rotas: api/filmes/ no Django e /filmes no Vue Router, que consome a primeira. Isso traz junto:
- uma API REST (DRF ou Django Ninja) com serializers;
- autenticação duplicada (sessão no Django, JWT no front);
- configuração de CORS;
- dois sistemas de rotas, dois projetos, dois deploys.
Se a API tem outros consumidores (um app mobile, terceiros), esse custo se paga. Mas num monolito você está criando uma API só para o seu próprio front consumir.
O Inertia se coloca no meio, como um tradutor, e se apresenta como "o monolito moderno":
- Primeira visita: o Django devolve uma página HTML completa com os dados da página embutidos em JSON. Carrega rápido.
- Navegações seguintes: o Inertia intercepta o clique e faz a requisição com o cabeçalho
X-Inertia. O Django devolve só um JSON com o nome do componente e as props, e o Vue troca apenas o que mudou. - Formulários: o Django processa, salva e faz
redirect, como sempre. O Inertia segue o redirect e busca a próxima página sozinho.
As rotas ficam só no Django. A autenticação é só a do Django, sem token no navegador.
Pré-requisitos
- Python 3.14 (o projeto usa Django 6.1 e
inertia-django2.0, protocolo Inertia v3). - Node.js 20 ou mais recente.
- Docker e Docker Compose (para o PostgreSQL).
1. Projeto Django
mkdir django-inertia-vuejs
cd django-inertia-vuejs
python -m venv .venv
source .venv/bin/activate
pip install django django-extensions django-vite inertia-django "psycopg[binary]" python-decouple
django-admin startproject apps .
cd apps
python ../manage.py startapp core
cd ..
O repositório usa o uv, com essas mesmas dependências no pyproject.toml; nele, basta uv sync. O django-vite liga o Django ao servidor de desenvolvimento do Vite.
Em apps/core/apps.py, ajuste name = 'apps.core'.
2. Banco de dados
# .env
SECRET_KEY=mude-me
POSTGRES_DB=movies_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5431
PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=admin
Gere uma chave para o SECRET_KEY com:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
# docker-compose.yml
services:
db:
image: postgres:18.6-alpine
restart: unless-stopped
env_file: .env
ports:
- "5431:5432"
volumes:
# Postgres 18+ espera um único mount em /var/lib/postgresql.
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
pgdata:
No repositório o compose também sobe o pgAdmin na porta 5050. Repare no comentário do volume: a partir do Postgres 18, montar em /var/lib/postgresql/data, como se fazia até o 17, faz o container entrar em loop de reinício.
docker compose up -d
3. Settings
Os trechos do apps/settings.py que importam:
# apps/settings.py
from pathlib import Path
from decouple import config
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = config('SECRET_KEY')
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_extensions',
'django_vite',
'inertia',
'apps.core',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'inertia.middleware.InertiaMiddleware', # adicionar após SessionMiddleware
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR.joinpath('templates')], # pasta do base.html
'APP_DIRS': True,
# OPTIONS igual ao gerado pelo startproject
},
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('POSTGRES_DB', 'movies_db'),
'USER': config('POSTGRES_USER', 'postgresql'),
'PASSWORD': config('POSTGRES_PASSWORD', 'postgresql'),
'HOST': 'localhost',
'PORT': config('POSTGRES_PORT', default='5431'),
}
}
LANGUAGE_CODE = 'pt-br'
TIME_ZONE = 'America/Sao_Paulo'
# Inertia
INERTIA_LAYOUT = 'base.html'
# Django Vite
DJANGO_VITE = {
'default': {
'dev_mode': True,
'dev_server_host': 'localhost',
'dev_server_port': 5173,
'manifest_path': BASE_DIR.joinpath(
'frontend', 'dist', '.vite', 'manifest.json'
),
}
}
STATIC_URL = 'static/'
STATICFILES_DIRS = [
BASE_DIR.joinpath('frontend', 'dist'),
]
'inertia'e oInertiaMiddlewaresão a instalação do Inertia. O middleware olha o cabeçalhoX-Inertiae decide se devolve HTML ou JSON.INERTIA_LAYOUTé obrigatório e não tem valor padrão: é o template que envolve todas as páginas. Isso mostra o tamanho do Inertia: ele substitui o sistema de templates, e só. Rotas, sessão e ORM continuam com o Django.DJANGO_VITEemdev_modecarrega os arquivos do servidor do Vite na porta 5173. Em produção,dev_modeviraFalsee o Django lê omanifest.jsongerado pelonpm run build.- O CSRF fica com os padrões do Django (
csrftokeneX-CSRFToken); quem se adapta é o cliente, nomain.js.
4. Layout base
<!-- templates/base.html -->
{% load django_vite %}
<!DOCTYPE html>
<html lang="pt-br" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movies</title>
<script>
try {
var saved = localStorage.getItem("theme")
if (saved === "dark" || saved === "light") {
document.documentElement.dataset.theme = saved
}
} catch (e) {}
</script>
{% vite_hmr_client %}
{% vite_asset 'src/main.js' %}
</head>
<body>
{% block inertia %}{% endblock %}
</body>
</html>
O inertia-django preenche o {% block inertia %} com a <div id="app"> e um <script type="application/json"> com os dados da página. vite_hmr_client e vite_asset vêm do django-vite. O script inline aplica o tema salvo antes da primeira pintura, para a página não piscar.
5. Model e form
# apps/core/models.py
from django.core.validators import MaxValueValidator
from django.db import models
class Movie(models.Model):
class Status(models.TextChoices):
WANT = 'want', 'Quero Ver'
WATCHING = 'watching', 'Assistindo'
WATCHED = 'watched', 'Assistido'
title = models.CharField(max_length=200)
director = models.CharField(max_length=200, blank=True)
year = models.PositiveIntegerField(null=True, blank=True)
genre = models.CharField(max_length=100, blank=True)
rating = models.PositiveSmallIntegerField(
null=True, blank=True, validators=[MaxValueValidator(5)]
)
status = models.CharField(
max_length=10, choices=Status.choices, default=Status.WANT
)
notes = models.TextField(blank=True)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['title']
verbose_name = 'Filme'
verbose_name_plural = 'Filmes'
def __str__(self):
return self.title
@property
def status_label(self):
"""'watched' -> 'Assistido'. O rótulo legível vai pronto pro Vue."""
return self.get_status_display()
@property
def stars(self):
"""3 -> '★★★☆☆'. Renderizar isso no servidor evita lógica no template."""
filled = min(self.rating or 0, 5)
return '★' * filled + '☆' * (5 - filled)
def serializable_values(self, exclude=[]):
tree = {}
for field in self._meta.fields:
if field.name in exclude:
continue
tree[field.name] = self.serializable_value(field.name)
# Campos derivados: não existem no banco, mas o componente Vue os recebe
# como props junto com o resto — sem serializer, sem endpoint extra.
tree['status_label'] = self.status_label
tree['stars'] = self.stars
return tree
O serializable_values é a dica da palestra para não precisar de serializer: ele percorre _meta.fields e monta o dicionário sozinho. Se você renomear um campo do model, não tem um dicionário escrito à mão para lembrar de atualizar.
# apps/core/forms.py
from django import forms
from .models import Movie
class MovieForm(forms.ModelForm):
class Meta:
model = Movie
fields = ('title', 'director', 'year', 'genre', 'rating', 'status', 'notes')
Rode as migrações:
python manage.py makemigrations
python manage.py migrate
6. Views com props
# apps/core/views.py
import json
from django.http import QueryDict
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from inertia import render
from .forms import MovieForm
from .models import Movie
def _get_post_data(request):
"""O Inertia envia JSON, mas o Django ModelForm espera QueryDict."""
if request.content_type == 'application/json':
return QueryDict(mutable=True) | json.loads(request.body)
return request.POST
def _index_props():
"""Props comuns para a página Index."""
movies = Movie.objects.all()
data = [movie.serializable_values(exclude=['added_at']) for movie in movies]
return {
'movies': data,
'stats': {
'total': movies.count(),
'want': movies.filter(status='want').count(),
'watching': movies.filter(status='watching').count(),
'watched': movies.filter(status='watched').count(),
},
}
def movie_list(request):
return render(request, 'Movies/Index', props=_index_props())
def movie_create(request):
data = _get_post_data(request)
form = MovieForm(data)
if form.is_valid():
form.save()
messages.success(request, 'Filme criado com sucesso!')
return redirect('movie_list')
props = _index_props()
props['errors'] = form.errors
props['showDialog'] = 'create'
props['formData'] = dict(data)
return render(request, 'Movies/Index', props=props)
def movie_update(request, pk):
movie = get_object_or_404(Movie, pk=pk)
data = _get_post_data(request)
form = MovieForm(data, instance=movie)
if form.is_valid():
form.save()
messages.success(request, 'Filme atualizado com sucesso!')
return redirect('movie_list')
props = _index_props()
props['errors'] = form.errors
props['showDialog'] = 'edit'
props['editMovie'] = movie.serializable_values(exclude=['added_at'])
props['formData'] = dict(data)
return render(request, 'Movies/Index', props=props)
def movie_delete(request, pk):
movie = get_object_or_404(Movie, pk=pk)
movie.delete()
messages.success(request, 'Filme excluído com sucesso!')
return redirect('movie_list')
- O
renderagora vem deinertia, não dedjango.shortcuts. O segundo argumento,'Movies/Index', é o nome do componente Vue;propsé um dicionário que chega ao componente como props. - O Inertia envia os formulários como JSON. Como o
ModelFormespera umQueryDict, o_get_post_datafaz a conversão. - Formulário válido: salva, registra uma mensagem e faz
redirect, como em qualquer view Django. - Formulário inválido: renderiza a mesma página com
errors,showDialogeformData, para o Vue reabrir o diálogo com os erros e o que o usuário digitou. - As mensagens do
django.contrib.messageschegam ao front automaticamente empage.flash.messages(Inertia v3).
7. URLs
# apps/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('apps.core.urls')),
]
# apps/core/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.movie_list, name='movie_list'),
path('create/', views.movie_create, name='movie_create'),
path('<int:pk>/update/', views.movie_update, name='movie_update'),
path('<int:pk>/delete/', views.movie_delete, name='movie_delete'),
]
É Django puro, nada mudou. E como não há API, também não há documentação de API para manter: python manage.py show_urls (django-extensions) lista todas as rotas, as views e os nomes.
8. Front-end: Vite e Vue
mkdir -p frontend/src/Pages/Movies frontend/src/Components frontend/src/composables
cd frontend
npm init -y
npm install vue @inertiajs/vue3 @vitejs/plugin-vue @picocss/pico
npm install -D vite
cd ..
// frontend/package.json
{
"name": "frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@inertiajs/vue3": "^3.7.0",
"@picocss/pico": "^2.1.1",
"@vitejs/plugin-vue": "^6.0.8",
"vue": "^3.5.41"
},
"devDependencies": {
"vite": "^8.2.2"
}
}
// frontend/vite.config.js
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
export default defineConfig({
plugins: [vue()],
root: ".",
base: "/static/",
build: {
outDir: "dist",
manifest: true,
rollupOptions: {
input: "src/main.js",
},
},
server: {
origin: "http://localhost:5173",
},
})
O base: "/static/" casa com o STATIC_URL do Django, e o manifest: true gera o arquivo que o django-vite lê em produção.
// frontend/src/main.js
import "@picocss/pico"
import { createApp, h } from "vue"
import { createInertiaApp } from "@inertiajs/vue3"
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob("./Pages/**/*.vue", { eager: true })
return pages[`./Pages/${name}.vue`]
},
// A partir do Inertia v3 o axios foi trocado por um cliente XHR próprio,
// que por padrão usa os nomes de CSRF do Laravel (XSRF-TOKEN / X-XSRF-TOKEN).
// Aqui ensinamos o cliente a falar a língua do Django.
http: {
xsrfCookieName: "csrftoken",
xsrfHeaderName: "X-CSRFToken",
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})
O resolve transforma o nome que a view passou ('Movies/Index') no arquivo ./Pages/Movies/Index.vue. O bloco http é a única configuração específica do Django: sem ele, os POST seriam recusados pelo CsrfViewMiddleware.
9. O componente da página
<!-- frontend/src/Pages/Movies/Index.vue -->
<script setup>
import { ref, onMounted } from "vue"
import { router, useForm } from "@inertiajs/vue3"
import StatsBar from "../../Components/StatsBar.vue"
import MovieTable from "../../Components/MovieTable.vue"
import MovieFormDialog from "../../Components/MovieFormDialog.vue"
import ConfirmDialog from "../../Components/ConfirmDialog.vue"
import Toast from "../../Components/Toast.vue"
import ThemeSwitch from "../../Components/ThemeSwitch.vue"
// Quem passa esses dados é o Django:
// render(request, "Movies/Index", props={"movies": data, "stats": {...}})
const props = defineProps([
"movies", "stats",
"errors", "showDialog", "editMovie", "formData",
])
const showCreateDialog = ref(false)
const showEditDialog = ref(false)
const showDeleteDialog = ref(false)
const movieToDelete = ref(null)
const editingMovieId = ref(null)
// useForm: estado reativo do formulário + métodos post, put etc.
const createForm = useForm({
title: "",
director: "",
year: "",
genre: "",
rating: "",
status: "want",
notes: "",
})
function openCreate() {
createForm.reset()
showCreateDialog.value = true
}
// POST direto numa rota do Django. Não existe Vue Router.
function submitCreate() {
createForm.post("/create/", {
onSuccess: () => {
createForm.reset()
showCreateDialog.value = false
},
})
}
const editForm = useForm({
title: "",
director: "",
year: "",
genre: "",
rating: "",
status: "want",
notes: "",
})
function openEdit(movie) {
editingMovieId.value = movie.id
editForm.title = movie.title
editForm.director = movie.director
editForm.year = movie.year || ""
editForm.genre = movie.genre
editForm.rating = movie.rating || ""
editForm.status = movie.status
editForm.notes = movie.notes
showEditDialog.value = true
}
function submitEdit() {
editForm.post(`/${editingMovieId.value}/update/`, {
onSuccess: () => { showEditDialog.value = false },
})
}
function confirmDelete(movie) {
movieToDelete.value = movie
showDeleteDialog.value = true
}
function executeDelete() {
router.post(`/${movieToDelete.value.id}/delete/`, {}, {
onSuccess: () => {
showDeleteDialog.value = false
movieToDelete.value = null
},
})
}
// Se o Django devolveu erros de validação, reabre o diálogo com os dados digitados.
onMounted(() => {
if (props.showDialog === "create" && props.formData) {
createForm.title = props.formData.title || ""
createForm.director = props.formData.director || ""
createForm.year = props.formData.year || ""
createForm.genre = props.formData.genre || ""
createForm.rating = props.formData.rating || ""
createForm.status = props.formData.status || "want"
createForm.notes = props.formData.notes || ""
showCreateDialog.value = true
}
if (props.showDialog === "edit" && props.editMovie) {
editingMovieId.value = props.editMovie.id
editForm.title = props.formData?.title || props.editMovie.title
editForm.director = props.formData?.director || props.editMovie.director
editForm.year = props.formData?.year || props.editMovie.year || ""
editForm.genre = props.formData?.genre || props.editMovie.genre
editForm.rating = props.formData?.rating || props.editMovie.rating || ""
editForm.status = props.formData?.status || props.editMovie.status
editForm.notes = props.formData?.notes || props.editMovie.notes
showEditDialog.value = true
}
})
</script>
<template>
<Toast />
<main class="container">
<div class="page-header">
<StatsBar :stats="stats" />
<ThemeSwitch />
</div>
<MovieTable :movies="movies" @edit="openEdit" @delete="confirmDelete">
<template #actions>
<button @click="openCreate">Novo filme</button>
</template>
</MovieTable>
<MovieFormDialog
v-model:open="showCreateDialog"
:form="createForm"
:errors="showCreateDialog ? errors : {}"
title="Novo Filme"
@submit="submitCreate"
/>
<MovieFormDialog
v-model:open="showEditDialog"
:form="editForm"
:errors="showEditDialog ? errors : {}"
title="Editar Filme"
@submit="submitEdit"
/>
<ConfirmDialog
v-model:open="showDeleteDialog"
title="Confirmar exclusão"
:message="`Tem certeza que deseja excluir <strong>${movieToDelete?.title}</strong>?`"
@confirm="executeDelete"
/>
</main>
</template>
<style scoped>
main.container {
padding-top: 2rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
</style>
Fluxo do formulário: createForm.post("/create/") envia JSON para a view movie_create. Se ela responde com redirect (sucesso), o Inertia busca a lista atualizada e troca as props sem recarregar a página. Se ela responde renderizando a página com errors, o diálogo continua aberto mostrando os erros. O form.processing fica true enquanto a requisição está em andamento.
10. A tabela com busca reativa
<!-- frontend/src/Components/MovieTable.vue -->
<script setup>
import { computed, ref } from "vue"
const props = defineProps(["movies"])
defineEmits(["edit", "delete"])
// Os filmes já chegaram como props do Django: filtrar é só uma computed
// sobre um array em memória. Nenhum fetch, nenhum endpoint, nenhum loading.
const search = ref("")
const filteredMovies = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return props.movies
return props.movies.filter((movie) =>
[movie.title, movie.director, movie.genre]
.filter(Boolean)
.some((field) => field.toLowerCase().includes(term))
)
})
</script>
<template>
<div class="toolbar">
<slot name="actions" />
<div role="search">
<input
v-model="search"
type="search"
placeholder="Buscar por título, diretor ou gênero…"
aria-label="Buscar filmes"
>
</div>
</div>
<small v-if="search" class="search-count">
{{ filteredMovies.length }} de {{ movies.length }} filme(s)
</small>
<figure>
<table>
<thead>
<tr>
<th>Título</th>
<th>Diretor</th>
<th>Ano</th>
<th>Nota</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
<tr v-for="movie in filteredMovies" :key="movie.id">
<td>{{ movie.title }}</td>
<td>{{ movie.director }}</td>
<td>{{ movie.year }}</td>
<td class="stars">{{ movie.stars }}</td>
<td>{{ movie.status_label }}</td>
<td>
<a href="#" role="button" class="outline" @click.prevent="$emit('edit', movie)">Editar</a>
<a href="#" role="button" class="outline secondary" @click.prevent="$emit('delete', movie)">Excluir</a>
</td>
</tr>
<tr v-if="!filteredMovies.length">
<td colspan="6">
<em v-if="search">Nenhum filme encontrado para "{{ search }}".</em>
<em v-else>Nenhum filme cadastrado ainda.</em>
</td>
</tr>
</tbody>
</table>
</figure>
</template>
stars e status_label vêm prontos do model. No repositório o componente tem ainda um bloco <style scoped> para alinhar a barra de busca.
11. Diálogo do formulário e barra de estatísticas
<!-- frontend/src/Components/MovieFormDialog.vue -->
<script setup>
import { ref, watch } from "vue"
import { useModal } from "../composables/useModal"
const props = defineProps(["form", "errors", "title", "open"])
const emit = defineEmits(["update:open", "submit"])
const dialogRef = ref(null)
const { isOpen, open, close } = useModal(dialogRef)
watch(() => props.open, (val) => {
if (val && !isOpen.value) open()
if (!val && isOpen.value) close()
})
function handleClose() {
close()
emit("update:open", false)
}
</script>
<template>
<dialog ref="dialogRef" @close="handleClose">
<article>
<header>
<button aria-label="Close" rel="prev" @click="handleClose"></button>
<h3>{{ title }}</h3>
</header>
<form @submit.prevent="$emit('submit')">
<label>
Título
<input v-model="form.title" :aria-invalid="!!errors?.title" />
<small v-if="errors?.title" style="color: red">{{ errors.title[0] }}</small>
</label>
<label>
Diretor
<input v-model="form.director" :aria-invalid="!!errors?.director" />
<small v-if="errors?.director" style="color: red">{{ errors.director[0] }}</small>
</label>
<div class="grid">
<label>
Ano
<input v-model="form.year" type="number" min="0" :aria-invalid="!!errors?.year" />
<small v-if="errors?.year" style="color: red">{{ errors.year[0] }}</small>
</label>
<label>
Gênero
<input v-model="form.genre" :aria-invalid="!!errors?.genre" />
<small v-if="errors?.genre" style="color: red">{{ errors.genre[0] }}</small>
</label>
</div>
<div class="grid">
<label>
Nota (0-5)
<input v-model="form.rating" type="number" min="0" max="5" :aria-invalid="!!errors?.rating" />
<small v-if="errors?.rating" style="color: red">{{ errors.rating[0] }}</small>
</label>
<label>
Status
<select v-model="form.status" :aria-invalid="!!errors?.status">
<option value="want">Quero Ver</option>
<option value="watching">Assistindo</option>
<option value="watched">Assistido</option>
</select>
<small v-if="errors?.status" style="color: red">{{ errors.status[0] }}</small>
</label>
</div>
<label>
Notas
<textarea v-model="form.notes" :aria-invalid="!!errors?.notes"></textarea>
<small v-if="errors?.notes" style="color: red">{{ errors.notes[0] }}</small>
</label>
<footer>
<div class="grid">
<button type="button" class="outline secondary" @click="handleClose">Cancelar</button>
<button type="submit" :disabled="form.processing" :aria-busy="form.processing">Salvar</button>
</div>
</footer>
</form>
</article>
</dialog>
</template>
Os errors são o form.errors do Django, no formato {"title": ["Este campo é obrigatório."]}.
<!-- frontend/src/Components/StatsBar.vue -->
<script setup>
defineProps(["stats"])
</script>
<template>
<hgroup>
<h1>Filmes</h1>
<p>
Total: {{ stats.total }} |
Quero ver: {{ stats.want }} |
Assistindo: {{ stats.watching }} |
Assistidos: {{ stats.watched }}
</p>
</hgroup>
</template>
12. Modal, confirmação, mensagens e tema
O Index.vue usa mais quatro peças de apoio, que não têm nada de específico do Django ou do Inertia. Copie-as do repositório:
frontend/src/composables/useModal.js: abre e fecha o<dialog>comshowModal()/close()e aplica no<html>as classesmodal-is-open,modal-is-openingemodal-is-closing, que o Pico CSS usa nas animações.frontend/src/Components/ConfirmDialog.vue: o mesmo padrão doMovieFormDialog(propopencomv-model), com os botões Cancelar e Confirmar, que emiteconfirm.frontend/src/Components/ThemeSwitch.vue: um switch que gravadata-themeno<html>e nolocalStorage.frontend/src/Components/Toast.vue: mostra as mensagens do Django. Esta tem um detalhe do Inertia v3 que vale ver:
// frontend/src/Components/Toast.vue (trecho do <script setup>)
import { ref, watch } from "vue"
import { usePage } from "@inertiajs/vue3"
// Cada item vem no formato { level, message }, com as tags do Django.
const page = usePage()
const visible = ref([])
watch(() => page.flash?.messages, (msgs) => {
if (!msgs || !msgs.length) return
msgs.forEach((msg, i) => {
const item = { ...msg, id: Date.now() + i }
visible.value.push(item)
setTimeout(() => {
visible.value = visible.value.filter(v => v.id !== item.id)
}, 4000)
})
}, { immediate: true })
O messages.success(...) da view chega em page.flash.messages, fora das props e sem middleware customizado. O template percorre visible com v-for e aplica a classe do nível (success, error, warning, info).
13. Rodando
Popule o banco com os filmes da demonstração e crie um superusuário. O seed_movies é um comando do próprio projeto (copie apps/core/management/commands/seed_movies.py do repositório); ele é idempotente e aceita --clear para apagar tudo antes:
python manage.py seed_movies
python manage.py createsuperuser
São dois terminais:
# Terminal 1 - Django
python manage.py runserver
# Terminal 2 - Vite
cd frontend
npm install
npm run dev
Acesse http://localhost:8000 e teste:
- Digite
tarantinona busca: a lista filtra na hora, sem nenhuma requisição ao servidor. - Clique em Novo filme, deixe o título vazio e salve: o erro de validação do Django aparece no diálogo.
- Edite um filme e salve: a lista atualiza e aparece o toast, sem recarregar a página.
- Abra a aba Rede do navegador: a primeira carga é HTML; as seguintes são JSON com o cabeçalho
X-Inertia.
Para produção, npm run build gera frontend/dist/ com o manifest, e dev_mode passa a False.
Quando não usar Inertia
- Com FastAPI: o foco dele é API, e o Inertia pressupõe um framework que renderiza o layout.
- Quando a API tem outros consumidores, como um app mobile ou terceiros. Aí a API REST se justifica.
A pergunta é: quem vai consumir isso além do meu próprio front-end? Se a resposta é ninguém, o Inertia entrega uma interface moderna sem abrir mão do monolito. Ele também tem adaptadores para Rails, Laravel e Phoenix no back-end, e para Vue, React e Svelte no front-end.
Referências:
- inertia-django: https://github.com/inertiajs/inertia-django
- Inertia.js: https://inertiajs.com/
- django-vite: https://github.com/MrBin99/django-vite