Ferramentas de desenvolvimento

Next.js: Migrando do Pages Router para App Router com Estratégia

Como migrar gradualmente aplicações Next.js existentes para App Router sem reescrever tudo, usando funcionalidades híbridas e transição incremental.

17/03/20268 min de leituraDev tools
Next.js: Migrando do Pages Router para App Router com Estratégia

Resumo executivo

Como migrar gradualmente aplicações Next.js existentes para App Router sem reescrever tudo, usando funcionalidades híbridas e transição incremental.

Ultima atualizacao: 17/03/2026

O dilema da transição

O App Router do Next.js (introduzido no Next.js 13) oferece recursos poderosos: React Server Components, streaming, layouts aninhados e paralelismo de dados. Mas migrar uma aplicação Pages Router estabelecida é não-trivial. Códigos em _app.tsx, _document.tsx, getServerSideProps e getStaticProps não funcionam diretamente.

O erro comum é tentar "big bang migration" — migrar tudo de uma vez. Isso meses de desenvolvimento com risco alto. Uma abordagem melhor é migração incremental: ambos os routers coexistem enquanto você move funcionalidades gradualmente.

Arquitetura híbrida: os routers podem coexistir

Next.js 13+ permite Pages Router e App Router no mesmo projeto. Esta funcionalidade foi construída especificamente para migrações.

my-app/
├── app/              # App Router (rotas /app/*)
│   ├── layout.tsx
│   ├── page.tsx
│   └── dashboard/
│       └── layout.tsx
├── pages/            # Pages Router (rotas /pages/*)
│   ├── _app.tsx
│   ├── _document.tsx
│   └── legacy/
│       └── page.tsx   # /legacy atende aqui
└── public/

Regras de precedência:

  1. Rotas em app/ têm precedência sobre pages/
  2. Rotas pages/ só servem se não houver correspondência em app/
  3. Ambos compartilham o mesmo public/ e configuração

Estratégia de migração por fases

Fase 1: Preparação e infraestrutura (1-2 semanas)

typescript// 1. Adiciona App Router mantendo Pages Router
// Cria app/layout.tsx sem quebrar pages/

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="pt-BR">
      <body>
        {/* Header/Footer podem ser compartilhados */}
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}
typescript// 2. Cria app/page.tsx (homepage nova)
// Testa que App Router funciona sem quebrar app/pages/index.tsx

export default function HomePage() {
  return (
    <div>
      <h1>Homepage (App Router)</h1>
    </div>
  );
}
typescript// 3. Configura compartilhamento de estilos
// app/layout.tsx importa estilos globais existentes

import '../styles/globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="pt-BR">
      <body>
        {children}
      </body>
    </html>
  );
}

Fase 2: Migração de páginas públicas (2-4 semanas)

Páginas públicas (homepage, sobre, contato) são bons pontos de partida:

