| name | migrate-nextjs |
| version | 1.0.0 |
| description | Migrate a Next.js application to Pracht. Converts App Router pages, layouts,
middleware, API routes, data fetching, and metadata to pracht equivalents.
Handles React→Preact, className→class, server components→loaders, and
manifest wiring.
Use when asked to "migrate from next", "convert next.js app", "port from
next to pracht", "nextjs migration", or "switch from next".
|
| allowed-tools | ["Bash","Read","Write","Edit","Grep","Glob","AskUserQuestion"] |
Migrate Next.js to Pracht
Systematically migrate a Next.js application (App Router or Pages Router) to pracht — a full-stack Preact framework built on Vite.
Step 0: Assess the source project
Before touching any code, understand what you're migrating:
- Read
next.config.js / next.config.mjs / next.config.ts for custom config.
- Read
package.json for React/Next versions and dependencies.
- Scan the directory structure:
app/ → App Router (Next 13+)
pages/ → Pages Router (legacy)
middleware.ts → edge middleware
app/api/ or pages/api/ → API routes
- Identify rendering patterns in use:
"use client" directives → client components
async page/layout components → server components with data fetching
generateStaticParams → static generation
generateMetadata / metadata export → head management
- Server Actions (
"use server") → mutations
- Note third-party integrations (auth, CMS, DB, analytics).
Ask the user to confirm the migration scope if the project is large (>20 routes).
Fast Path: Pages Router Projects
If the source Next.js project uses the pages router (pages/ directory), pracht's pagesDir plugin option provides a near-drop-in migration:
- Set
pracht({ pagesDir: "/src/pages" }) in vite.config.ts
- Copy
pages/ to src/pages/
- Convert
_app.tsx to pracht shell format (Shell export + children prop)
- Convert
getServerSideProps/getStaticProps to loader exports
- Add
export const RENDER_MODE = "ssg" to static pages, "ssr" for dynamic (default is "ssr")
- Run dev server, iterate on errors
- Optionally run
generateRoutesFile to eject to explicit manifest
For pages router projects, you can skip manual manifest wiring entirely (Phase 7 below).
Concept Mapping
| Next.js | Pracht | Notes |
|---|
pages/ directory | pagesDir plugin option | Auto-discovers routes from file system |
app/page.tsx | src/routes/*.tsx + route() in manifest | File is a module; wiring is explicit |
app/layout.tsx | src/shells/*.tsx + shells in defineApp | Shells are named, not directory-nested |
app/loading.tsx | No direct equivalent | Use Suspense in component if needed |
app/error.tsx | ErrorBoundary export in route module | Same concept, different wiring |
app/not-found.tsx | 404 route: route("*", () => import("./routes/not-found.tsx")) | Catch-all at end of routes array |
middleware.ts | src/middleware/*.ts + middleware in defineApp | Named, applied per route/group |
app/api/*/route.ts | src/api/*.ts with GET/POST exports | Auto-discovered, no manifest entry |
generateStaticParams | getStaticPaths() export | Returns RouteParams[] of param objects |
generateMetadata | head() export | Returns { title, meta } |
| Server Components | loader() export | Data fetching moves to loader; component is always a Preact component |
"use server" actions | API routes + <Form> / fetch | Mutations move to src/api/*; return Response objects |
useRouter() (next/navigation) | useNavigate() from pracht | Accepts paths or typed route targets after pracht typegen |
useSearchParams() | useRouteData() or parse from loader args | Loaders receive url with searchParams |
useParams() | useRouteData() or params in loader | Params flow through loader data |
next/link <Link> | <Link route="..."> or plain <a> | Prefer typed <Link> for known app routes after pracht typegen; plain anchors still work |
next/link prefetch={false} | <Link prefetch="none"> | Pracht prefetches on hover/focus by default; also "viewport", "render" |
useLinkStatus() / pending UI | useNavigation() | { state, location, formData } — powers progress bars and optimistic UI |
next/image | Standard <img> | Use vite-imagetools plugin if optimization needed |
next/head or Metadata API | head() export on route/shell | Per-route and per-shell head merging |
className | class | Preact uses class attribute |
React.useState etc. | import { useState } from "preact/hooks" | Preact hooks API is compatible |
React.useEffect | import { useEffect } from "preact/hooks" | Same API |
import React from "react" | Remove — no import needed | Pracht's Vite plugin handles JSX automatically |
Migration Procedure
Phase 1: Project setup
-
Initialize the pracht project structure:
src/
routes.ts # Route manifest
routes/ # Route modules
shells/ # Layout shells
middleware/ # Server-side middleware
api/ # API routes
-
Create vite.config.ts:
import { defineConfig } from "vite";
import { pracht } from "@pracht/vite-plugin";
export default defineConfig({
plugins: [pracht()],
});
-
Update package.json:
- Replace
react, react-dom → preact
- Replace
next → pracht, @pracht/vite-plugin, @pracht/adapter-node (or target adapter)
- Update scripts:
dev → pracht dev, build → pracht build, start → node dist/server/server.js (Node.js) or a platform-specific deploy command; add preview → pracht preview to serve the production build locally
-
Remove Next.js config files: next.config.*, next-env.d.ts, .next/
-
If tsconfig.json has "jsx": "preserve", change to "jsx": "react-jsx" and add "jsxImportSource": "preact".
Phase 2: Convert layouts → shells
For each layout.tsx:
Next.js:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body className="root">{children}</body>
</html>
);
}
Pracht:
import type { ShellProps } from "@pracht/core";
export function Shell({ children }: ShellProps) {
return (
<div class="root">
<main>{children}</main>
</div>
);
}
export function head() {
return { title: "My App" };
}
Key differences:
- Pracht shells do NOT render
<html>, <head>, or <body> — the framework owns the HTML document.
- Use
class not className.
- Register in
defineApp({ shells: { main: "./shells/main.tsx" } }).
Phase 3: Convert pages → route modules
For each page.tsx:
Next.js (Server Component with data):
async function getData() {
const res = await fetch("https://api.example.com/data");
return res.json();
}
export default async function Page() {
const data = await getData();
return <div className="page">{data.title}</div>;
}
export async function generateMetadata() {
const data = await getData();
return { title: data.title };
}
Pracht:
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
export async function loader(_args: LoaderArgs) {
const res = await fetch("https://api.example.com/data");
return res.json();
}
export function head({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
return { title: data.title };
}
export default function Page({ data }: RouteComponentProps<typeof loader>) {
return <div class="page">{data.title}</div>;
}
Key transforms:
- Server-side data fetching →
loader() export
generateMetadata → head() export
- Keep
export default function Page as the page component
className → class
- No
async components — data comes via props from loader
Phase 4: Convert client components
Next.js:
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Pracht:
import { useState } from "preact/hooks";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Key transforms:
- Remove
"use client" directive — not needed in pracht
import { ... } from "react" → import { ... } from "preact/hooks" or import { ... } from "preact/compat"
import { ... } from "react-dom" → import { ... } from "preact/compat"
Phase 5: Convert API routes
Next.js (app/api/users/route.ts):
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const users = await getUsers();
return NextResponse.json(users);
}
Pracht (src/api/users.ts):
import type { BaseRouteArgs } from "@pracht/core";
export function GET({ request }: BaseRouteArgs) {
const users = await getUsers();
return Response.json(users);
}
Key transforms:
NextRequest → standard Request (via BaseRouteArgs)
NextResponse.json() → Response.json() (Web standard)
- Dynamic segments:
app/api/users/[id]/route.ts → src/api/users/[id].ts
- No manifest wiring needed — auto-discovered
Phase 6: Convert middleware
Next.js (middleware.ts):
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session) return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };
Pracht (src/middleware/auth.ts):
import { redirect, type MiddlewareFn } from "@pracht/core";
export const middleware: MiddlewareFn = async ({ request }, next) => {
const session = request.headers.get("cookie")?.includes("session");
if (!session) return redirect("/login", { request });
return next();
};
Then apply it in the manifest:
group({ middleware: ["auth"] }, [
route("/dashboard", () => import("./routes/dashboard.tsx"), { render: "ssr" }),
]);
Key transforms:
- Path matching moves from
config.matcher to manifest group/route assignment
NextResponse.redirect() → return redirect("/path", { request })
NextResponse.next() → return next()
- Pracht middleware is wrap-around (Hono/Koa/Astro shape), so you can
also
await next() and observe the response — useful for tracing.
Phase 7: Wire the route manifest
Note: For pages router projects using pagesDir, this phase is automatic. Skip to Phase 8.
Build src/routes.ts mapping every migrated page. Module references accept () => import("./path") (enables IDE navigation) or plain "./path" strings — both work:
import { defineApp, group, route } from "@pracht/core";
export const app = defineApp({
shells: {
main: () => import("./shells/main.tsx"),
},
middleware: {
auth: () => import("./middleware/auth.ts"),
},
routes: [
group({ shell: "main" }, [
route("/", () => import("./routes/home.tsx"), { render: "ssg" }),
route("/about", () => import("./routes/about.tsx"), { render: "ssg" }),
route("/dashboard", () => import("./routes/dashboard.tsx"), {
render: "ssr",
middleware: ["auth"],
}),
route("/blog/:slug", () => import("./routes/blog-post.tsx"), { render: "isg" }),
route("*", () => import("./routes/not-found.tsx"), { render: "ssr" }),
]),
],
});
Choose render modes based on the Next.js original:
- Static pages (no data fetching, or
generateStaticParams) → "ssg"
- Dynamic pages (
cookies(), headers(), per-request data) → "ssr"
- ISR pages (
revalidate option) → "isg" with timeRevalidate(seconds)
- Client-only pages →
"spa"
Phase 8: Handle common patterns
next/link → typed <Link> or plain <a>
After manifest wiring is in place, run pracht typegen and prefer route-id based links for known app routes:
import Link from "next/link";
<Link href={`/products/${id}`}>Product</Link>
import { Link } from "@pracht/core";
<Link route="product" params={{ id }}>Product</Link>
Plain anchors still work for simple, external, or user-provided URLs because the client router intercepts same-origin <a> clicks:
<a href="/about">About</a>
<Link> also accepts navigation-behavior props: prefetch ("none" | "intent" | "viewport" | "render", default "intent" on hover/focus — the equivalent of next/link's prefetch tuning), preserveScroll (skip the scroll-to-top reset), and viewTransition (wrap the navigation in document.startViewTransition() where supported). Scroll restoration on back/forward works out of the box, like Next.js.
next/image → <img>
import Image from "next/image";
<Image src="/photo.jpg" width={500} height={300} alt="Photo" />
<img src="/photo.jpg" width={500} height={300} alt="Photo" />
useRouter → navigation
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/dashboard");
import { useNavigate } from "@pracht/core";
const navigate = useNavigate();
navigate("/dashboard");
navigate({ route: "dashboard" });
Server Actions → API routes
"use server";
async function createPost(formData: FormData) {
await db.insert({ title: formData.get("title") });
revalidatePath("/posts");
}
export async function POST({ request }: ApiRouteArgs) {
const form = await request.formData();
await db.insert({ title: form.get("title") });
return new Response(null, {
status: 303,
headers: { location: "/posts" },
});
}
cookies() / headers() → loader args
import { cookies, headers } from "next/headers";
const session = cookies().get("session");
const ua = headers().get("user-agent");
export async function loader({ request }: LoaderArgs) {
const cookies = request.headers.get("cookie");
const ua = request.headers.get("user-agent");
return {
};
}
Phase 9: Clean up
- Remove all
"use client" and "use server" directives.
- Remove all
next/* imports (next/link, next/image, next/navigation, next/headers).
- Search for remaining
className → replace with class.
- Search for remaining
react imports → replace with preact equivalents.
- Remove
next.config.*, next-env.d.ts, .next/ directory.
- Run
pracht typegen if route ids/paths changed or if you converted links/navigation to typed route ids.
- Run the dev server (
pracht dev) and fix any remaining issues.
Dependency Mapping
| Next.js package | Pracht equivalent |
|---|
next | pracht, @pracht/vite-plugin, @pracht/adapter-node |
react | preact |
react-dom | preact |
@next/font | CSS @font-face or fontsource packages |
@next/mdx | @mdx-js/rollup (Vite plugin) |
next-auth | Direct integration in middleware/loaders |
next/og | @vercel/og or custom solution |
React Library Compatibility
Many React libraries work with Preact via preact/compat. Add aliases in vite.config.ts if needed:
resolve: {
alias: {
"react": "preact/compat",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime",
}
}
Note: The pracht Vite plugin sets these aliases automatically. Only add manual aliases if a dependency doesn't resolve correctly.
Rules
- Always read the Next.js source before converting — understand what each file does.
- Migrate in phases: setup → shells → routes → API → middleware → manifest → cleanup.
- Prefer the simplest pracht equivalent. Don't over-engineer the migration.
- Identify React libraries that need
preact/compat aliasing and flag them.
- After migration, run
pracht dev to verify. Fix errors iteratively.
- If a Next.js feature has no pracht equivalent, explain the gap and suggest alternatives.
- Use Preact idioms:
class not className, no React import needed, preact/hooks for hooks.
$ARGUMENTS