Ghost routes in remix

Roger Stringer
August 16, 2023
8 min read

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 __:

// remix.config.mjs:
1import { flatRoutes } from 'remix-flat-routes'
2
3/**
4 * @type {import('@remix-run/dev').AppConfig}
5 */
6export default {
7	ignoredRouteFiles: ['**/*'],
8	serverModuleFormat: 'cjs',
9	tailwind: true,
10	postcss: true,
11	future: {
12		v2_headers: true,
13		v2_meta: true,
14		v2_errorBoundary: true,
15		v2_normalizeFormMethod: true,
16		v2_routeConvention: true,
17		v2_dev: true,
18	},
19	routes: async defineRoutes => {
20		return flatRoutes('routes', defineRoutes, {
21			ignoredRouteFiles: [
22				'.*',
23				'**/*.css',
24				'**/*.test.{js,jsx,ts,tsx}',
25				'**/__*.*', // <- ignore any route that begins with __
26			],
27		})
28	},
29}
30

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.

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

// app/routes/notes+/__note-editor.tsx:
1import { conform, useForm } from '@conform-to/react'
2import { getFieldsetConstraint, parse } from '@conform-to/zod'
3import { redirect, json, type DataFunctionArgs } from '@vercel/remix'
4import { useFetcher } from '@remix-run/react'
5import { z } from 'zod'
6import { Button } from '~/components/core/ui/button'
7import { StatusButton } from '~/components/core/ui/status-button'
8import { Icon } from '~/components/icons'
9import * as React from "react";
10import { isAuthenticated, getDirectusClient, createOne, updateOne } from "~/auth.server";
11import { ErrorList, Field } from '~/components/core/forms'
12
13import {
14    invariant,
15    useIsSubmitting,
16} from '~/utils'
17
18import { Editor } from "~/components/core/editor";
19
20export const NoteEditorSchema = z.object({
21	id: z.string().optional(),
22	title: z.string().min(1),
23	body: z.string().min(1),
24})
25
26export async function action({ request }: DataFunctionArgs) {
27    const userAuthenticated = await isAuthenticated(request, true);
28    if (!userAuthenticated) {
29        return redirect("/signin");
30    }
31  
32    const {token} = userAuthenticated;
33  
34    await getDirectusClient({ token })
35  
36	const formData = await request.formData()
37	const submission = parse(formData, {
38		schema: NoteEditorSchema,
39		acceptMultipleErrors: () => true,
40	})
41	if (submission.intent !== 'submit') {
42		return json({ status: 'idle', submission } as const)
43	}
44	if (!submission.value) {
45		return json({ status: 'error', submission } as const, { status: 400 })
46	}
47	let note: { id: string }
48
49	const { title, body, id } = submission.value
50
51	if (id) {
52        await updateOne('notes', id, {
53            title,
54            body
55        });
56        return redirect(`/notes/${id}`);
57	} else {
58        const newNote = await createOne('notes', {
59            title,
60            body
61        });
62        return redirect(`/notes/${newNote.id}`);
63	}
64}
65
66export function NoteEditor({
67	note,
68}: {
69	note?: { id: string; title: string; body: string }
70}) {
71	const noteEditorFetcher = useFetcher<typeof action>()
72
73    let [hidePreview, setHidePreview] = React.useState(false)
74    const [markup, setMarkup] = React.useState(note?.body || "" );
75    
76    const handleChange = (newValue: string) => {
77        setMarkup(newValue)
78    }  
79  
80    const isSubmitting = useIsSubmitting()
81
82	const [form, fields] = useForm({
83		id: 'note-editor',
84		constraint: getFieldsetConstraint(NoteEditorSchema),
85		lastSubmission: noteEditorFetcher.data?.submission,
86		onValidate({ formData }) {
87			return parse(formData, { schema: NoteEditorSchema })
88		},
89		defaultValue: {
90			title: note?.title,
91			body: note?.body,
92		},
93		shouldRevalidate: 'onBlur',
94	})
95
96	return (
97		<noteEditorFetcher.Form
98			method="post"
99			className="flex h-full flex-col gap-y-4 overflow-x-hidden px-10 pb-28 pt-12"
100			{...form.props}
101		>
102			<input name="id" type="hidden" value={note?.id} />
103            <input type="hidden" value={markup} id="body" name="body" />
104			<Field
105				labelProps={{ children: 'Title' }}
106				inputProps={{
107					...conform.input(fields.title),
108					autoFocus: true,
109				}}
110				errors={fields.title.errors}
111				className="flex flex-col gap-y-2"
112			/>
113            <div>
114                <Editor 
115                    content={markup}
116                    onChange={handleChange}
117                    hidePreview={hidePreview}
118                />
119                <div className="flex-1 mt-2 rounded-md border border-neutral-300 bg-gray-100 p-4">
120                    {hidePreview ? <button
121                        type="button"
122                        name="showModel"
123                        onClick={()=>setHidePreview(false)}
124                        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"
125                    >
126                        <Icon name="eye" />
127                        Show Preview
128                    </button> : 
129                    <button
130                        type="button"
131                        name="showModel"
132                        onClick={()=>setHidePreview(true)}
133                        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"
134                    >
135                        <Icon name="eye-off" />
136                        Hide Preview
137                    </button>}
138                </div>
139            </div>
140			<ErrorList errors={form.errors} id={form.errorId} />
141			<div className="pt-5">
142                <div className="flex justify-end">
143                    <Button
144                        variant="destructive"
145                        type="reset"
146                        className="min-[525px]:max-md:aspect-square min-[525px]:max-md:px-0"
147                    >
148                        <Icon name="x-circle" className="scale-125 max-md:scale-150">
149                            <span className="max-md:hidden">Reset</span>
150                        </Icon>
151                    </Button>
152                    <StatusButton
153                        status={
154                            noteEditorFetcher.state === 'submitting'
155                                ? 'pending'
156                                : noteEditorFetcher.data?.status ?? 'idle'
157                        }
158                        type="submit"
159                        disabled={noteEditorFetcher.state !== 'idle'}
160                        className="min-[525px]:max-md:aspect-square min-[525px]:max-md:px-0 text-white"
161                    >
162                        <Icon name="arrow-right" className="scale-125 max-md:scale-150">
163                            <span className="max-md:hidden">Submit</span>
164                        </Icon>
165                    </StatusButton>
166                </div>
167			</div>
168		</noteEditorFetcher.Form>
169	)
170}
171

