Development

The InertiaJS Response Handler you Didn’t Know You Needed... Until now....

4 minutes

We’re big fans of IntertiaJS here at Jump24, it makes developing fully featured Vue or React single page applications with a Laravel backend so easy it feels like cheating, in our Laravel controllers we can just pass a Vue page name, and some props, and Inertia takes care of the rest and will render a full Vue page for the user in their browser.

php
1<?php
2 
3namespace App\Http\Controllers;
4 
5use App\Models\Blog;
6use Inertia\Inertia;
7 
8class HomeController
9{
10 public function __invoke()
11 {
12 return Inertia::render('Index', [
13 'meta' => [
14 'title' => config('app.name'),
15 ],
16 'latest_blogs' => Blog::query()->latest()->take(3)->get(),
17 ]);
18 }
19}

But I’ve always felt that there’s something missing, let's take this example a little further, let's imagine we have a main Layout file, and that expects a page title, maybe some meta information like keywords, a description, and maybe even some OG information for social media… suddenly, we have to remember to send all of that data as props, with the correct key names every single time from every single Controller.

vue
1<script setup lang="ts">
2type MetaProps = {
3 title: string;
4 currentUrl: string;
5 description: string;
6 keywords: string;
7 image: string;
8 siteName: string;
9}
10 
11defineProps<{
12 meta: MetaProps
13}>();
14</script>
15 
16<template>
17 <!doctype html>
18 <html lang="en">
19 <head>
20 <meta charset="UTF-8">
21 <meta name="viewport" content="width=device-width, initial-scale=1">
22 
23 <title>{{ meta.title }}</title>
24 
25 <meta
26 name="description"
27 :content="meta.description"
28 />
29 
30 <meta
31 name="keywords"
32 :content="meta.keywords"
33 />
34 
35 <meta
36 property="og:type"
37 content="article"
38 />
39 
40 <meta
41 property="og:locale"
42 content="en_GB"
43 />
44 <meta
45 property="og:site_name"
46 :content="meta.siteName"
47 />
48 <meta
49 property="og:description"
50 :content="meta.description"
51 />
52 <meta
53 property="og:title"
54 :content="meta.title"
55 />
56 <meta
57 property="og:image"
58 :content="meta.image"
59 />
60 <meta
61 property="og:image:type"
62 content="image/jpeg"
63 />
64 <meta
65 property="og:url"
66 :content="meta.currentUrl"
67 />
68 </head>
69 <body>
70 <slot />
71 </body>
72 </html>
73</template>

Now, let's imagine we have a couple of controllers, one to show a blog, and one to list all blogs, we have to remember to send each of those props, every single time…

php
1<?php
2 
3namespace App\Http\Controllers\Blogs;
4 
5use App\Models\Blog;
6use Illuminate\Http\Request;
7use Inertia\Inertia;
8 
9class ShowController
10{
11 public function __invoke(Blog $blog, Request $request)
12 {
13 return Inertia::render('Blog/Show', [
14 'meta' => [
15 'title' => $blog->title . ' - ' . config('app.name'),
16 'currentUrl' => $request->url(),
17 'description' => $blog->description,
18 'keywords' => $blog->keywords,
19 'image' => $blog->image,
20 'siteName' => config('app.name'),
21 ],
22 'blog' => $blog,
23 ]);
24 }
25}
26 
27 
28//...
29 
30<?php
31 
32namespace App\Http\Controllers\Blogs;
33 
34use App\Models\Blog;
35use Illuminate\Http\Request;
36use Inertia\Inertia;
37 
38class IndexController
39{
40 public function __invoke(Request $request)
41 {
42 return Inertia::render('Blog/Index', [
43 'meta' => [
44 'title' => config('app.name'),
45 'currentUrl' => $request->url(),
46 'description' => config('app.description'),
47 'keywords' => config('app.keywords'),
48 'image' => config('app.default_og_image'),
49 'siteName' => config('app.name'),
50 ],
51 'blogs' => Blog::query()->latest()->paginate(),
52 ]);
53 }
54}

