Ghost routes in remix
Credit to Kent C. Dodds for this idea, ghost routes
are a handy way to create a reusable component route used by other routes, it takes Full-stack Components in a slightly different direction.
As Kent said in the Remix Discord:
I just discovered a new pattern I'm calling "Ghost Routes" which are files in the routes directory which are ignored by remix (via remix config) but still export loaders, actions, etc which can then be used by actual route files that are siblings to that route. This is like a halfway point between full stack components and routes. It allows me to make a component that's shared, but doesn't need its own route.
To start, ghost routes are prefixed by __
, and inside remix.config.mjs
, we tell it to ignore routes that start with __
:
import { flatRoutes } from 'remix-flat-routes'
/**
* @type {import('@remix-run/dev').AppConfig}
*/
export default {
ignoredRouteFiles: ['**/*'],
serverModuleFormat: 'cjs',
tailwind: true,
postcss: true,
future: {
v2_headers: true,
v2_meta: true,
v2_errorBoundary: true,
v2_normalizeFormMethod: true,
v2_routeConvention: true,
v2_dev: true,
},
routes: async defineRoutes => {
return flatRoutes('routes', defineRoutes, {
ignoredRouteFiles: [
'.*',
'**/*.css',
'**/*.test.{js,jsx,ts,tsx}',
'**/__*.*', // <- ignore any route that begins with __
],
})
},
}
Essentially, we've told Remix that having a file prefixed with __
means ignore it as a route, but it also allows us to place it next to the two routes that share the code. But it's more than just shared code.
Note
I'm using Flat Routes in the Directed Stack so your route organization may be different.
FYI, since this example comes directly from the Directed Stack repo, I'm not sharing every piece of code involved, only focusing on how to structure your ghost route, you can look at the repo to see how to do everything else.
To illustrate this, inside a folder in routes, for example app/routes/notes+
, we can create a file called __note-editor.tsx
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import { redirect, json, type DataFunctionArgs } from '@vercel/remix'
import { useFetcher } from '@remix-run/react'
import { z } from 'zod'
import { Button } from '~/components/core/ui/button'
import { StatusButton } from '~/components/core/ui/status-button'
import { Icon } from '~/components/icons'
import * as React from "react";
import { isAuthenticated, getDirectusClient, createOne, updateOne } from "~/auth.server";
import { ErrorList, Field } from '~/components/core/forms'
import {
invariant,
useIsSubmitting,
} from '~/utils'
import { Editor } from "~/components/core/editor";
export const NoteEditorSchema = z.object({
id: z.string().optional(),
title: z.string().min(1),
body: z.string().min(1),
})
export async function action({ request }: DataFunctionArgs) {
const userAuthenticated = await isAuthenticated(request, true);
if (!userAuthenticated) {
return redirect("/signin");
}
const {token} = userAuthenticated;
await getDirectusClient({ token })
const formData = await request.formData()
const submission = parse(formData, {
schema: NoteEditorSchema,
acceptMultipleErrors: () => true,
})
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
let note: { id: string }
const { title, body, id } = submission.value
if (id) {
await updateOne('notes', id, {
title,
body
});
return redirect(`/notes/${id}`);
} else {
const newNote = await createOne('notes', {
title,
body
});
return redirect(`/notes/${newNote.id}`);
}
}
export function NoteEditor({
note,
}: {
note?: { id: string; title: string; body: string }
}) {
const noteEditorFetcher = useFetcher<typeof action>()
let [hidePreview, setHidePreview] = React.useState(false)
const [markup, setMarkup] = React.useState(note?.body || "" );
const handleChange = (newValue: string) => {
setMarkup(newValue)
}
const isSubmitting = useIsSubmitting()
const [form, fields] = useForm({
id: 'note-editor',
constraint: getFieldsetConstraint(NoteEditorSchema),
lastSubmission: noteEditorFetcher.data?.submission,
onValidate({ formData }) {
return parse(formData, { schema: NoteEditorSchema })
},
defaultValue: {
title: note?.title,
body: note?.body,
},
shouldRevalidate: 'onBlur',
})
return (
<noteEditorFetcher.Form
method="post"
className="flex h-full flex-col gap-y-4 overflow-x-hidden px-10 pb-28 pt-12"
{...form.props}
>
<input name="id" type="hidden" value={note?.id} />
<input type="hidden" value={markup} id="body" name="body" />
<Field
labelProps={{ children: 'Title' }}
inputProps={{
...conform.input(fields.title),
autoFocus: true,
}}
errors={fields.title.errors}
className="flex flex-col gap-y-2"
/>
<div>
<Editor
content={markup}
onChange={handleChange}
hidePreview={hidePreview}
/>
<div className="flex-1 mt-2 rounded-md border border-neutral-300 bg-gray-100 p-4">
{hidePreview ? <button
type="button"
name="showModel"
onClick={()=>setHidePreview(false)}
className="ml-5 rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
>
<Icon name="eye" />
Show Preview
</button> :
<button
type="button"
name="showModel"
onClick={()=>setHidePreview(true)}
className="ml-5 rounded bg-green-500 py-2 px-4 text-white hover:bg-green-600 focus:bg-green-400 disabled:bg-green-300"
>
<Icon name="eye-off" />
Hide Preview
</button>}
</div>
</div>
<ErrorList errors={form.errors} id={form.errorId} />
<div className="pt-5">
<div className="flex justify-end">
<Button
variant="destructive"
type="reset"
className="min-[525px]:max-md:aspect-square min-[525px]:max-md:px-0"
>
<Icon name="x-circle" className="scale-125 max-md:scale-150">
<span className="max-md:hidden">Reset</span>
</Icon>
</Button>
<StatusButton
status={
noteEditorFetcher.state === 'submitting'
? 'pending'
: noteEditorFetcher.data?.status ?? 'idle'
}
type="submit"
disabled={noteEditorFetcher.state !== 'idle'}
className="min-[525px]:max-md:aspect-square min-[525px]:max-md:px-0 text-white"
>
<Icon name="arrow-right" className="scale-125 max-md:scale-150">
<span className="max-md:hidden">Submit</span>
</Icon>
</StatusButton>
</div>
</div>
</noteEditorFetcher.Form>
)
}
Then inside the same folder, we can create a file called $noteId_.edit.tsx
:
import type {
ActionArgs,
LoaderArgs,
} from "@vercel/remix";
import { json, redirect } from "@vercel/remix";
import {
Form,
useLoaderData,
useActionData
} from "@remix-run/react";
import { CacheControl } from "~/utils/cache-control.server";
import { isAuthenticated, getDirectusClient, readOne, updateOne } from "~/auth.server";
import * as React from "react";
import {
invariant,
useIsSubmitting,
} from '~/utils'
import { NoteEditor, action } from './__note-editor'
// export the action directly from the note-editor
export { action }
export async function loader({ request, params }: LoaderArgs) {
invariant(params.noteId, "noteId not found");
const userAuthenticated = await isAuthenticated(request, true);
if (!userAuthenticated) {
return redirect("/signin");
}
const {token} = userAuthenticated;
await getDirectusClient({ token })
const note = await readOne("notes", params.noteId);
if (!note) {
throw new Response("Not Found", { status: 404 });
}
return json({
note
}, {
headers: {
"Cache-Control": new CacheControl("swr").toString()
},
});
}
export default function NoteDetailsPage() {
const { note } = useLoaderData<typeof loader>();
return (
<NoteEditor note={note} />
);
}
Finally, we create a file called new.tsx
:
import type { ActionArgs } from "@vercel/remix";
import { json, redirect } from "@vercel/remix";
import { Form, useActionData } from "@remix-run/react";
import * as React from "react";
import { NoteEditor, action } from './__note-editor'
// export the action directly from the note-editor
export { action }
export default function NewNotePage() {
return (
<NoteEditor />
);
}
Both of these files use the __note-editor
ghost route as an editor as well as to handle the form submission, this lets us keep code clean.
I've updated my Directed Stack repo to use this method and it works great. Previously I'd have stored the notes-editor route inside resources
which also worked fine but this way the routes and related files stay together.