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
In this repository33 other files it gives its agents.
- AGENTS.mdAGENTS.md
- add-authSkill
- add-capabilitiesSkill
- add-contentSkill
- add-dbSkill
- add-i18nSkill
- add-imagesSkill
- add-observabilitySkill
- add-openapiSkill
- audit-a11ySkill
- audit-agent-surfaceSkill
- audit-authSkill
- audit-bundlesSkill
- audit-csrfSkill
- audit-depsSkill
- audit-headersSkill
- audit-islandsSkill
- audit-loadersSkill
- audit-redirectsSkill
- audit-secretsSkill
- audit-seoSkill
- audit-shellsSkill
- configure-isgSkill
- pracht-debugSkill
- pracht-deploySkill
- pracht-scaffoldSkill
- pracht-test-apiSkill
- pre-deploySkill
- scaffold-e2eSkill
- scaffold-testsSkill
- tune-render-modeSkill
- typed-routesSkill
- upgrade-prachtSkill
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
- Very specific: line 31
Read next.config.* and package.json (React/Next versions, deps), then map the tree: app/ (App Router), pages/ (Pages Router), …
- Very specific: line 66
Run the dev server, iterate, and optionally eject later with generateRoutesFile.
- Very specific: line 107
Create src/routes.ts (manifest), src/routes/, src/shells/, src/middleware/, src/api/, and a vite.config.ts whose plugins array contains …
- Very specific: line 120
Delete next.config.*, next-env.d.ts, .next/.
- Very specific: line 173
Islands: if the source is mostly server components with a handful of "use client" leaves, do not silently regress those pages to full-page …
- Very specific: line 264
Links and navigation: After the manifest exists, run pracht typegen and switch known app routes to route ids: <Link route="product" …
≈ “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.
Files
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:
pracht({ pagesDir: "/src/pages" })invite.config.ts; copypages/tosrc/pages/._app.tsx→ pracht shell shape (Shellexport takingchildren). A subdirectory_appscopes a shell to that subtree (blog/_app.tsx→"pages:blog"), where App Routerlayout.tsxlands — but shells replace rather than nest, so it repeats the chrome,head(), andheaders()it needs.getServerSideProps/getStaticProps→loaderexport.export const RENDER_MODE = "ssg"on static pages ("ssr"is the default). For time-revalidated pages exportRENDER_MODE = "isg"plus a positive integerREVALIDATEin seconds; webhook policies require ejecting to a manifest.middleware.ts→ a root-levelsrc/pages/_middleware.tsexporting aMiddlewareFn(Phase 6). It runs on every page route; API routes are not wrapped. Moveconfig.matcherchecks into the body and comparestripBase(url.pathname). A nested_middleware, a_middleware/directory, and a missing export are build/doctor/verify errors.- Other
_-prefixed files and directories are reserved: pracht ignores the subtree. Capabilities live insrc/capabilities/(auto-discovered, self-named);agents/constraintsinsrc/pages/_app.config.ts. - Run the dev server, iterate, and optionally eject later with
generateRoutesFile.
Concept mapping
| Next.js | pracht | Notes |
pages/ directory | pagesDir plugin option | Auto-discovers routes from the 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 (pages: _app.tsx per directory) | Named shells; a directory _app replaces its parent. Layout data: shell loader + useShellData() |
app/loading.tsx | Loading export on the shell | SSR placeholder for SPA routes until the client router takes over |
app/error.tsx | ErrorBoundary export in route module | Same concept, different wiring |
app/not-found.tsx | notFound: in defineApp (or pages/404.tsx in pagesDir mode) | Not a route — never matches a URL, so it cannot shadow static assets |
middleware.ts | src/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.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 the loader; the component is a Preact component |
"use server" actions | API routes + <Form> / fetch | Mutations 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/revalidate | On-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 UI | useNavigation() | { state, location, formData } — progress bars, optimistic UI |
next/image | <Image> from @pracht/image | Responsive srcsets; Node, Cloudflare, Vercel, Netlify, or passthrough loaders |
next/head or Metadata API | head() export on route/shell | Per-route and per-shell head merging |
next/script <Script> | <Script> from @pracht/core | beforeHydration (≈ beforeInteractive), afterHydration (≈ afterInteractive, default), idle (≈ lazyOnload), visible |
cookies() / headers() | request.headers in loader/middleware/API args | No separate API — read the standard Request |
className | class | Preact uses the class attribute |
react / react-dom imports | preact/hooks, preact/compat | Same hook APIs |
import React from "react" | Remove | The 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-domforpreact; dropnextfor@pracht/core(runtime),@pracht/cli(theprachtbin),@pracht/vite-plugin, and a target adapter such as@pracht/adapter-node. There is no package namedpracht. Add@pracht/imageif the app usednext/image, andsharponly for the built-in Node optimization endpoint or build-time?prachtimports. - Scripts:
dev→pracht dev,build→pracht build,start→node dist/server/server.js(Node) or the platform deploy command; addpreview→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 original | Render 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:
| Target | Loader |
| Node | createImageHandler() from @pracht/image/node + sharp; set its localOrigin to the same trusted value as nodeAdapter({ canonicalOrigin }) |
| Cloudflare Workers | cloudflareLoader — never bundle the Node handler, sharp does not run in Workers |
| Vercel Edge | vercelLoader, with Vercel's allowed image sizes aligned to the pracht breakpoints |
| Netlify | netlifyLoader (Netlify Image CDN) |
| Static hosts | passthroughLoader |
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 . 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 package | pracht equivalent |
next | @pracht/core + @pracht/cli + @pracht/vite-plugin + a target adapter |
next/image | @pracht/image |
react, react-dom | preact |
next/font/local | Import WOFF2 with ?url&no-inline into defineFont(); register head().fonts, apply font.className/font.style |
next/font/google | Self-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-auth | Direct 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
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
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.

