GioJSdocs
On this page

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 directory

It 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 --help

The 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 dev

MIGRATION_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.jsGioJS
pages/index.tsxapp/page.tsx
pages/about.tsx, pages/about/index.tsxapp/about/page.tsx
pages/[id].tsx, pages/[...slug].tsxapp/[id]/page.tsx, app/[...slug]/page.tsx
pages/_app.tsx + pages/_document.tsxapp/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.tsxapp/not-found.tsx / app/error.tsx
pages/api/x.tsapp/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.jsGioJS
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/imageGioImage; 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/navigationThe 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/headPlain <title>/<meta>/<link> - React 19 hoists them into <head>
next/scriptPlain <script> (async for the default strategy, inline bodies as dangerouslySetInnerHTML); other strategies and onLoad get a TODO
next/dynamicReact.lazy; the loading and ssr: false options get a TODO (render inside <Suspense>)
next/fontA same-shape stand-in object plus a [[fonts]] snippet for gio.toml in the report
getStaticPropsgetServerSideProps 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 / generateMetadataKept - 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/cacherevalidatePath/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 filesapp/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 handlersNextResponse.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 importsGioJS 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").

toml
# 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_widths
  • i18n → [i18n] (localeDetection: false → path detection only)
  • output: 'export' → the build script runs gio export; env → a .env hint
  • A rule GioJS would match differently is skipped with a TODO instead of approximated: has/missing conditions, regex or optional parameters, external destinations, query strings in destinations, a root catch-all rewrite (/(.*), /:path*) outside beforeFiles - Next ran it only after checking pages and public files, GioJS would rewrite every request - and image pathname globs with a single * (one segment in Next.js; a gio.toml pattern'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 /blog itself, with a TODO comment saying so
  • experimental.serverActions points at page actions (bodySizeLimit → [server] max_body_bytes, allowedOrigins → [security.csrf] trusted_origins), experimental.ppr at export const shell = 'cache'; typed routes need no flag
  • basePath, trailingSlash, webpack, other experimental flags 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:

tsx
// 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/layout is 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's export async function action(req) (fields from await req.formData(), answer with redirect(url) or { status: 422, data } to re-render with actionData - see Forms and Mutations). Actions called from code become route.ts handlers called with fetch()
  • Middleware - declarative redirects, rewrites, headers, and session or cookie guards move to middleware.ts (defineMiddleware from @gio.js/core) or gio.toml, and run in the Rust layer before routing. Imperative request interception belongs in a Node plugin (GioNodePlugin with an onRequest hook)
  • Unsupported app router files - template, parallel (@slot) and intercepting routes, generated (.tsx) icon/opengraph-image/twitter-image files (GioJS has no image generation: put a rendered image in public/) and nested sitemap.ts files (only app/sitemap.ts is served) are listed in the report
  • Data caching - unstable_cache, 'use cache' and fetch() cache options become page caching: export const revalidate plus export const tags for the pages revalidateTag() should purge
Run 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.