agentleFS
JoviDeCroockJoviDeCroock/pracht/skills/migrate-nextjs/SKILL.md

migrate-nextjs

Skill106 starsChanged 7 months agoMITView on GitHub

Migrate a Next.js app (App or Pages Router) to pracht: pages, layouts, middleware, API routes, data fetching, metadata, and React to Preact. Use for "migrate from Next.js", "convert my Next app", "port from next to pracht", "switch from next".

  • Reads credentials

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 0 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read next.config.* and package.json (React/Next versions, deps), then map the tree: app/ (App Router), pages/ (Pages Router), …

    line 31
  2. Very specific:

    Run the dev server, iterate, and optionally eject later with generateRoutesFile.

    line 66
  3. Very specific:

    Create src/routes.ts (manifest), src/routes/, src/shells/, src/middleware/, src/api/, and a vite.config.ts whose plugins array contains …

    line 107
  4. Very specific:

    Delete next.config.*, next-env.d.ts, .next/.

    line 120
  5. Very specific:

    Islands: if the source is mostly server components with a handful of "use client" leaves, do not silently regress those pages to full-page …

    line 173
  6. Very specific:

    Links and navigation: After the manifest exists, run pracht typegen and switch known app routes to route ids: <Link route="product" …

    line 264

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Migrate Next.js to pracht

Migrate in phases: setup → shells → routes → client components → API → middleware → manifest → patterns → cleanup. Read each Next.js source file before converting it; never infer from the filename. Prefer the simplest pracht equivalent, and when a Next.js feature has no equivalent, say so and propose an alternative instead of inventing one.

MCP: if pracht dev-mcp is registered, prefer its inspect_*, generate_*, doctor, and verify tools to the CLI.

Step 0: Assess the source

Read next.config.* and package.json (React/Next versions, deps), then map the tree: app/ (App Router), pages/ (Pages Router), middleware.ts, app/api/ or pages/api/. Note which patterns are in use — "use client", async server components, generateStaticParams, generateMetadata/metadata, "use server" actions — and the third-party integrations (auth, CMS, DB, analytics). Confirm scope with the user if the app has more than ~20 routes.

Agent tools alone do not require a router migration; use the standalone host: https://pracht.resynapse.dev/docs/standalone-capabilities.

Fast path: Pages Router

pagesDir makes a pages-router source near-drop-in — Phase 7 is then automatic:

  1. pracht({ pagesDir: "/src/pages" }) in vite.config.ts; copy pages/ to src/pages/.
  2. _app.tsx → pracht shell shape (Shell export taking children). A subdirectory _app scopes a shell to that subtree (blog/_app.tsx → "pages:blog"), where App Router layout.tsx lands — but shells replace rather than nest, so it repeats the chrome, head(), and headers() it needs.
  3. getServerSideProps/getStaticProps → loader export.
  4. export const RENDER_MODE = "ssg" on static pages ("ssr" is the default). For time-revalidated pages export RENDER_MODE = "isg" plus a positive integer REVALIDATE in seconds; webhook policies require ejecting to a manifest.
  5. middleware.ts → a root-level src/pages/_middleware.ts exporting a MiddlewareFn (Phase 6). It runs on every page route; API routes are not wrapped. Move config.matcher checks into the body and compare stripBase(url.pathname). A nested _middleware, a _middleware/ directory, and a missing export are build/doctor/verify errors.
  6. Other _-prefixed files and directories are reserved: pracht ignores the subtree. Capabilities live in src/capabilities/ (auto-discovered, self-named); agents / constraints in src/pages/_app.config.ts.
  7. Run the dev server, iterate, and optionally eject later with generateRoutesFile.

Concept mapping