Then inside the same folder, we can create a file called $noteId_.edit.tsx:

// app/routes/notes+/$noteId_.edit.tsx:
1import type { 
2  ActionArgs, 
3  LoaderArgs, 
4} from "@vercel/remix";
5import { json, redirect } from "@vercel/remix";
6import { 
7  Form, 
8  useLoaderData, 
9  useActionData
10} from "@remix-run/react";
11import { CacheControl } from "~/utils/cache-control.server";
12import { isAuthenticated, getDirectusClient, readOne, updateOne } from "~/auth.server";
13import * as React from "react";
14
15import {
16    invariant,
17    useIsSubmitting,
18} from '~/utils'
19
20import { NoteEditor, action } from './__note-editor'
21
22// export the action directly from the note-editor
23export { action }
24
25export async function loader({ request, params }: LoaderArgs) {
26    invariant(params.noteId, "noteId not found");
27
28    const userAuthenticated = await isAuthenticated(request, true);
29    if (!userAuthenticated) {
30        return redirect("/signin");
31    }
32
33    const {token} = userAuthenticated;
34
35    await getDirectusClient({ token })
36    
37    const note = await readOne("notes", params.noteId);
38    if (!note) {
39        throw new Response("Not Found", { status: 404 });
40    }
41    
42    return json({ 
43      note
44    }, {
45			headers: {
46				"Cache-Control": new CacheControl("swr").toString() 
47			},
48		});
49}
50
51export default function NoteDetailsPage() {
52  const { note } = useLoaderData<typeof loader>();
53
54  return (
55    <NoteEditor note={note} />
56  );
57}
58

Finally, we create a file called new.tsx:

// app/routes/notes+/new.tsx:
1import type { ActionArgs } from "@vercel/remix";
2import { json, redirect } from "@vercel/remix";
3import { Form, useActionData } from "@remix-run/react";
4import * as React from "react";
5
6import { NoteEditor, action } from './__note-editor'
7
8// export the action directly from the note-editor
9export { action }
10
11export default function NewNotePage() {
12  return (
13    <NoteEditor />
14  );
15}
16

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.

Do you like my content?

Sponsor Me On Github