Inertia.js sem Laravel: usando o protocolo com outros backends

1. Entendendo o Protocolo Inertia.js além do Laravel

Inertia.js é, em sua essência, um protocolo de comunicação entre frontend e backend, não um framework completo. O Laravel é apenas a implementação mais madura desse protocolo. O cerne do Inertia reside em headers HTTP específicos e um formato JSON padronizado.

Os headers fundamentais são:
- X-Inertia: true — indica que a requisição espera uma resposta Inertia
- X-Inertia-Version: [hash] — versão dos assets frontend para controle de cache
- X-Inertia-Partial-Data: component,prop1,prop2 — usado em partial reloads

A resposta JSON deve seguir esta estrutura:

{
  "component": "Users/List",
  "props": {
    "users": [...],
    "flash": { "success": "Usuário criado" }
  },
  "url": "/users",
  "version": "abc123"
}

2. Configuração do Backend Node.js (Express/Fastify)

Vamos implementar um middleware Inertia mínimo para Express:

// middleware/inertia.js
const inertiaVersion = require('fs').readFileSync('public/mix-manifest.json', 'utf8');

function inertia(component, props = {}) {
  return (req, res) => {
    const version = JSON.parse(inertiaVersion)['/js/app.js'].hash;

    // Validação de versão (cache busting)
    if (req.headers['x-inertia-version'] !== version) {
      return res.status(409).json({
        component: component,
        props: {},
        url: req.originalUrl,
        version: version
      });
    }

    // Resposta Inertia padrão
    res.setHeader('X-Inertia', 'true');
    res.json({
      component,
      props: {
        ...props,
        flash: req.session?.flash || {}
      },
      url: req.originalUrl,
      version
    });
  };
}

function redirect(url, status = 302) {
  return (req, res) => {
    if (req.headers['x-inertia']) {
      res.status(409).setHeader('X-Inertia-Location', url).end();
    } else {
      res.redirect(status, url);
    }
  };
}

Uso em uma rota:

const express = require('express');
const app = express();

app.get('/users', inertia('Users/List', { 
  users: [{ id: 1, name: 'Alice' }] 
}));

app.post('/users', (req, res) => {
  // lógica de criação
  req.session.flash = { success: 'Usuário criado' };
  redirect('/users')(req, res);
});

3. Backend em Python (Flask ou FastAPI)

Implementação com Flask:

# inertia_adapter.py
from flask import jsonify, request, session, redirect as flask_redirect
import hashlib
import json

class Inertia:
    def __init__(self, app, version_callback=None):
        self.app = app
        self.version_callback = version_callback or (lambda: '1.0.0')
        self.shared_props = {}

    def render(self, component, props=None):
        props = props or {}
        version = self.version_callback()

        # Partial reload handling
        if request.headers.get('X-Inertia-Partial-Data'):
            only = request.headers['X-Inertia-Partial-Data'].split(',')
            if request.headers.get('X-Inertia-Partial-Component') == component:
                props = {k: v for k, v in props.items() if k in only}

        # Version check
        if request.headers.get('X-Inertia-Version') != version:
            return self._version_conflict(component, props, version)

        response = jsonify({
            'component': component,
            'props': {**self.shared_props, **props, 'flash': session.get('_flash', {})},
            'url': request.url,
            'version': version
        })
        response.headers['X-Inertia'] = 'true'
        return response

    def _version_conflict(self, component, props, version):
        response = jsonify({
            'component': component,
            'props': {},
            'url': request.url,
            'version': version
        })
        response.status_code = 409
        return response

    def redirect(self, url, status=302):
        if request.headers.get('X-Inertia'):
            response = self.app.response_class(status=409)
            response.headers['X-Inertia-Location'] = url
            return response
        return flask_redirect(url, status)

    def share(self, key, value):
        self.shared_props[key] = value

Exemplo de CRUD:

from flask import Flask, session
from inertia_adapter import Inertia

app = Flask(__name__)
inertia = Inertia(app, version_callback=lambda: '2.0.0')

@app.route('/users')
def list_users():
    users = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
    return inertia.render('Users/List', {'users': users})

@app.route('/users/create', methods=['POST'])
def create_user():
    session['_flash'] = {'success': 'Usuário criado com sucesso!'}
    return inertia.redirect('/users')

4. Backend em Go (Gin ou Chi)

Adapter mínimo para Gin:

package inertia

import (
    "crypto/sha256"
    "encoding/json"
    "fmt"
    "net/http"
    "github.com/gin-gonic/gin"
)

type Inertia struct {
    version string
    shared  map[string]interface{}
}

func New(version string) *Inertia {
    return &Inertia{
        version: version,
        shared:  make(map[string]interface{}),
    }
}