typescript// Antes: pages/index.tsx com getStaticProps
export default function Home({ posts }: { posts: Post[] }) {
  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

export async function getStaticProps() {
  const posts = await fetchPosts();
  return { props: { posts } };
}
typescript// Depois: app/page.tsx com Server Component
export default async function Home() {
  const posts = await fetchPosts();

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Benefício: server components eliminam necessidade de getStaticProps e simplificam código.

Fase 3: Migração de componentes compartilhados (3-6 semanas)

Componentes que funcionam em ambos os routers precisam de atenção especial:

typescript// Componente compatível com ambos os routers
// Usa Client Component apenas quando necessário

'use client'; // Apenas se precisar de hooks/interatividade

export function InteractiveButton({ onClick }: { onClick: () => void }) {
  return (
    <button onClick={onClick}>
      Clique aqui
    </button>
  );
}
typescript// Componente Server Component (padrão no App Router)
// Pode fazer fetch de dados diretamente

export async function PostList() {
  const posts = await fetchPosts();

  return (
    <div>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  );
}

**Heurística para use client:**

  • Componentes com useState, useEffect, event handlers → 'use client'
  • Componentes apenas apresentacionais sem hooks → Server Component (padrão)
  • Componentes com bibliotecas que só funcionam no client → 'use client'

Fase 4: Migração de rotas de negócio (4-8 semanas)

Rotas de negócio (dashboard, checkout, perfil) são mais complexas:

typescript// Antes: pages/dashboard/[id].tsx
export default function DashboardPage({ params }: { params: { id: string } }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/dashboard/${params.id}`)
      .then(res => res.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, [params.id]);

  if (loading) return <Spinner />;

  return <DashboardView data={data} />;
}

export async function getServerSideProps({ params }: GetServerSidePropsContext) {
  // Autenticação acontece aqui
  const session = await getSession();
  if (!session) {
    return { redirect: { destination: '/login', permanent: false } };
  }
  return { props: {} };
}
typescript// Depois: app/dashboard/[id]/page.tsx
export default async function DashboardPage({
  params
}: {
  params: { id: string };
}) {
  // Fetch de dados direto no server
  const data = await fetchDashboardData(params.id);

  return <DashboardView data={data} />;
}

// app/dashboard/[id]/layout.tsx com middleware
import { auth } from '@/app/lib/auth';

export default async function DashboardLayout({
  children,
  params
}: {
  children: React.ReactNode;
  params: { id: string };
}) {
  // Autenticação no layout
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  return (
    <DashboardShell>
      {children}
    </DashboardShell>
  );
}

Padrões de autenticação:

  • Pages Router: getServerSideProps → middleware opcional
  • App Router: middleware ou layout → Server Component auth

Fase 5: Refatoração de API routes (2-4 semanas)

typescript// Antes: pages/api/users.ts
import { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const users = await getUsers();
  res.json(users);
}
typescript// Depois: app/api/users/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const users = await getUsers();
  return NextResponse.json(users);
}

Benefício: API Routes do App Router usam Web Standard Request/Response, mais padronizado.

Padrões de transição específicos

Compartilhando estado entre routers

typescript// shared/store.ts funciona em ambos os routers
import { createStore } from 'zustand';

type Store = {
  user: User | null;
  setUser: (user: User) => void;
};

export const useStore = createStore<Store>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));
typescript// pages/_app.tsx (Pages Router)
function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}
typescript// app/layout.tsx (App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="pt-BR">
      <body>
        <StoreProvider>
          {children}
        </StoreProvider>
      </body>
    </html>
  );
}

Migrando layouts

typescript// Antes: pages/_app.tsx com layout global
export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <Header />
      <main>
        <Component {...pageProps} />
      </main>
      <Footer />
    </>
  );
}
typescript// Depois: app/layout.tsx (layout raiz)
import Header from '@/components/Header';
import Footer from '@/components/Footer';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="pt-BR">
      <body>
        <Header />
        <main>
          {children}
        </main>
        <Footer />
      </body>
    </html>
  );
}

// app/dashboard/layout.tsx (layout aninhado)
import DashboardNav from '@/components/DashboardNav';

export default function DashboardLayout({
  children
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <DashboardNav />
      <div className="dashboard-content">
        {children}
      </div>
    </>
  );
}

Benefício: layouts aninhados permitem UI compartilhada por grupo de rotas sem repetição.

Anti-padrões de migração

Anti-padrão 1: 'use client' em tudo

typescript// RUIM: Todo arquivo tem 'use client'
'use client';

export function StaticContent() {
  return <div>Conteúdo estático</div>;
}

Solução:

typescript// BOM: Remove 'use client', usa Server Component
export function StaticContent() {
  return <div>Conteúdo estático</div>;
}

Anti-padrão 2: Copiar lógica de getServerSideProps

typescript// RUIM: Tenta replicar getServerSideProps
export default async function Page() {
  const { searchParams } = props;
  const data = await fetchData(searchParams);
  return <View data={data} />;
}

// Tentando recriar getInitialProps
export async function generateMetadata() {
  // Isso não é getInitialProps!
}

Solução:

typescript// BOM: Usa Server Component com fetch direto
export default async function Page({
  searchParams
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  const data = await fetchData(searchParams);
  return <View data={data} />;
}

Anti-padrão 3: Ignorar streaming de dados

typescript// RUIM: Busca tudo antes de renderizar
export default async function Page() {
  const posts = await fetchPosts();
  const comments = await fetchComments();
  const analytics = await fetchAnalytics();

  return (
    <View
      posts={posts}
      comments={comments}
      analytics={analytics}
    />
  );
}

Solução:

typescript// BOM: Usa paralelismo de fetch
import PostList from '@/components/PostList';
import CommentList from '@/components/CommentList';
import AnalyticsView from '@/components/AnalyticsView';

export default async function Page() {
  const [posts, comments, analytics] = await Promise.all([
    fetchPosts(),
    fetchComments(),
    fetchAnalytics()
  ]);

  return (
    <div>
      <Suspense fallback={<PostListSkeleton />}>
        <PostList posts={posts} />
      </Suspense>
      <Suspense fallback={<CommentListSkeleton />}>
        <CommentList comments={comments} />
      </Suspense>
      <AnalyticsView analytics={analytics} />
    </div>
  );
}

Benefício: streaming permite renderizar progressivamente enquanto dados carregam.

Testando migração gradualmente

Estratégia de rollout

typescript// Feature flag para controlar migração
const USE_APP_ROUTER = process.env.NEXT_PUBLIC_USE_APP_ROUTER === 'true';

export default function HomePage() {
  if (USE_APP_ROUTER) {
    // Usa componente App Router
    return <AppRouterHomePage />;
  }

  // Usa componente Pages Router original
  return <PagesRouterHomePage />;
}

Monitoramento de bugs

typescript// A/B test entre routers
import { useFeatureFlag } from '@/hooks/useFeatureFlag';

export default function HomePage() {
  const useAppRouter = useFeatureFlag('app-router-homepage');

  if (useAppRouter) {
    return <AppRouterHomePage />;
  }

  return <PagesRouterHomePage />;
}
typescript// Track performance por router
export function withRouterTracking<P extends object>(
  Component: React.ComponentType<P>,
  routerType: 'app' | 'pages'
) {
  return function TrackedComponent(props: P) {
    useEffect(() => {
      metrics.track('page_load', {
        router_type: routerType,
        page: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
      });
    }, []);

    return <Component {...props} />;
  };
}

Checklist de conclusão

Por funcionalidade migrada, verifique:

  • [ ] Renderização igual visual e funcionalmente
  • [ ] SEO (metadata, Open Graph) preservado
  • [ ] Autenticação funcionando corretamente
  • [ ] Performance não degradou (Core Web Vitals)
  • [ ] Logs de erro não aumentaram
  • [ ] Links internos funcionam
  • [ ] Formulários submitem corretamente

Conclusão

Migrar do Pages Router para App Router não precisa ser um evento traumático de big bang. A arquitetura híbrida do Next.js permite transição incremental: novos recursos em App Router, código legado em Pages Router, ambos coexistindo enquanto você migra funcionalidade por funcionalidade.

A estratégia certa depende da complexidade da aplicação e da tolerância a risco de mudança. Aproache incremental reduz risco técnico: você pode testar cada migração isoladamente, fazer rollback se necessário e aprender com problemas antes de migrar código crítico.

Mais importante que a velocidade de migração é a qualidade da transição. Páginas públicas primeiro, componentes compartilhados depois, rotas de negócio por último. Cada fase adiciona complexidade — certifique-se que a anterior está estável antes de avançar.

O resultado final vale o esforço: Server Components, layouts aninhados, streaming de dados e paralelismo de fetch tornam o aplicativo mais rápido e eficiente. A migração é investimento em performance e desenvolvedor experience que se paga ao longo do tempo.


Precisa migrar sua aplicação Next.js para App Router com previsibilidade técnica? Fale com especialistas da Imperialis em React e Next.js para desenhar e executar uma estratégia de migração incremental segura.

Fontes

Leituras relacionadas