Migrating from Next.js
A practical guide to moving your Next.js App Router project to pracht. Covers routing, data loading, rendering modes, middleware, layouts, and API routes โ with side-by-side code examples.
Overview
Next.js and pracht share many of the same concepts โ server rendering, file-based conventions, loaders, middleware โ but pracht takes a more explicit approach. This guide walks through the key differences so you can migrate incrementally.
| Concept | Next.js (App Router) | pracht |
|---|---|---|
| UI library | React | Preact |
| Bundler | Turbopack / Webpack | Vite |
| Routing | File-system conventions | Explicit manifest (src/routes.ts) |
| Layouts | layout.tsx nesting |
Named shells |
| Data fetching | async Server Components, fetch |
loader / action exports |
| Rendering modes | Per-segment (dynamic, revalidate) |
Per-route (ssg, ssr, isg, spa) |
| Middleware | Single middleware.ts at root |
Named middleware, per-route or per-group |
| API routes | app/api/**/route.ts |
src/api/**/*.ts |
| Deployment | Vercel-first | Adapter-based (Node, Cloudflare, Vercel) |
React โ Preact
Preact is API-compatible with React for the vast majority of components. The main changes:
- Replace imports โ
reactโpreactandreact-domโpreact/compat classNameโclassโ Preact supports both, butclassis idiomatic- No Server Components โ pracht uses loaders for server-side data, not
asynccomponents - Hooks โ Import from
preact/hooksinstead ofreact
// Next.js
import { useState } from "react";
// pracht
import { useState } from "preact/hooks";[!NOTE] If you have a large component library built for React, you can use `preact/compat` as a drop-in alias during migration. Configure it in your `vite.config.ts` with `resolve.alias`.
Routing
File-system โ Manifest
Next.js derives routes from the file system. pracht uses an explicit src/routes.ts manifest:
# Next.js file structure
app/
page.tsx โ /
about/page.tsx โ /about
blog/[slug]/page.tsx โ /blog/:slug
(auth)/login/page.tsx โ /login (route group)// pracht equivalent
import { defineApp, group, route } from "@pracht/core";
export const app = defineApp({
shells: {
public: "./shells/public.tsx",
auth: "./shells/auth.tsx",
},
routes: [
group({ shell: "public" }, [
route("/", "./routes/home.tsx", { render: "ssg" }),
route("/about", "./routes/about.tsx", { render: "ssg" }),
route("/blog/:slug", "./routes/blog-post.tsx", { render: "ssr" }),
]),
group({ shell: "auth" }, [route("/login", "./routes/login.tsx", { render: "spa" })]),
],
});Why? The manifest gives you full control: URL structure is independent of file layout, shell and middleware assignment is explicit, and render modes are visible at a glance.
Dynamic Routes
| Next.js | pracht |
|---|---|
[slug] folder |
:slug in path |
[...slug] folder |
* catch-all |
(group) folder |
group() call |
Layouts โ Shells
Next.js uses layout.tsx files that nest based on folder structure. pracht uses named shells that are explicitly assigned to routes or groups.
// Next.js โ app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}// pracht โ named shell
import type { ShellProps } from "@pracht/core";
export function Shell({ children }: ShellProps) {
return (
<html>
<body>{children}</body>
</html>
);
}
export function head() {
return { title: "My App" };
}Key difference: Shells are decoupled from URL structure. A flat route like /settings can use the app shell without being nested under /app/settings in the file system.
Data Fetching โ Loaders & Actions
Server Components โ Loaders
Next.js uses async Server Components that fetch data inline. pracht separates data fetching into loader functions:
// Next.js โ app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await db.posts.find(params.slug);
return (
<article>
<h1>{post.title}</h1>
</article>
);
}// pracht
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
import { useRouteData } from "pracht/client";
export async function loader({ params }: LoaderArgs) {
const post = await db.posts.find(params.slug);
return { post };
}
export default function BlogPost() {
const { post } = useRouteData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
</article>
);
}Server Actions โ Actions
Next.js Server Actions become pracht action exports:
// Next.js
"use server";
async function createPost(formData: FormData) {
await db.posts.create({ title: formData.get("title") });
redirect("/blog");
}// pracht
import type { ActionArgs } from "@pracht/core";
import { Form } from "pracht/client";
export async function action({ request }: ActionArgs) {
const form = await request.formData();
await db.posts.create({ title: form.get("title") });
return { redirect: "/blog" };
}
export default function NewPost() {
return (
<Form method="post">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}Head Metadata
Next.js uses a metadata export or generateMetadata function. pracht uses a head export:
// Next.js
export const metadata = { title: "About Us" };
// or
export async function generateMetadata({ params }) {
return { title: `Post: ${params.slug}` };
}// pracht
import type { HeadArgs } from "@pracht/core";
export function head({ data }: HeadArgs) {
return {
title: "About Us",
meta: [{ name: "description", content: "Learn about us" }],
};
}Rendering Modes
Next.js controls caching with export const dynamic and revalidate. pracht sets rendering mode per-route in the manifest:
| Next.js | pracht | When to use |
|---|---|---|
dynamic = "force-static" |
render: "ssg" |
Content known at build time |
dynamic = "force-dynamic" |
render: "ssr" |
Personalized or real-time data |
revalidate = 3600 |
render: "isg" + timeRevalidate(3600) |
Mostly static, periodic updates |
Client component with "use client" |
render: "spa" |
Client-only UI (dashboards) |
import { route, timeRevalidate } from "@pracht/core";
route("/pricing", "./routes/pricing.tsx", {
render: "isg",
revalidate: timeRevalidate(3600),
});Middleware
Next.js uses a single middleware.ts file at the project root with path matching. pracht uses named middleware assigned per-route or per-group:
// Next.js โ middleware.ts
import { NextResponse } from "next/server";
export function middleware(request) {
const session = getSession(request);
if (!session) return NextResponse.redirect(new URL("/login", request.url));
}
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };// pracht โ named middleware
import type { MiddlewareFn } from "@pracht/core";
export const middleware: MiddlewareFn = async ({ request }) => {
const session = await getSession(request);
if (!session) return { redirect: "/login" };
};// Applied to specific routes via the manifest
group({ middleware: ["auth"], shell: "app" }, [
route("/dashboard", "./routes/dashboard.tsx", { render: "ssr" }),
route("/settings", "./routes/settings.tsx", { render: "spa" }),
]);Advantage: Multiple named middleware can be composed per-group. No regex matchers โ assignment is explicit.
API Routes
Both frameworks use file-based API routes with named HTTP method exports:
// Next.js โ app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const posts = await db.posts.list();
return NextResponse.json(posts);
}// pracht โ src/api/posts.ts
export async function GET() {
const posts = await db.posts.list();
return Response.json(posts);
}The main differences:
- pracht uses standard
Responseinstead ofNextResponse - Files live in
src/api/instead ofapp/api/ - No need for route segment config โ middleware is applied via
defineApp({ api: { middleware } })
Deployment
Next.js is optimized for Vercel. pracht uses adapters to deploy anywhere:
import { pracht } from "@pracht/vite-plugin";
import { node } from "@pracht/adapter-node";
// or: import { cloudflare } from "@pracht/adapter-cloudflare";
// or: import { vercel } from "@pracht/adapter-vercel";
export default {
plugins: [pracht({ adapter: node() })],
};| Target | Adapter | Notes |
|---|---|---|
| Node.js | @pracht/adapter-node |
Express-compatible, ISG revalidation |
| Cloudflare Workers | @pracht/adapter-cloudflare |
KV, D1, R2 bindings via context |
| Vercel | @pracht/adapter-vercel |
Edge Functions, Build Output API v3 |
Migration Checklist
- Scaffold a pracht project โ
npm create pracht@latest - Move components โ Update imports from
reacttopreact/hooks,classinstead ofclassName - Create the route manifest โ Map your
app/folder structure tosrc/routes.ts - Convert layouts to shells โ Extract
layout.tsxfiles into named shell components - Extract data fetching into loaders โ Move
asynccomponent logic intoloaderexports - Convert Server Actions to actions โ Replace
"use server"functions withactionexports - Move middleware โ Split your single
middleware.tsinto named middleware files - Move API routes โ Copy
app/api/handlers tosrc/api/, replaceNextResponsewithResponse - Choose an adapter โ Pick your deployment target in
vite.config.ts - Test โ Run
pracht devand verify each route renders correctly