type Response struct {
    Component string                 `json:"component"`
    Props     map[string]interface{} `json:"props"`
    URL       string                 `json:"url"`
    Version   string                 `json:"version"`
}

func (i *Inertia) Render(c *gin.Context, component string, props map[string]interface{}) {
    if c.GetHeader("X-Inertia-Version") != i.version {
        c.JSON(http.StatusConflict, Response{
            Component: component,
            Props:     map[string]interface{}{},
            URL:       c.Request.URL.String(),
            Version:   i.version,
        })
        return
    }

    // Merge shared props
    finalProps := make(map[string]interface{})
    for k, v := range i.shared {
        finalProps[k] = v
    }
    for k, v := range props {
        finalProps[k] = v
    }

    c.Header("X-Inertia", "true")
    c.JSON(http.StatusOK, Response{
        Component: component,
        Props:     finalProps,
        URL:       c.Request.URL.String(),
        Version:   i.version,
    })
}

func (i *Inertia) Redirect(c *gin.Context, url string) {
    if c.GetHeader("X-Inertia") == "true" {
        c.Header("X-Inertia-Location", url)
        c.Status(http.StatusConflict)
        return
    }
    c.Redirect(http.StatusFound, url)
}

func (i *Inertia) Share(key string, value interface{}) {
    i.shared[key] = value
}

5. Frontend: Adaptação do Cliente Inertia.js

Configuração com React sem Laravel:

// app.js
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import { InertiaProgress } from '@inertiajs/progress'

createInertiaApp({
  resolve: name => {
    const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true })
    return pages[`./Pages/${name}.jsx`]
  },
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />)
  },
  progress: {
    delay: 250,
    color: '#29d',
  },
})

Tratamento de erros no cliente:

// errors.js
import { router } from '@inertiajs/react'

router.on('error', (event) => {
  const { response } = event.detail
  if (response.status === 409) {
    // Forçar recarregamento da página por conflito de versão
    window.location.reload()
  }
})

6. Autenticação e Sessão sem Laravel

Implementação de login com JWT:

// backend (Express)
const jwt = require('jsonwebtoken')

app.post('/login', (req, res) => {
  const { email, password } = req.body
  // validar credenciais
  const token = jwt.sign({ userId: user.id }, 'secret', { expiresIn: '7d' })

  res.cookie('token', token, { httpOnly: true, sameSite: 'strict' })
  inertia.redirect('/dashboard')(req, res)
})

// Compartilhar dados de sessão
app.use((req, res, next) => {
  if (req.cookies.token) {
    try {
      const decoded = jwt.verify(req.cookies.token, 'secret')
      inertia.share('auth', { user: { name: decoded.name } })
    } catch (e) {
      inertia.share('auth', null)
    }
  }
  next()
})

7. Testes e Debug do Protocolo

Testes unitários para o middleware:

// test/inertia.test.js
const request = require('supertest')
const app = require('../app')

describe('Inertia Middleware', () => {
  test('deve retornar 409 se versão divergir', async () => {
    const res = await request(app)
      .get('/users')
      .set('X-Inertia', 'true')
      .set('X-Inertia-Version', 'old-version')

    expect(res.status).toBe(409)
    expect(res.body.version).toBeDefined()
  })

  test('deve retornar JSON com estrutura correta', async () => {
    const res = await request(app)
      .get('/users')
      .set('X-Inertia', 'true')
      .set('X-Inertia-Version', 'current-version')

    expect(res.status).toBe(200)
    expect(res.body).toHaveProperty('component')
    expect(res.body).toHaveProperty('props')
    expect(res.body).toHaveProperty('url')
  })
})

Debug no navegador:
- Abra o DevTools > Network
- Filtre por X-Inertia header
- Inspecione o JSON de resposta para verificar component, props e version

8. Considerações Finais e Boas Práticas

Quando usar Inertia sem Laravel:
- Times que dominam Node.js, Python ou Go mas querem SPA sem API REST
- Projetos legados que migram para frontend moderno gradualmente
- Situações onde Laravel é overengineering para o backend necessário

Limitações importantes:
- Falta de ecossistema oficial (adapters comunitários podem quebrar)
- Manutenção manual do controle de versão de assets
- Ausência de recursos como defer props ou lazy loading nativos

Roteiro de migração de Laravel para outro stack:
1. Extraia a lógica de negócio do Laravel para um microserviço
2. Implemente o adapter Inertia no novo backend
3. Migre as rotas uma a uma, mantendo o frontend intacto
4. Substitua o Laravel por completo quando todas as rotas estiverem migradas

Para projetos que precisam de máxima produtividade, considere alternativas como HTMX (menos JavaScript) ou SPA puro com API REST (mais flexibilidade). Inertia sem Laravel é uma ponte elegante, mas exige cuidado com a manutenção do protocolo.

Referências