Migration Guide
Move a Next.js app (pages or app router) to GioJS with one command, then work through a report of what needs a human.
Run the migration
Commit your work first - the migration edits files in place. From the project root:
npm create giojs@latest -- migrate # current directory
npx create-giojs migrate ./my-next-app # same thing, explicit directorypnpm create giojs migrate # current directory
pnpm dlx create-giojs migrate ./my-next-app # same thing, explicit directoryyarn create giojs migrate # current directory
yarn dlx create-giojs migrate ./my-next-app # same thing, explicit directorybun create giojs migrate # current directory
bunx create-giojs migrate ./my-next-app # same thing, explicit directoryIt prints a summary of every move and edit and asks before writing anything. Preview the full diff without touching a file, or skip the prompt (CI, scripts):
npx create-giojs migrate --dry-run # summary + diff of every change, writes nothing
npx create-giojs migrate --yes # apply without asking (required when there is no terminal)
npx create-giojs migrate --helppnpm dlx create-giojs migrate --dry-run # summary + diff of every change, writes nothing
pnpm dlx create-giojs migrate # apply without asking (required when there is no terminal)
pnpm dlx create-giojs migrate --helpyarn dlx create-giojs migrate --dry-run # summary + diff of every change, writes nothing
yarn dlx create-giojs migrate # apply without asking (required when there is no terminal)
yarn dlx create-giojs migrate --helpbunx create-giojs migrate --dry-run # summary + diff of every change, writes nothing
bunx create-giojs migrate # apply without asking (required when there is no terminal)
bunx create-giojs migrate --helpThe gio-migrate bin of the same package runs the same command (npx -p create-giojs gio-migrate). Afterwards:
npm install # next is swapped for @gio.js/server + @gio.js/react
npx tsc --noEmit # catches what the migration could not see
npm run devpnpm install # next is swapped for @gio.js/server + @gio.js/react
pnpm exec tsc --noEmit # catches what the migration could not see
pnpm devyarn # next is swapped for @gio.js/server + @gio.js/react
yarn tsc --noEmit # catches what the migration could not see
yarn devbun install # next is swapped for @gio.js/server + @gio.js/react
bunx tsc --noEmit # catches what the migration could not see
bun run devMIGRATION_REPORT.md
The migration writes MIGRATION_REPORT.md to the project root: every file moved, every change with its file:line, the converted configuration, and a checklist of TODOs. Each TODO is also a // TODO(gio-migrate): ... comment above the code it is about, so grep -rn "TODO(gio-migrate)" . finds them too. Nothing that needs a decision is changed silently.
pages/ → app/
GioJS uses the app router conventions (layouts, route groups, catch-alls, not-found/error/loading files), so an app router project keeps its structure (src/app/ moves to app/ - GioJS reads it from the project root). A pages router project is moved file by file, and relative imports in moved files are rewritten to match:
| Next.js | GioJS |
|---|---|
pages/index.tsx | app/page.tsx |
pages/about.tsx, pages/about/index.tsx | app/about/page.tsx |
pages/[id].tsx, pages/[...slug].tsx | app/[id]/page.tsx, app/[...slug]/page.tsx |
pages/_app.tsx + pages/_document.tsx | app/layout.tsx - the document's <Html>, <Head> and <body> markup carried over, global CSS linked, originals kept as a comment |
pages/404.tsx / pages/500.tsx | app/not-found.tsx / app/error.tsx |
pages/api/x.ts | app/api/x/route.ts, with a TODO sketching the GET/POST exports that replace the (req, res) handler |
A file is never moved onto an existing one: it stays where it is with a TODO. Next.js middleware.ts is renamed to middleware.next.ts - GioJS loads middleware.ts as declarative rules (see Middleware) - and flagged for porting.
Next.js accepts JSX in .js files; GioJS compiles JSX only in .jsx/.tsx files, so every .js file with JSX is renamed to .jsx (pages/index.js → app/page.jsx, components/Nav.js → components/Nav.jsx) and imports that spell out the .js extension are updated. Files without JSX keep their name.
Catch-all params differ in shape: Next passes [...slug] as an array (['a', 'b']), GioJS as the '/'-joined string ('a/b') - in page props, getServerSideProps's params, useParams() and route handlers alike. Code in a catch-all route that reads them gets a TODO to use .split('/').
Code transforms
Source files are parsed with the TypeScript compiler (so JSX text, strings and comments are never mistaken for code) and edited in place - formatting and comments survive.
| Next.js | GioJS |
|---|---|
next/link (default, named or aliased import) | GioLink; legacyBehavior/passHref removed (a child <a> is unwrapped onto the link), as becomes href, prefetch becomes prefetch="viewport"; object hrefs, shallow, locale and unsupported props get a TODO |
next/image, next/legacy/image | GioImage; fill, priority, sizes, quality, placeholder kept, legacy layout mapped; static image imports, loaders and missing width/height get a TODO |
next/router useRouter() | useRouter from @gio.js/react (push, replace, back, forward, prefetch, refresh); router.query → useSearchParams() + useParams() merged in a useMemo (it keeps its identity until the URL changes, like router.query, so effects that depend on it don't re-run every render), pathname/asPath → usePathname(), reload() → window.location.reload(); router.events and other unsupported APIs get a TODO |
next/navigation | The hooks move to @gio.js/react unchanged, notFound to @gio.js/core. redirect()/permanentRedirect() become redirect from @gio.js/core, which returns the redirect instead of throwing it: redirect(url) as a statement becomes throw redirect(url) (redirect(url, 308) for a permanent one) - what getServerSideProps, generateMetadata, page actions and the helpers they call may throw. return redirect(url) becomes throw redirect(url) too, except directly in getServerSideProps or a page action, the only places that read a returned redirect (a guard helper's caller would take it for a value, and generateMetadata would merge it as metadata). Directly in a route handler it becomes a 307/308 Response; while rendering a component or in a hook it gets a TODO (redirect from getServerSideProps, or navigate() in the browser) |
next/head | Plain <title>/<meta>/<link> - React 19 hoists them into <head> |
next/script | Plain <script> (async for the default strategy, inline bodies as dangerouslySetInnerHTML); other strategies and onLoad get a TODO |
next/dynamic | React.lazy; the loading and ssr: false options get a TODO (render inside <Suspense>) |
next/font | A same-shape stand-in object plus a [[fonts]] snippet for gio.toml in the report |
getStaticProps | getServerSideProps plus export const revalidate (its revalidate value, or false - cache until the next deploy); getStaticPaths is kept for gio export, and generateStaticParams gets a getStaticPaths next to it |
export const metadata / generateMetadata | Kept - GioJS reads both on pages and layouts, with the same field names, title templates and root-to-page merge (see Metadata & SEO); the Metadata type now comes from @gio.js/core. Fields GioJS does not render get a TODO naming them: applicationName, generator, referrer, creator, publisher, category, verification and the like with their other: { name: content } replacement; viewport, appleWebApp, appLinks, itunes, facebook, alternates.types, icons.other and Open Graph article fields as having no equivalent. generateMetadata gets GioJS's (ctx, { props }) signature: params is unchanged, searchParams becomes query: searchParams, an unused parent is dropped and a used one gets a TODO (segments merge on their own) |
'use client' / 'use server' | Removed - every GioJS page hydrates. <form action={serverAction}> becomes <GioForm>, which posts to the page's own URL, and each Server Action gets a TODO to move into that page's export async function action(req) (a non-form one into a route.ts handler); the report sketches the result. A button's formAction={serverAction} becomes name="intent" value="serverAction" for the page's action to branch on, and its <form> becomes a <GioForm> as well. A client function as a form action is React 19's own and stays |
next/cache | revalidatePath/revalidateTag from @gio.js/core ('layout' becomes { type: 'prefix' }, a route pattern such as /posts/[id] gets a TODO, and so does each revalidateTag: it purges the pages that declare the tag with export const tags). unstable_cache(fn) becomes fn and 'use cache' is removed, both with a TODO: GioJS caches whole pages (export const revalidate and export const tags), and fetch()'s next options are flagged - Node's fetch has no data cache. noStore() calls are removed, and export const dynamic = 'force-static' on a page becomes export const revalidate = false (on a layout it gets a TODO: GioJS reads revalidate from pages only, so each page below it needs the export) |
| Metadata files | app/sitemap.ts, app/robots.ts and app/manifest.ts are kept: GioJS serves them at the same URLs from the same return shapes (sitemap images/videos and generateSitemaps get a TODO). Next linked the manifest from every page on its own; GioJS renders that <link rel="manifest"> from metadata only, so app/manifest.ts gets a TODO to add manifest: '/manifest.webmanifest' to the root layout's metadata. The static files Next serves from app/ - favicon.ico, robots.txt, sitemap.xml, manifest.json, icon.png, opengraph-image.png, ... - move to public/; images and the manifest get a TODO to reference them from metadata (icons, openGraph.images, manifest), which Next did implicitly |
next/server in route handlers | NextResponse.json/redirect → Response, NextRequest → GioRequest; request.nextUrl, headers.get(), text() and the { params } argument get a TODO (GioJS passes a GioRequest with plain objects, json() and formData()) |
| CSS imports | GioJS does not bundle CSS imports: in the root layout an app/ stylesheet becomes a <link>, elsewhere the import is commented out with a TODO |
package.json drops next for @gio.js/server and @gio.js/react (plus @gio.js/core when the migrated code imports it; React moves to 19), its next dev/next startscripts run the GioJS server, and it gets "type": "module", like every GioJS app: GioJS loads app files as ES modules and @gio.js/react ships ES modules only, so without it every page importing @gio.js/react fails to load. That makes Node treat every .js file as an ES module, so CommonJS files (module.exports/require, such as postcss.config.js or tailwind.config.js) are renamed to .cjs, and a file mixing import/export with module.exports gets a TODO. tsconfig.json gets "jsx": "react-jsx" - "preserve" would leave JSX untransformed - loses the next plugin, and includes .gio/routes.d.ts for typed routes.
next.config → gio.toml
The config is read statically (it is never executed). Redirects, rewrites and headers become the gio.toml rules the Rust server evaluates before routing, with the path syntax converted: :slug stays, :path* and a trailing (.*) or :path(.*) become the catch-all *path(source: '/(.*)', the usual site-wide headers rule, becomes path = "/*rest").
# redirects() { return [{ source: '/blog/:path*', destination: '/news/:path*', permanent: true }] }
[[redirects]]
from = "/blog/*path"
to = "/news/*path"
status = 308 # permanent: true → 308, false → 307 (statusCode is kept)
[[headers]]
path = "/*path"
headers = { "X-Frame-Options" = "DENY" }images.remotePatterns/domains→[[images.remote_patterns]],deviceSizes/imageSizes→[images] allowed_widthsi18n→[i18n](localeDetection: false→ path detection only)output: 'export'→ the build script runsgio export;env→ a.envhint- A rule GioJS would match differently is skipped with a TODO instead of approximated:
has/missingconditions, regex or optional parameters, external destinations, query strings in destinations, a root catch-all rewrite (/(.*),/:path*) outsidebeforeFiles- Next ran it only after checking pages and public files, GioJS would rewrite every request - and imagepathnameglobs with a single*(one segment in Next.js; agio.tomlpattern's*matches at any depth, which would widen what the image proxy fetches). The one exception is a catch-all Next requires to be non-empty (/blog/:path+,/blog/(.*)): it becomes/blog/*path, which also matches/blogitself, with a TODO comment saying so experimental.serverActionspoints at page actions (bodySizeLimit→[server] max_body_bytes,allowedOrigins→[security.csrf] trusted_origins),experimental.ppratexport const shell = 'cache'; typed routes need no flagbasePath,trailingSlash,webpack, otherexperimentalflags and the rest are listed in the report - GioJS compiles with esbuild, so webpack and SWC options don't apply
An existing gio.toml is never overwritten. New tables are merged into it when no table or key would be defined twice; otherwise the converted sections go to gio.migrated.toml (not loaded by GioJS) for you to merge by hand. To convert just the config: npx create-giojs migrate --config next.config.js.
Server Actions → page actions
A form's Server Action becomes the page's action export: a POST to the page runs it, and <GioForm> (which the migration already put in place of the <form>) posts to it - with or without JavaScript:
// Next.js
async function createPost(formData: FormData) {
'use server';
await db.posts.create({ title: String(formData.get('title')) });
redirect('/posts');
}
// <form action={createPost}>...</form>
// GioJS - app/posts/new/page.tsx
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
export async function action(req: ActionArgs) {
const title = String((await req.formData()).get('title') ?? '');
if (title === '') return { status: 422, data: { error: 'Title is required' } };
await db.posts.create({ title });
return redirect('/posts');
}
export default function NewPost({ actionData }: WithActionData<typeof action>) {
return (
<GioForm>
<input name="title" />
{actionData?.error && <p role="alert">{actionData.error}</p>}
<button>Create</button>
</GioForm>
);
}useFormStatus() becomes useGioFormState(), values passed with .bind() become hidden inputs, and useActionState's result is the page's actionData prop. The migration flags each of these.
What needs a human
- async Server Components - every GioJS page hydrates, so data loading moves into
getServerSideProps(flagged on async pages and layouts) - Providers in the root layout - the root
app/layoutis server-only HTML and never hydrates: context providers and interactive components go in a nested layout or the pages (flagged) - Server Actions - the forms already post through
<GioForm>; move each action's body into the page'sexport async function action(req)(fields fromawait req.formData(), answer withredirect(url)or{ status: 422, data }to re-render withactionData- see Forms and Mutations). Actions called from code becomeroute.tshandlers called withfetch() - Middleware - declarative redirects, rewrites, headers, and session or cookie guards move to
middleware.ts(defineMiddlewarefrom@gio.js/core) orgio.toml, and run in the Rust layer before routing. Imperative request interception belongs in a Node plugin (GioNodePluginwith anonRequesthook) - Unsupported app router files -
template, parallel (@slot) and intercepting routes, generated (.tsx)icon/opengraph-image/twitter-imagefiles (GioJS has no image generation: put a rendered image inpublic/) and nestedsitemap.tsfiles (onlyapp/sitemap.tsis served) are listed in the report - Data caching -
unstable_cache,'use cache'andfetch()cache options become page caching:export const revalidateplusexport const tagsfor the pagesrevalidateTag()should purge
npx tsc --noEmit after migrating: types imported from next(NextPage, GetStaticProps, NextApiRequest, ...) are flagged but not rewritten - only Metadata and MetadataRoute, which @gio.js/core exports under the same names, are - and the compiler points at every place that still uses them.