migrate-nextjs
Migrate a Next.js app to pracht: App or Pages Router pages, layouts, middleware, API routes, data fetching, and metadata — plus React→Preact, `className`→`class`, server components→loaders, and manifest wiring. Use for "migrate from next", "convert next.js app", "port from next t
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/migrate-nextjs
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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.
With the Pracht MCP server, prefer its generate/inspect/doctor/verify tools;
inspect_build needs a prior pracht build.
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, 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 works out of the box. <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", …)]). Pracht middleware
is wrap-around (Hono/Koa/Astro shape), so you can await next() and observe
the response — useful 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 |
| 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
Files (pracht)
-
SKILL.md 19.6 KB
--- name: migrate-nextjs version: 1.8.1 description: | Migrate a Next.js app to pracht: App or Pages Router pages, layouts, middleware, API routes, data fetching, and metadata — plus React→Preact, `className`→`class`, server components→loaders, and manifest wiring. Use for "migrate from next", "convert next.js app", "port from next to pracht", "nextjs migration", "switch from next". allowed-tools: - Bash - Read - Write - Edit - Grep - Glob - AskUserQuestion --- # 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. With the Pracht MCP server, prefer its generate/inspect/doctor/verify tools; `inspect_build` needs a prior `pracht build`. ## 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.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, 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 works out of the box. `<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 ```tsx 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: ```tsx 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: ```ts 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 ```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(); }; ``` `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", …)])`. Pracht middleware is wrap-around (Hono/Koa/Astro shape), so you can `await next()` and observe the response — useful 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. ```ts 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 | | 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: ```ts 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()`](https://pracht.resynapse.dev/docs/fonts); 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
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.