dlinfo(... SERPATH ...) C++ wrapper
class dl_error : public std::runtime_error {
public:
    using std::runtime_error::runtime_error;
};

Given the purpose of this class, it seems logical to have it get the most recent error automatically. Especially since there is a gotcha involved, where dlerror() will return NULL if called a second time (so if some coder checks dlerror() to print or log the error message, and then does dl_error{::dlerror()}… boom). Plus it just makes it so much easier to use: throw dl_error{}; is just so much more ergonomic than throw dl_error{::dlerror()};.
Also, given that this class is already in namespace dl, it seems superfluous to have a prefix. dl::error is much nicer than dl::dl_error.
class serpath

Eh, I mean, is it really that much more painful to type out search_path (or search_path_info, given that it’s actually a path and some (in theory) flags)?
If you’re doing a thin wrapper around a C library, then it’s okay to recreate the C library’s shitty names. But that doesn’t look like what you’re doing, so why not use human-readable names? For someone whose first language isn’t English, the following is damn-near illegible:
auto const paths = dl::serinfo{dlhandle};

auto const path = paths.serpath(paths.cnt() - 1);

Compare that to:
auto const paths = dl::get_search_paths_for(dlhandle);

auto const path = paths[paths.size() - 1];

Every semi-competent C++ programmer will understand what size() and operator[] mean, and the function name is pretty much self-documenting. Meanwhile, ser isn’t even short for search (“serch” path?), and cnt just sounds rude.
Coders shouldn’t need to keep a translation guide open at hand to be able to read code, stopping every two lines to translate dl_serinfopathxyzagiuasgtha to something human-legible. 90% of the verbiage in good C++ code disappears into auto and the like anyway (and the fact that you need way fewer function calls than in C code), so take advantage of that and spell things out clearly.
class serpath {
    // ... [snip] ...

    std::string_view name_;

This is extremely unwise. I get that you’re trying to avoid unnecessary allocations, and you assume that the string name_ references will be owned by a serinfo object which will outlive the serpath object. But there’s nothing in the interface of either class that suggests that, let alone requires it. What’s to stop someone from doing:
auto get_particular_library_search_path(void* dlhandle)
{
    auto const info = dl::serinfo{dlhandle};

    for (auto i = 0u; i &lt; info.cnt(); ++i)
    {
        auto const sp = info.serpath(i);
    
        if (satisfies_some_condition(sp))
            return sp;
    }

    throw std::runtime_error{&quot;not found&quot;};
}

If you’re going to keep this interface, then it might be wise to use a shared_ptr rather than a unique_ptr in serinfo, and pass a copy of that to every serpath you create. Copying shared_ptr is cheap (it’s actually destroying shared pointers that is fairly costly, but not really that much). That way, even if the original serinfo is destroyed, the serpaths won’t dangle.
Alternately, perhaps, you could make serpath non-copyable and non-movable, then create an array of them at construction time, and return references to them. Or, even simpler, just return a reference to the Dl_serpath struct directly. Because why not? All you’re doing with serpath is converting the char* to a string_view… and you don’t really gain all that much by that. (In fact, you lose info, because it’s very likely the user might want to use the returned path(s) with some system call or other, but you can’t use string_view because it isn’t NUL-terminated. You’d need to convert the string_view to a string to do pretty much anything useful with it anyway.)
Frankly, I’d just use a string (or possibly a std::filesystem::path!) in serpath, and all of this risk and hassle vanishes. Yeah, sure, you’re paying for the cost of a string allocation… but compared to all the costs involved in the allocation for serinfo, all those system calls, and so on… meh. The safety and ease of use is so much more worth it compared to avoiding a single, likely small allocation on what is certainly never going to be a hot path.
class serpath {

    // ... [snip] ...

    std::string_view name_;
    unsigned int flags_;

    // ... [snip] ...

public:
    std::string_view name() const { return name_; }
    unsigned int flags() const { return flags_; }

Are getters really necessary? Why couldn’t you just do:
class serpath
{
    friend class serinfo;

    explicit serpath(::Dl_serpath path)
        : name(path.dls_name)
        , flags(path.dls_flags)
    {}

public:
    std::string name; // perhaps better: std::filesystem::path path;
    unsigned int flags;
};

There doesn’t seem to be any gain from using getter functions for this simple return-value struct.
class serinfo

The thing that strikes me most about this class is that it’s actually a container of serpaths. All of the gymnastics you do in the constructor is really just to get that array of Dl_serpath, then dole it out safely. In theory, the class could be this:
class serinfo
{
    std::vector&lt;serpath&gt; _paths; // assuming serpath uses string, not string_view

public:
    
    explicit serinfo(void* dlhandle)
    {
        // everything the same as your current constructor, except `bytes` is
        // a local variable

        // then:

        _paths.reserve(bytes-&gt;dls_cnt);
        for (auto i = 0u; i &lt; bytes-&gt;dls_cnt; ++i)
            _paths.push_back(serpath{bytes-&gt;dls_serpath[i]});
    }

    auto size() const noexcept { return _paths.size(); }