Notice the duplication? As previously alluded to, this is full of risks, you have to remember to send the meta props each time, and remember to always make sure you’re using the same keys, sure you could guard around it in the Vue side with v-if’s, and fallbacks, but even then, you’ve got to remember to use the right keys each time.

So what can we do to help? Insert the InertaJS response handler you didn’t know you needed… until now.

Imagine an Inertia class, one you can just add into the controller arguments to inject each time you need it, imagine it has fluent, syntactic methods for chaining your page title, your meta information, everything you need… well imagine no longer…

php
1<?php
2 
3namespace App\Http\Responses;
4 
5use Inertia\Inertia as BaseInertia;
6use Inertia\Response;
7 
8class Inertia
9{
10 public function __construct()
11 {
12 $this->setDefaultMetas();
13 }
14 
15 protected function setDefaultMetas(): void
16 {
17 BaseInertia::share('metas.baseUrl', config('app.url'));
18 BaseInertia::share('metas.title', config('app.title'));
19 BaseInertia::share('metas.description', config('app.description'));
20 BaseInertia::share('metas.tags', config('app.keywords'));
21 BaseInertia::share('metas.image', config('app.default_og_image'));
22 BaseInertia::share('metas.currentUrl', request()->url());
23 }
24 
25 public function title(string $title): self
26 {
27 $defaultTitle = config('app.name');
28 
29 BaseInertia::share('metas.title', "{$title} - {$defaultTitle}");
30 
31 return $this;
32 }
33 
34 public function metaDescription(string $description): self
35 {
36 BaseInertia::share('metas.description', $description);
37 
38 return $this;
39 }
40 
41 public function metaKeywords(array $tags, bool $merge = true): self
42 {
43 if ($merge) {
44 /** @var string[] $defaultTags */
45 $defaultTags = config('app.keywords');
46 
47 $tags = array_merge($tags, $defaultTags);
48 }
49 
50 BaseInertia::share('metas.tags', $tags);
51 
52 return $this;
53 }
54 
55 public function metaImage(string $image): self
56 {
57 BaseInertia::share('metas.image', $image);
58 
59 return $this;
60 }
61 
62 /** @param array<string, mixed> $props */
63 public function render(string $component, array $props = []): Response
64 {
65 return BaseInertia::render($component, $props);
66 }
67}

So in the constructor, we’re just setting some fallback defaults for a site title, keywords, default OG image etc, and then we expose several methods to set anything we need to set fluently, and there’s no magic going on here, we’re simply just passing what we need to Inertia, and then Inertia will take care of the rest and send the data we need as props to the Vue file and the response.

Now lets refactor our two controllers from before to use this new Inertia class

php
1<?php
2 
3namespace App\Http\Controllers\Blogs;
4 
5use App\Http\Responses\Inertia;
6use App\Models\Blog;
7 
8class ShowController
9{
10 public function __invoke(Blog $blog, Inertia $inertia)
11 {
12 return $inertia
13 ->title($blog->title)
14 ->metaDescription($blog->description)
15 ->metaKeywords($blog->keywords)
16 ->metaImage($blog->image)
17 ->render('Blog/Show', [
18 'blog' => $blog,
19 ]);
20 }
21}
22 
23//...
24 
25<?php
26 
27namespace App\Http\Controllers\Blogs;
28 
29use App\Http\Responses\Inertia;
30use App\Models\Blog;
31 
32class IndexController
33{
34 public function __invoke(Inertia $inertia)
35 {
36 return $inertia
37 ->title('Latest Blogs')
38 ->render('Blog/Index', [
39 'blogs' => Blog::query()->latest()->paginate(),
40 ]);
41 }
42}

Ok, so it might be a little longer line wise, but it's much more readable, you instantly know what you’re passing as the page title, descriptions, whether the page has an OG image, you don’t need to worry about forgetting to send them, or using the wrong keys.

And the usability of the Inertia response handler doesn't end there, here’s are a few more examples.

Say you have some pages you don’t really want Google to track, using the no index header, just add it as a method in the Inertia handler, and account for it on the front end.