Next.jsprachtNotes
pages/ directorypagesDir plugin optionAuto-discovers routes from the file system
app/page.tsxsrc/routes/*.tsx + route() in manifestFile is a module; wiring is explicit
app/layout.tsxsrc/shells/*.tsx + shells in defineApp (pages: _app.tsx per directory)Named shells; a directory _app replaces its parent. Layout data: shell loader + useShellData()
app/loading.tsxLoading export on the shellSSR placeholder for SPA routes until the client router takes over
app/error.tsxErrorBoundary export in route moduleSame concept, different wiring
app/not-found.tsxnotFound: in defineApp (or pages/404.tsx in pagesDir mode)Not a route — never matches a URL, so it cannot shadow static assets
middleware.tssrc/middleware/*.ts + middleware in defineApp (or src/pages/_middleware.ts in pagesDir mode)Named, applied per route/group; the pages-mode file runs on every page route
app/api/*/route.tssrc/api/*.ts with GET/POST exportsAuto-discovered, no manifest entry
generateStaticParamsgetStaticPaths() exportReturns RouteParams[] of param objects
generateMetadatahead() exportReturns { title, meta }
Server Componentsloader() exportData fetching moves to the loader; the component is a Preact component
"use server" actionsAPI routes + <Form> / fetchMutations move to src/api/*; return Response objects
"use client" (few, mostly-server app)hydration: "islands" + src/islands/Only islands ship JS; see Phase 4
revalidatePath / res.revalidate()webhookRevalidate() + POST /__pracht/revalidateOn-demand ISG; combinable with timeRevalidate(seconds)
useRouter() (next/navigation)useNavigate()Takes a path, or { route: "id" } after pracht typegen
useSearchParams()useSearchParams()Read-only; set with useSetSearchParams(). SSG gets the browser query after hydration. For typed values export a search schema and read args.search / useSearch()
useParams()useParams()Direct equivalent; also params in loader args
next/link <Link><Link route="..." params={{…}}> or plain <a>Prefer typed <Link> after pracht typegen; the router intercepts same-origin anchors
next/link prefetch={false}<Link prefetch="none">Default "intent" (hover/focus); also "viewport", "render"
useLinkStatus() / pending UIuseNavigation(){ state, location, formData } — progress bars, optimistic UI
next/image<Image> from @pracht/imageResponsive srcsets; Node, Cloudflare, Vercel, Netlify, or passthrough loaders
next/head or Metadata APIhead() export on route/shellPer-route and per-shell head merging
next/script <Script><Script> from @pracht/corebeforeHydration (≈ beforeInteractive), afterHydration (≈ afterInteractive, default), idle (≈ lazyOnload), visible
cookies() / headers()request.headers in loader/middleware/API argsNo separate API — read the standard Request
classNameclassPreact uses the class attribute
react / react-dom importspreact/hooks, preact/compatSame hook APIs
import React from "react"RemoveThe Vite plugin handles JSX

Scroll restoration on back/forward is automatic. <Link> also takes preserveScroll (skip the scroll-to-top reset) and viewTransition (wrap the navigation in document.startViewTransition() where supported).

Phase 1: Project setup

Create src/routes.ts (manifest), src/routes/, src/shells/, src/middleware/, src/api/, and a vite.config.ts whose plugins array contains pracht() from @pracht/vite-plugin. Then:

  • Dependencies: drop react/react-dom for preact; drop next for @pracht/core (runtime), @pracht/cli (the pracht bin), @pracht/vite-plugin, and a target adapter such as @pracht/adapter-node. There is no package named pracht. Add @pracht/image if the app used next/image, and sharp only for the built-in Node optimization endpoint or build-time ?pracht imports.
  • Scripts: dev → pracht dev, build → pracht build, start → node dist/server/server.js (Node) or the platform deploy command; add preview → pracht preview.
  • Delete next.config.*, next-env.d.ts, .next/.
  • In tsconfig.json, "jsx": "preserve" → "jsx": "react-jsx" with "jsxImportSource": "preact".

Phase 2: Layouts → shells

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" };
}

Shells must NOT render <html>, <head>, or <body> — the framework owns the document, so move anything from RootLayout's document tags into head(). Register as defineApp({ shells: { main: "./shells/main.tsx" } }).

Phase 3: Pages → route modules

An async page that fetches and exports generateMetadata becomes three exports — the fetch moves to loader, the metadata to head, the JSX stays in the default export:

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>;
}

Components are never async — data arrives as the data prop.

Phase 4: Client components

Drop the "use client" directive (pracht has no such concept), and repoint imports: react → preact/hooks for hooks, react/react-dom → preact/compat for everything else. The component body is otherwise unchanged.

Islands: if the source is mostly server components with a handful of "use client" leaves, do not silently regress those pages to full-page hydration. Set hydration: "islands" on the route (or export const HYDRATION = "islands" in pages mode) and move the interactive components into src/islands/ — the rest renders as inert HTML.

Phase 5: API routes

app/api/users/route.ts → src/api/users.ts, dynamic segments included (app/api/users/[id]/route.ts → src/api/users/[id].ts). Handlers take ApiRouteArgs and use web standards throughout:

import type { ApiRouteArgs } from "@pracht/core";

export async function GET({ request }: ApiRouteArgs) {
  return Response.json(await getUsers());
}

NextRequest → the standard Request from ApiRouteArgs; NextResponse.json() → Response.json(). No manifest wiring — API routes are auto-discovered.

Phase 6: Middleware

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();
};

NextResponse.redirect() → return redirect("/path", { request }); NextResponse.next() → return next(). Path matching moves out of config.matcher and into manifest assignment: group({ middleware: ["auth"] }, [route("/dashboard", …)]). Middleware wraps the handler, so await next() returns the response for tracing.

In pagesDir mode the same MiddlewareFn goes in a root-level src/pages/_middleware.ts, applied to every page route (API routes stay unwrapped). Do not migrate a per-request auth matcher onto an ssg/isg page and call it protected: that document runs middleware only at build/revalidation, with a sanitized request. Keep such pages ssr/spa, or keep a separately verified edge gate. Pure static exports have no request runtime.

Phase 7: Route manifest

Skip this phase for pagesDir projects. Prefer pracht generate route --path ... --render ... (plus --shell/--middleware/ --loader) per page — it creates a wired skeleton and updates src/routes.ts — then port the Next.js bodies into the generated files. Hand-write manifest entries only for shapes the generator cannot express.

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("/dashboard", () => import("./routes/dashboard.tsx"), {
        render: "ssr",
        middleware: ["auth"],
      }),
      route("/blog/:slug", () => import("./routes/blog-post.tsx"), { render: "isg" }),
    ]),
  ],
  notFound: { component: () => import("./routes/not-found.tsx"), shell: "main" },
});

Module references accept () => import("./path") (better IDE navigation) or a plain "./path" string. Pick render modes from the Next.js original:

Next.js originalRender mode
No data fetching, or generateStaticParams"ssg"
cookies(), headers(), per-request data"ssr"
revalidate option"isg" + timeRevalidate(seconds)
revalidatePath / res.revalidate()"isg" + webhookRevalidate(), triggered by POST /__pracht/revalidate
Client-only"spa"

Phase 8: Remaining patterns

Links and navigation. After the manifest exists, run pracht typegen and switch known app routes to route ids: <Link route="product" params={{ id }}> and navigate({ route: "dashboard" }). Plain <a href="/about"> and navigate("/dashboard") keep working for simple, external, or user-provided URLs.

Images. <Image> from @pracht/image takes the same width/height/fill/sizes/quality and priority intent — preserve them. Pick the loader for the deployment target:

TargetLoader
NodecreateImageHandler() from @pracht/image/node + sharp; set its localOrigin to the same trusted value as nodeAdapter({ canonicalOrigin })
Cloudflare WorkerscloudflareLoader — never bundle the Node handler, sharp does not run in Workers
Vercel EdgevercelLoader, with Vercel's allowed image sizes aligned to the pracht breakpoints
NetlifynetlifyLoader (Netlify Image CDN)
Static hostspassthroughLoader

Static imports and blur placeholders migrate too: import photo from "./photo.jpg" → "./photo.jpg?pracht", add prachtImage() (from @pracht/image/vite) to the Vite plugins, reference the @pracht/image/client types once in a .d.ts, and keep <Image src={photo} placeholder="blur" /> as-is — the import supplies width/height/blurDataURL exactly like Next's static imports, though pracht's blur is CSS-only (no fade, no inline handlers). Where next/image produced files during a static export, use ?pracht&pracht-static: it emits cached responsive WebP variants and bypasses the runtime loader while keeping plain hydration-free <img> markup. For relative images in Markdown, defineMarkdownCollection() from @pracht/markdown applies the same pipeline to ![alt](./photo.jpg). Leave public/ and remote URLs unchanged, and use an absolute Vite base for static variants. See https://pracht.resynapse.dev/docs/images.

Server Actions. A "use server" mutation becomes an API route; the revalidatePath half becomes an authenticated webhook call:

import { withBase, type ApiRouteArgs } from "@pracht/core";

export async function POST({ request }: ApiRouteArgs) {
  const form = await request.formData();
  await db.insert({ title: form.get("title") });
  await fetch(new URL(withBase("/__pracht/revalidate"), request.url), {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.PRACHT_REVALIDATE_TOKEN}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ paths: ["/posts"] }),
  });
  return new Response(null, { status: 303, headers: { location: withBase("/posts") } });
}

That call only does something if /posts is render: "isg" and opts in with revalidate: webhookRevalidate() (or [timeRevalidate(seconds), webhookRevalidate()], both imported from @pracht/core), and PRACHT_REVALIDATE_TOKEN is set in the runtime environment. If /posts is a plain SSR route, drop the call — the redirect re-renders it fresh.

Phase 9: Clean up and verify

Sweep for leftovers: "use client"/"use server" directives, next/* imports, className, react imports, and next.config.*/next-env.d.ts/ .next/. Run pracht typegen if route ids or paths changed, then pracht dev and fix errors iteratively.

Dependency mapping

Next.js packagepracht equivalent
next@pracht/core + @pracht/cli + @pracht/vite-plugin + a target adapter
next/image@pracht/image
react, react-dompreact
next/font/localImport WOFF2 with ?url&no-inline into defineFont(); register head().fonts, apply font.className/font.style
next/font/googleSelf-host WOFF2 in src/fonts/, then follow next/font/local; pracht never fetches fonts at build time
@next/mdx@mdx-js/rollup (Vite plugin)
next-authDirect integration in middleware/loaders
next/og@vercel/og or a custom solution

Most React libraries work through preact/compat, and the pracht Vite plugin already aliases react/react-dom/react/jsx-runtime for you. Add manual resolve.alias entries only when a dependency still fails to resolve — and flag those libraries to the user.

$ARGUMENTS

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.