    // same for all the other useful const vector functions:
    // begin, end, operator[], etc.
};

I’m not suggesting it should be like this; there’s no point in allocating the Dl_serinfo structure, then just duplicating that allocation in the vector to copy all the data over. I’m just illustrating that it could be like this to make the point that serinfo is basically an immutable container of serpaths… so its interface should reflect that.
If you did implement a container interface, you could do stuff like:
// printing all the search paths
for (auto const&amp; [path, _] : dl::get_search_paths_for(dlhandle))
    std::cout &lt;&lt; path &lt;&lt; '\n';

Note that you don’t need the entire container interface. All you need are const versions of begin(), end(), size(), and maybe empty(). If you wanted, you could add operator[] for convenience, but that’s pretty much it.
bytes = std::make_unique&lt;std::byte[]&gt;(size.dls_size);

This might work… if alignof(::Dl_serinfo) &gt;= __STDCPP_DEFAULT_NEW_ALIGNMENT__.
See, std::malloc() always allocates with alignment alignof(std::max_align_t). Presumably ::Dl_serinfo doesn’t have any special alignment requirements (you don’t need to use std::aligned_alloc() to allocate it), so std::malloc() will never allocate under-aligned memory for ::Dl_serinfo, though it may allocate over-aligned memory, but that’s not a problem (other than a tiny amount of wasted memory, I suppose).
But new uses __STDCPP_DEFAULT_NEW_ALIGNMENT__… which may be more or less than alignof(std::max_align_t). If you did new ::Dl_serinfo directly, there would be no problem; if alignof(::Dl_serinfo) &gt; __STDCPP_DEFAULT_NEW_ALIGNMENT__, then new would automatically fix the alignment for you.
But you’re doing new std::byte[sizeof(::Dl_serinfo)] (basically). You’re not allocating a ::Dl_serinfo, you’re allocating bytes (which have an alignment of 1, though new will naturally use __STDCPP_DEFAULT_NEW_ALIGNMENT__). So you need to be sure that the memory is properly aligned.
You could just use a static assert like:
static_assert(__STDCPP_DEFAULT_NEW_ALIGNMENT__ &gt;= alignof(::Dl_serinfo));

That will just kill the compilation if there is a danger of alignment problems.
A better solution is to make sure the alignment is correct:
// in constructor:
constexpr auto alignment = std::align_val_t{alignof(::Dl_serinfo)};

bytes.reset(new (alignment) std::byte[size.dls_size]);
// note: can't use make_unique(), but doesn’t really matter

All the calls to ::dlinfo() look like this:
if (::dlinfo(/*...*/)) {
    throw dl_error(::dlerror());
}

For starters, I think you should make the error class more ergonomic:
if (::dlinfo(/*...*/)) {
    throw error{};
}

But also, simply converting the result of the ::dlinfo() call to bool doesn’t really give the best sense of what is actually going on here. I have no way of knowing whether maybe ::dlinfo() actually returns bool or something, or who knows what else, or what it means. You should generally not add redundant != or == to conditions… but the guideline is actually more subtle; it says adding redundant tests is unwise when there are only two meaningful values (like a valid pointer value and nullptr). With integers, there are often several meaningful values. It would be more clear to explain that you’re explicitly testing for “not zero” like this:
if (::dlinfo(/*...*/) != 0) {
    throw error{};
}

When I read that, even without looking up the ::dlinfo() API, I immediately understand, okay, this function returns zero on success.
One final thing: I’m not a fan of constructors that do more than just construct objects. serinfo’s constructor is more than just a constructor of serinfo objects. It’s actually doing a series of queries first, and then using the results to construct the object. That’s a violation of the single-responsibility principle.
Besides, you’re “doing a thing”… not “making a thing”. You’re “getting the search paths for a shared object”… you’re not “making a serinfo object”. The latter only happens as a side-effect of the former, but it’s not really the serinfo object you want… it’s the paths.
“Doing a thing” usually means you want a function, not a constructor. I think that’s especially important when you’re doing a thing that might fail, because then you can give the user the option of avoiding exceptions in cases where they can’t afford them. In other words, you could do:
class serinfo
{
private:
    explicit serinfo(std::unique_ptr&lt;std::byte[]&gt; data) :
        _data{std::move(data)}
    {}

    std::unique_ptr&lt;std::byte[]&gt; _data;

    friend auto get_search_paths_for(handle h) -&gt; expected&lt;serinfo&gt;;

    // ... [snip] ...
};

// using something similar to the proposed std::expected:
auto get_search_paths_for(handle h) -&gt; expected&lt;serinfo&gt;
{
    auto size = ::Dl_serinfo{};

    if (::dlinfo(h, RTLD_DI_SERINFOSIZE, &amp;size) != 0)
        return make_unexpected(dl_error{});

    auto buffer = std::unique_ptr&lt;std::byte[]&gt;{};
    try
    {
        auto p = new (std::align_val_t{alignof(::Dl_serinfo)}) std::byte[size.dls_size];

        buffer.reset(p);
    }
    catch (...)
    {
        return make_unexpected(std::current_exception());
    }

    if (::dlinfo(h, RTLD_DI_SERINFOSIZE, buffer.get()) != 0)
        return make_unexpected(dl_error{});

    if (::dlinfo(h, RTLD_DI_SERINFO, buffer.get()) != 0)
        return make_unexpected(dl_error{});

    return serinfo{std::move(buffer)};
}

With usage like:
// I absolutely cannot tolerate an exception here:
if (auto const search_paths = dl::get_search_paths_for(dlhandle); search_paths)
{
    // do something with *search_paths
}
else
{
    // report error somehow
}

// Exceptions are okay here:
auto const search_paths = *dl::get_search_paths_for(dlhandle);
// if there was an error, it will be automatically rethrown

Once you do this, you open the door to allowing no-fail default-constructed serinfo objects, which internally just have a null pointer, but externally present as an empty list of search paths. And once you have that, you can trivially implement no-fail moving and swapping, which are very handy in general. Plus, being able to default construct things is handy in a lot of ways (like reading stuff from streams, for example).
Even if you don’t use something like expected&lt;T&gt; and just throw exceptions from the function, I think it’s still a better interface than doing all the work in the constructor on top of actually constructing the object.