php
1<?php
2 
3namespace App\Http\Responses;
4 
5use Inertia\Inertia as BaseInertia;
6use Inertia\Response;
7 
8class Inertia
9{
10 // ...
11 
12 public function doNotTrack(): self
13 {
14 BaseInertia::share('metas.doNotTrack', true);
15 
16 return $this;
17 }
18}
vue
1<template>
2 <!doctype html>
3 <html lang="en">
4 <head>
5 // ...
6 
7 <meta
8 v-if="meta.doNotTrack"
9 name="robots"
10 content="noindex"
11 />
12 </head>
13 <body>
14 <slot />
15 </body>
16 </html>
17</template>

Or maybe you have a site announcements/alerts system, and on every page you want to check if there’s any announcements to place at the top of your layout, as before, just call it in the constructor, and if there is, pass it along to the Vue layer.

php
1<?php
2 
3namespace App\Http\Responses;
4 
5use App\Models\Anouncement;
6use Illuminate\Support\Str;
7use Inertia\Inertia as BaseInertia;
8use Inertia\Response;
9 
10class Inertia
11{
12 public function __construct()
13 {
14 $this->setDefaultMetas();
15 $this->checkForSiteAnnouncement();
16 }
17 
18 protected function checkForSiteAnnouncement(): void
19 {
20 $announcement = Announcement::query()
21 ->where('live', true)
22 ->where('expires_at', '>', now())
23 ->first();
24 
25 if ($announcement) {
26 BaseInertia::share('meta.announcement', [
27 'title' => $announcement->title,
28 'text' => Str::markdown($announcement->text),
29 ]);
30 }
31 }

Perhaps your website has an online shop, and on any pages under the shop section you always want to pass the users current basket information, like maybe to always show ‘you have X items in your basket’ in a card near the header, again, can easily be done with this approach.

php
1<?php
2 
3namespace App\Http\Responses;
4 
5use App\Actions\ResolveBasketAction;
6use App\Models\Anouncement;
7use Illuminate\Support\Facades\Request;
8use Illuminate\Support\Str;
9use Inertia\Inertia as BaseInertia;
10use Inertia\Response;
11 
12class Inertia
13{
14 public function __construct()
15 {
16 $this->setDefaultMetas();
17 $this->checkForSiteAnnouncement();
18 
19 if (Request::routeIs('shop.*')) {
20 $this->includeBasket();
21 }
22 }
23 
24 protected function includeBasket(): void
25 {
26 [$items, $subtotal] = app(ResolveBasketAction::class)->handle(...);
27 }

You can even just add support for Interia deferred props, and merge props to your handler, to keep everything together.

php
1<?php
2 
3namespace App\Http\Responses;
4 
5use App\Actions\ResolveBasketAction;
6use App\Models\Anouncement;
7use Illuminate\Support\Facades\Request;
8use Illuminate\Support\Str;
9use Inertia\DeferProp;
10use Inertia\Inertia as BaseInertia;
11use Inertia\MergeProp;
12use Inertia\Response;
13 
14class Inertia
15{
16 // ...
17 
18 public static function defer(callable $closure): DeferProp
19 {
20 return BaseInertia::defer($closure);
21 }
22 
23 public static function merge(mixed $value): MergeProp
24 {
25 return BaseInertia::merge($value);
26 }
27}

Ok, you’ve convinced me, are there any downsides?

The only downsides I’ve personally experienced with this are more with IDE support, using something like LaravelIDEA in PHPStorm, that fully understands calling inertia() or Inertia::render() from your controller and gives you full auto complete over your Vue page names, but this approach unfortunately doesn't, LaravelIDEA isn’t smart enough to know that calling ->render() on our Inertia handler is expecting a valid Inertia page component, and I haven’t found a way to let LaravelIDEA know that.

But, if you can live without a little bit of autocompletion in your IDE, and if you work a lot with Inertia and have to pass a lot of shared props to each page, give this approach a try, it might have been the thing you didn’t realise you were missing all this time.

Need help with a Vue Project?

Looking for help with your Vue Project? We’re passionate about creating top-quality web applications that stand out from the crowd. Let our skilled development team work with you to bring your ideas to life! Get in touch today and let’s get started!

Get in touch